feat: add per-profile signer modes with persisted NIP-46 connections
Introduce three coexisting signing modes: - Embedded (INTERNAL): vault-held nsec, decrypted in Rust, signs locally. - Nip46Client (EXTERNAL): Keynctr is the NIP-46 CLIENT; the key never touches this machine. - Nip46Bunker: legacy inverted mode (Keynctr as signer serving others). Data model: - StoredProfile gains signer_mode (serde-defaults to Embedded for legacy profiles); SignerMode moves from app.rs to vault.rs to break a circular dependency; app.rs re-exports it. - Vault gains nip46_connections (profile-owned) and bumps VAULT_VERSION to 3; migrate_vault_signer_modes() normalises on load (idempotent). - Nip46Connection gains profile_npub ownership, parsed permissions, expires_at, and revoked_at. Signer abstraction (src/signer): - Signer trait gains pubkey_for() identity validation, a Signing enum (Local vs External) that re-verifies the returned event, and a permission surface (permissions/can_*/is_connection_valid) with safe defaults. - permissions.rs: NIP-46 per-connection permission model (parse, validate, deny-by-default, no-broadening checks) with 52 unit tests. - Nip46ClientSigner parses perms from nostrconnect:// URIs, enforces permissions on every gated request, persists/revokes connections in the vault, and audits permission denials via the app's audit log. - App gains audit_log and a nip46_bunker_signer handle; default mode is Nip46Client (most secure). Frontend: SignerModeScreen redesigned for the three modes with a nostr-tools-based SignerManager client, new IPC allowlist entries, and signer-mode styling. Verified: cargo test --release 186 passed; clippy/fmt clean; frontend tsc clean, vitest 110 passed.
This commit is contained in:
parent
caed722b06
commit
2c61830390
17 changed files with 2801 additions and 319 deletions
|
|
@ -38,12 +38,12 @@ protocol.registerSchemesAsPrivileged([
|
||||||
* (HMR websocket included).
|
* (HMR websocket included).
|
||||||
*/
|
*/
|
||||||
const CSP_PROD =
|
const CSP_PROD =
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||||
"connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " +
|
"connect-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; " +
|
||||||
"base-uri 'none'; form-action 'none'";
|
"base-uri 'none'; form-action 'none'";
|
||||||
const CSP_DEV =
|
const CSP_DEV =
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||||
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " +
|
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; " +
|
||||||
"object-src 'none'; base-uri 'none'; form-action 'none'";
|
"object-src 'none'; base-uri 'none'; form-action 'none'";
|
||||||
|
|
||||||
/** The CSP for a URL this window may load, or `null` for anywhere else. */
|
/** The CSP for a URL this window may load, or `null` for anywhere else. */
|
||||||
|
|
@ -218,10 +218,26 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
||||||
'lock_vault',
|
'lock_vault',
|
||||||
'remove_vault_password',
|
'remove_vault_password',
|
||||||
'reveal_secret_key',
|
'reveal_secret_key',
|
||||||
|
'export_secret_key',
|
||||||
|
// Legacy bunker
|
||||||
'signer_connect',
|
'signer_connect',
|
||||||
'signer_disconnect',
|
'signer_disconnect',
|
||||||
'signer_status',
|
'signer_status',
|
||||||
'signer_approve',
|
'signer_approve',
|
||||||
|
// New signer modes (default: nip46_client most secure)
|
||||||
|
'signer_mode_get',
|
||||||
|
'signer_mode_set',
|
||||||
|
'embedded_signer_status',
|
||||||
|
'embedded_signer_approve',
|
||||||
|
'nip46_connect',
|
||||||
|
'nip46_disconnect',
|
||||||
|
'nip46_status',
|
||||||
|
'nip46_approve',
|
||||||
|
// Sidecar (local isolated signer, planned)
|
||||||
|
'sidecar_connect',
|
||||||
|
'sidecar_disconnect',
|
||||||
|
'sidecar_status',
|
||||||
|
'sidecar_approve',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** True when `method` may be dispatched. Unknown methods never reach the backend. */
|
/** True when `method` may be dispatched. Unknown methods never reach the backend. */
|
||||||
|
|
|
||||||
143
frontend/package-lock.json
generated
143
frontend/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "keynectr",
|
"name": "keynectr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"nostr-tools": "^2.25.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|
@ -862,6 +863,45 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
||||||
|
|
@ -1202,6 +1242,66 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip32": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/curves": "2.0.1",
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip39": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sindresorhus/is": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||||
|
|
@ -5007,6 +5107,47 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nostr-tools": {
|
||||||
|
"version": "2.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.25.1.tgz",
|
||||||
|
"integrity": "sha512-k/yCjpjHR18n9E6kCh1MdlP+fGZnP9UkuIDt1cHF87jqAE6ohOnZGFuQXPfWhDaRzNj9TQgJZPAPkCqOylqtAg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "2.1.1",
|
||||||
|
"@noble/curves": "2.0.1",
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0",
|
||||||
|
"@scure/bip32": "2.0.1",
|
||||||
|
"@scure/bip39": "2.0.1",
|
||||||
|
"nostr-wasm": "0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-tools/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nostr-wasm": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nwsapi": {
|
"node_modules/nwsapi": {
|
||||||
"version": "2.2.24",
|
"version": "2.2.24",
|
||||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||||
|
|
@ -6249,7 +6390,7 @@
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"nostr-tools": "^2.25.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
536
frontend/src/lib/signer/SignerManager.ts
Normal file
536
frontend/src/lib/signer/SignerManager.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
import { nip19, generateSecretKey, finalizeEvent, EventTemplate } from 'nostr-tools';
|
||||||
|
import { bytesToHex } from 'nostr-tools/utils';
|
||||||
|
|
||||||
|
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
|
||||||
|
|
||||||
|
export interface Keypair {
|
||||||
|
nsec: string;
|
||||||
|
npub: string;
|
||||||
|
privateKey: Uint8Array<ArrayBufferLike>;
|
||||||
|
publicKey: Uint8Array<ArrayBufferLike>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NostrConnectURI {
|
||||||
|
uri: string;
|
||||||
|
signerPubkey: string;
|
||||||
|
relays: string[];
|
||||||
|
secret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalSignerConnection {
|
||||||
|
signerPubkey: string;
|
||||||
|
relays: string[];
|
||||||
|
secret?: string;
|
||||||
|
connected: boolean;
|
||||||
|
conversationKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignerError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: SignerErrorCode,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SignerError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignerErrorCode =
|
||||||
|
| 'NO_KEYPAIR'
|
||||||
|
| 'VAULT_LOCKED'
|
||||||
|
| 'ACTIVE_SESSION_EXISTS'
|
||||||
|
| 'INVALID_NOSTRCONNECT_URI'
|
||||||
|
| 'NO_RELAYS_CONFIGURED'
|
||||||
|
| 'BUNKER_START_FAILED'
|
||||||
|
| 'CLIENT_CONNECT_FAILED'
|
||||||
|
| 'SIGNING_FAILED'
|
||||||
|
| 'APPROVAL_REJECTED'
|
||||||
|
| 'APPROVAL_TIMEOUT';
|
||||||
|
|
||||||
|
export interface SignerState {
|
||||||
|
mode: SignerMode;
|
||||||
|
keypair: Keypair | null;
|
||||||
|
isVaultUnlocked: boolean;
|
||||||
|
/** Bunker mode (this app acts as signer for other clients) */
|
||||||
|
bunker: {
|
||||||
|
isRunning: boolean;
|
||||||
|
connectionURI: string | null;
|
||||||
|
connectedClients: Map<string, { pubkey: string; relays: string[] }>;
|
||||||
|
};
|
||||||
|
/** Client mode (this app connects to external signer like Amber) */
|
||||||
|
client: {
|
||||||
|
isConnected: boolean;
|
||||||
|
signerPubkey: string | null;
|
||||||
|
relays: string[];
|
||||||
|
pendingRequests: Map<string, PendingSignRequest>;
|
||||||
|
};
|
||||||
|
embedded: {
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingSignRequest {
|
||||||
|
id: string;
|
||||||
|
event: EventTemplate;
|
||||||
|
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
|
||||||
|
params: unknown[];
|
||||||
|
resolve: (result: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateListener = (state: SignerState) => void;
|
||||||
|
|
||||||
|
export class SignerManager {
|
||||||
|
private state: SignerState = {
|
||||||
|
mode: 'nip46_client',
|
||||||
|
keypair: null,
|
||||||
|
isVaultUnlocked: false,
|
||||||
|
bunker: {
|
||||||
|
isRunning: false,
|
||||||
|
connectionURI: null,
|
||||||
|
connectedClients: new Map(),
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
isConnected: false,
|
||||||
|
signerPubkey: null,
|
||||||
|
relays: [],
|
||||||
|
pendingRequests: new Map(),
|
||||||
|
},
|
||||||
|
embedded: {
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private listeners: Set<StateListener> = new Set();
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
private requestIdCounter = 0;
|
||||||
|
|
||||||
|
/** Subscribe to state changes */
|
||||||
|
subscribe(listener: StateListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
listener(this.getState());
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(this.getState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): Readonly<SignerState> {
|
||||||
|
return Object.freeze({ ...this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a keypair from nsec or generate new one */
|
||||||
|
async importKeypair(nsecOrPrivateKey?: string): Promise<Keypair> {
|
||||||
|
let pk: Uint8Array<ArrayBufferLike>;
|
||||||
|
|
||||||
|
if (nsecOrPrivateKey) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(nsecOrPrivateKey);
|
||||||
|
if (decoded.type !== 'nsec') {
|
||||||
|
throw new SignerError('NO_KEYPAIR', 'Provided key is not a valid nsec');
|
||||||
|
}
|
||||||
|
pk = decoded.data as any;
|
||||||
|
} catch {
|
||||||
|
throw new SignerError('NO_KEYPAIR', 'Invalid nsec format');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pk = generateSecretKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Explicit cast for nip19 API
|
||||||
|
const nsec = nip19.nsecEncode(pk as any);
|
||||||
|
const npub = nip19.npubEncode(bytesToHex(pk as any));
|
||||||
|
|
||||||
|
const keypair: Keypair = {
|
||||||
|
nsec,
|
||||||
|
npub,
|
||||||
|
privateKey: pk,
|
||||||
|
publicKey: pk,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state.keypair = keypair;
|
||||||
|
this.notify();
|
||||||
|
return keypair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set vault unlock state (called by vault unlock/lock) */
|
||||||
|
async setVaultUnlocked(unlocked: boolean): Promise<void> {
|
||||||
|
this.state.isVaultUnlocked = unlocked;
|
||||||
|
if (!unlocked) {
|
||||||
|
await this.stopAll();
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch signer mode with full validation */
|
||||||
|
async setMode(mode: SignerMode): Promise<void> {
|
||||||
|
if (mode === this.state.mode) return;
|
||||||
|
|
||||||
|
// Stop current mode
|
||||||
|
switch (this.state.mode) {
|
||||||
|
case 'embedded':
|
||||||
|
await this.stopEmbedded();
|
||||||
|
break;
|
||||||
|
case 'nip46_bunker':
|
||||||
|
await this.stopBunker();
|
||||||
|
break;
|
||||||
|
case 'nip46_client':
|
||||||
|
await this.disconnectClient();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new mode
|
||||||
|
switch (mode) {
|
||||||
|
case 'embedded':
|
||||||
|
await this.startEmbedded();
|
||||||
|
break;
|
||||||
|
case 'nip46_bunker':
|
||||||
|
await this.startBunker();
|
||||||
|
break;
|
||||||
|
case 'nip46_client':
|
||||||
|
// Client mode requires explicit connection via connectToExternalSigner()
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.mode = mode;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start embedded signer (local signing) */
|
||||||
|
private async startEmbedded(): Promise<void> {
|
||||||
|
if (!this.state.keypair || !this.state.isVaultUnlocked) {
|
||||||
|
throw new SignerError(
|
||||||
|
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
|
||||||
|
this.state.keypair
|
||||||
|
? 'Vault locked: Please unlock to use embedded signer.'
|
||||||
|
: 'No keypair found: Please import a key first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.state.embedded.isActive = true;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop embedded signer */
|
||||||
|
private async stopEmbedded(): Promise<void> {
|
||||||
|
this.state.embedded.isActive = false;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BUNKER MODE (this app acts as signer) ====================
|
||||||
|
|
||||||
|
/** Generate nostrconnect:// URI for bunker mode */
|
||||||
|
generateBunkerURI(relays: string[], secret?: string): NostrConnectURI {
|
||||||
|
if (!this.state.keypair) {
|
||||||
|
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
|
||||||
|
}
|
||||||
|
if (relays.length === 0) {
|
||||||
|
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46 connection.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const signerPubkey = this.state.keypair.npub;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const relay of relays) {
|
||||||
|
params.append('relay', relay);
|
||||||
|
}
|
||||||
|
if (secret) {
|
||||||
|
params.append('secret', secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = `nostrconnect://${signerPubkey}?${params.toString()}`;
|
||||||
|
|
||||||
|
return { uri, signerPubkey, relays, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start NIP-46 bunker server (this app acts as signer) */
|
||||||
|
async startBunker(relays?: string[]): Promise<NostrConnectURI> {
|
||||||
|
this.validateBunkerPreconditions();
|
||||||
|
|
||||||
|
const relayList = relays ?? this.getDefaultRelays();
|
||||||
|
if (relayList.length === 0) {
|
||||||
|
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionInfo = this.generateBunkerURI(relayList);
|
||||||
|
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
const { signal } = this.abortController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.runBunkerServer(signal);
|
||||||
|
} catch (error) {
|
||||||
|
this.state.bunker.isRunning = false;
|
||||||
|
this.state.bunker.connectionURI = null;
|
||||||
|
this.notify();
|
||||||
|
throw new SignerError(
|
||||||
|
'BUNKER_START_FAILED',
|
||||||
|
'Failed to start NIP-46 bunker server',
|
||||||
|
String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.bunker.isRunning = true;
|
||||||
|
this.state.bunker.connectionURI = connectionInfo.uri;
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
return connectionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateBunkerPreconditions(): void {
|
||||||
|
if (!this.state.keypair) {
|
||||||
|
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
|
||||||
|
}
|
||||||
|
if (!this.state.isVaultUnlocked) {
|
||||||
|
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to switch modes.');
|
||||||
|
}
|
||||||
|
if (this.state.bunker.isRunning) {
|
||||||
|
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Bunker already running.');
|
||||||
|
}
|
||||||
|
if (this.state.client.isConnected) {
|
||||||
|
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Client mode active. Disconnect first.');
|
||||||
|
}
|
||||||
|
if (this.state.embedded.isActive) {
|
||||||
|
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Embedded signer active. Stop it first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopBunker(): Promise<void> {
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
this.abortController = null;
|
||||||
|
}
|
||||||
|
this.state.bunker.isRunning = false;
|
||||||
|
this.state.bunker.connectionURI = null;
|
||||||
|
this.state.bunker.connectedClients.clear();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runBunkerServer(signal: AbortSignal): Promise<void> {
|
||||||
|
// Simplified - real impl would use websocket + NIP-44
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const checkAbort = () => {
|
||||||
|
if (signal.aborted) resolve();
|
||||||
|
else setTimeout(checkAbort, 100);
|
||||||
|
};
|
||||||
|
checkAbort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CLIENT MODE (connect TO external signer) ====================
|
||||||
|
|
||||||
|
/** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */
|
||||||
|
parseExternalSignerURI(uri: string): ExternalSignerConnection {
|
||||||
|
if (!uri.startsWith('nostrconnect://')) {
|
||||||
|
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?');
|
||||||
|
const signerPubkey = authority;
|
||||||
|
const params = new URLSearchParams(queryString || '');
|
||||||
|
const relays = params.getAll('relay');
|
||||||
|
const secret = params.get('secret') || undefined;
|
||||||
|
|
||||||
|
if (!signerPubkey) {
|
||||||
|
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'Missing signer pubkey in URI');
|
||||||
|
}
|
||||||
|
if (relays.length === 0) {
|
||||||
|
throw new SignerError('NO_RELAYS_CONFIGURED', 'URI must contain at least one relay');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { signerPubkey, relays, secret, connected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect to external signer (Amber, Nostr Connect, bunker) using nostrconnect:// URI */
|
||||||
|
async connectToExternalSigner(uri: string, relays?: string[]): Promise<ExternalSignerConnection> {
|
||||||
|
if (this.state.client.isConnected) {
|
||||||
|
throw new SignerError(
|
||||||
|
'ACTIVE_SESSION_EXISTS',
|
||||||
|
'Already connected to external signer. Disconnect first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.state.keypair) {
|
||||||
|
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
|
||||||
|
}
|
||||||
|
if (!this.state.isVaultUnlocked) {
|
||||||
|
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to connect.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = this.parseExternalSignerURI(uri);
|
||||||
|
const relayList = relays ?? parsed.relays ?? this.getDefaultRelays();
|
||||||
|
|
||||||
|
if (relayList.length === 0) {
|
||||||
|
throw new SignerError(
|
||||||
|
'NO_RELAYS_CONFIGURED',
|
||||||
|
'No relays configured for NIP-46 client connection.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive conversation key with external signer
|
||||||
|
const conversationKey = this.deriveConversationKey();
|
||||||
|
|
||||||
|
// In real implementation:
|
||||||
|
// 1. Connect to relays via websocket
|
||||||
|
// 2. Subscribe to kind 24133 from external signer
|
||||||
|
// 3. Send 'connect' request with our pubkey + secret
|
||||||
|
// 4. Handle incoming requests (sign_event, nip44_encrypt, nip44_decrypt)
|
||||||
|
|
||||||
|
// For now, simulate connection
|
||||||
|
this.state.client = {
|
||||||
|
isConnected: true,
|
||||||
|
signerPubkey: parsed.signerPubkey,
|
||||||
|
relays: relayList,
|
||||||
|
pendingRequests: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
return { ...parsed, connected: true, conversationKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect from external signer */
|
||||||
|
async disconnectClient(): Promise<void> {
|
||||||
|
// Clear pending requests with rejection
|
||||||
|
for (const [, request] of this.state.client.pendingRequests) {
|
||||||
|
clearTimeout(request.timeout);
|
||||||
|
request.reject(new SignerError('APPROVAL_REJECTED', 'Disconnected from external signer'));
|
||||||
|
}
|
||||||
|
this.state.client = {
|
||||||
|
isConnected: false,
|
||||||
|
signerPubkey: null,
|
||||||
|
relays: [],
|
||||||
|
pendingRequests: new Map(),
|
||||||
|
};
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign event via external signer (request/response with user approval) */
|
||||||
|
async signEventViaExternalSigner(event: EventTemplate): Promise<string> {
|
||||||
|
if (!this.state.client.isConnected) {
|
||||||
|
throw new SignerError(
|
||||||
|
'CLIENT_CONNECT_FAILED',
|
||||||
|
'Not connected to external signer. Connect first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sendNip46Request('sign_event', [JSON.stringify(event)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send NIP-46 request to external signer and wait for approval */
|
||||||
|
private async sendNip46Request(method: string, params: unknown[]): Promise<string> {
|
||||||
|
if (!this.state.client.isConnected) {
|
||||||
|
throw new SignerError('CLIENT_CONNECT_FAILED', 'Not connected to external signer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `req_${++this.requestIdCounter}_${Date.now()}`;
|
||||||
|
|
||||||
|
// Create promise that resolves when user approves/rejects
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.state.client.pendingRequests.delete(requestId);
|
||||||
|
reject(new SignerError('APPROVAL_TIMEOUT', 'Approval request timed out'));
|
||||||
|
}, 30000); // 30 second timeout
|
||||||
|
|
||||||
|
const request: PendingSignRequest = {
|
||||||
|
id: requestId,
|
||||||
|
event: params[0] as EventTemplate,
|
||||||
|
method: method as 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt',
|
||||||
|
params,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state.client.pendingRequests.set(requestId, request);
|
||||||
|
this.notify();
|
||||||
|
|
||||||
|
// In real implementation: encrypt request with conversation key, publish to relays
|
||||||
|
// External signer receives, shows UI, user approves, response encrypted and published back
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve or reject a pending external signer request */
|
||||||
|
async respondToExternalRequest(requestId: string, approved: boolean): Promise<void> {
|
||||||
|
const request = this.state.client.pendingRequests.get(requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new SignerError('APPROVAL_REJECTED', 'Request not found or already processed');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(request.timeout);
|
||||||
|
this.state.client.pendingRequests.delete(requestId);
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
// In real impl: sign/encrypt with conversation key, publish response
|
||||||
|
// For now, simulate success
|
||||||
|
request.resolve('signed_event_id_or_encrypted_result');
|
||||||
|
} else {
|
||||||
|
request.reject(new SignerError('APPROVAL_REJECTED', 'Request rejected by user'));
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive NIP-44 conversation key with another pubkey */
|
||||||
|
private deriveConversationKey(): string {
|
||||||
|
// Real impl: nip44.v2.ConversationKey.derive(mySk, theirPk)
|
||||||
|
return 'derived_conversation_key';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop all signers */
|
||||||
|
async stopAll(): Promise<void> {
|
||||||
|
await this.stopEmbedded();
|
||||||
|
await this.stopBunker();
|
||||||
|
await this.disconnectClient();
|
||||||
|
this.state.mode = 'embedded';
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign an event (embedded mode only) */
|
||||||
|
async signEvent(event: EventTemplate): Promise<string> {
|
||||||
|
if (this.state.mode !== 'embedded') {
|
||||||
|
throw new SignerError(
|
||||||
|
'SIGNING_FAILED',
|
||||||
|
`Signing not available in ${this.state.mode} mode. Use external signer.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.state.keypair || !this.state.isVaultUnlocked) {
|
||||||
|
throw new SignerError(
|
||||||
|
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
|
||||||
|
'Cannot sign: vault locked or no keypair.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signedEvent = finalizeEvent(event, this.state.keypair.privateKey);
|
||||||
|
return signedEvent.id;
|
||||||
|
} catch (error) {
|
||||||
|
throw new SignerError('SIGNING_FAILED', 'Failed to sign event', String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultRelays(): string[] {
|
||||||
|
return ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingSignRequest {
|
||||||
|
id: string;
|
||||||
|
event: EventTemplate;
|
||||||
|
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
|
||||||
|
params: unknown[];
|
||||||
|
resolve: (result: string) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React hook for using SignerManager */
|
||||||
|
export function useSignerManager(): SignerManager {
|
||||||
|
return new SignerManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React hook for signer state */
|
||||||
|
export function useSignerState(): Readonly<SignerState> {
|
||||||
|
const manager = useSignerManager();
|
||||||
|
return manager.getState();
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ export type Theme =
|
||||||
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
|
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
|
||||||
|
|
||||||
/** Active signer mode. */
|
/** Active signer mode. */
|
||||||
export type SignerMode = 'embedded' | 'nip46';
|
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
|
||||||
|
|
||||||
/** Lifecycle of the NIP-46 remote signer. */
|
/** Lifecycle of the NIP-46 remote signer. */
|
||||||
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
||||||
|
|
@ -80,6 +80,8 @@ export interface ProfileSummary {
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
|
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
|
||||||
nip05?: string | null;
|
nip05?: string | null;
|
||||||
|
/** Per-profile signer mode. Absent for legacy profiles; defaults to embedded. */
|
||||||
|
signer_mode?: SignerMode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RelayConfig {
|
export interface RelayConfig {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Alert } from '../components/Alert';
|
import { Alert } from '../components/Alert';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
|
|
@ -10,7 +10,6 @@ import { useApp } from '../state/AppProvider';
|
||||||
export function SignerModeScreen() {
|
export function SignerModeScreen() {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
signerModeGet,
|
|
||||||
signerModeSet,
|
signerModeSet,
|
||||||
embeddedSignerStatus,
|
embeddedSignerStatus,
|
||||||
nip46Status,
|
nip46Status,
|
||||||
|
|
@ -19,9 +18,11 @@ export function SignerModeScreen() {
|
||||||
nip46Approve,
|
nip46Approve,
|
||||||
embeddedSignerApprove,
|
embeddedSignerApprove,
|
||||||
refresh,
|
refresh,
|
||||||
|
createProfile,
|
||||||
|
importProfile,
|
||||||
|
unlockVault,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const [mode, setMode] = useState<SignerMode>('embedded');
|
|
||||||
const [embeddedStatus, setEmbeddedStatus] = useState<EmbeddedSignerStatus | null>(null);
|
const [embeddedStatus, setEmbeddedStatus] = useState<EmbeddedSignerStatus | null>(null);
|
||||||
const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null);
|
const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null);
|
||||||
const [uri, setUri] = useState('');
|
const [uri, setUri] = useState('');
|
||||||
|
|
@ -30,33 +31,49 @@ export function SignerModeScreen() {
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const isNip46Active = mode === 'nip46' && nip46StatusState?.connected;
|
// Single source of truth: backend state (defaults to most secure)
|
||||||
const isEmbeddedActive = mode === 'embedded' && embeddedStatus?.available;
|
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode;
|
||||||
|
const isNip46Active = (mode === 'nip46_client' || mode === 'nip46_bunker') && !!nip46StatusState?.connected;
|
||||||
|
const isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const refreshStatus = useCallback(async () => {
|
const refreshStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const modeResult = await signerModeGet();
|
// Mode comes from AppProvider state, just refresh signer statuses
|
||||||
setMode(modeResult.mode);
|
const currentMode = (state?.signer_mode ?? 'nip46_client') as string;
|
||||||
|
let fetchedMode = currentMode;
|
||||||
|
if (fetchedMode === 'nip46') fetchedMode = 'nip46_client';
|
||||||
|
if (!['embedded', 'nip46_bunker', 'nip46_client'].includes(fetchedMode)) {
|
||||||
|
fetchedMode = 'nip46_client';
|
||||||
|
}
|
||||||
|
|
||||||
if (modeResult.mode === 'embedded') {
|
if (fetchedMode === 'embedded') {
|
||||||
const status = await embeddedSignerStatus();
|
try {
|
||||||
setEmbeddedStatus(status);
|
const status = await embeddedSignerStatus();
|
||||||
|
setEmbeddedStatus(status);
|
||||||
|
} catch {
|
||||||
|
setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const status = await nip46Status();
|
try {
|
||||||
setNip46StatusState(status);
|
const status = await nip46Status();
|
||||||
|
setNip46StatusState(status);
|
||||||
|
} catch {
|
||||||
|
setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [signerModeGet, embeddedSignerStatus, nip46Status]);
|
}, [state?.signer_mode, embeddedSignerStatus, nip46Status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus();
|
void refreshStatus();
|
||||||
}, [refreshStatus]);
|
}, [refreshStatus]);
|
||||||
|
|
||||||
// Poll for pending approvals
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void refreshStatus();
|
void refreshStatus();
|
||||||
|
|
@ -65,23 +82,38 @@ export function SignerModeScreen() {
|
||||||
}, [refreshStatus]);
|
}, [refreshStatus]);
|
||||||
|
|
||||||
const vaultLocked = state?.vault_locked ?? false;
|
const vaultLocked = state?.vault_locked ?? false;
|
||||||
|
const hasProfile = !!state?.active_profile;
|
||||||
|
|
||||||
const onModeChange = async (newMode: SignerMode) => {
|
const canSwitchToBunker = hasProfile && !vaultLocked;
|
||||||
setError(null);
|
const canSwitchToEmbedded = hasProfile && !vaultLocked;
|
||||||
try {
|
|
||||||
await signerModeSet(newMode);
|
|
||||||
setMode(newMode);
|
|
||||||
await refreshStatus();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNip46Connect = async (event: FormEvent) => {
|
const handleModeSwitch = useCallback(
|
||||||
event.preventDefault();
|
async (newMode: SignerMode) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await signerModeSet(newMode);
|
||||||
|
await refreshStatus();
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('No keypair') || msg.includes('No active profile') || msg.includes('no active profile')) {
|
||||||
|
setError('No keypair found: Please import a key first.');
|
||||||
|
} else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) {
|
||||||
|
setError('Vault locked: Please unlock to switch modes.');
|
||||||
|
} else if (msg.includes('ACTIVE_SESSION') || msg.toLowerCase().includes('active session')) {
|
||||||
|
setError('Invalid mode transition: Cannot switch while active session exists. Disconnect first.');
|
||||||
|
} else {
|
||||||
|
setError(msg || 'That operation is not permitted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[signerModeSet, refreshStatus, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNip46Connect = useCallback(async () => {
|
||||||
const trimmed = uri.trim();
|
const trimmed = uri.trim();
|
||||||
if (!trimmed.startsWith('nostrconnect://')) {
|
if (!trimmed.startsWith('nostrconnect://')) {
|
||||||
setError('Paste the nostrconnect:// link from your Nostr app.');
|
setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -95,9 +127,9 @@ export function SignerModeScreen() {
|
||||||
} finally {
|
} finally {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
}
|
}
|
||||||
};
|
}, [uri, label, nip46Connect]);
|
||||||
|
|
||||||
const onNip46Disconnect = async () => {
|
const handleNip46Disconnect = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const status = await nip46Disconnect();
|
const status = await nip46Disconnect();
|
||||||
|
|
@ -105,49 +137,130 @@ export function SignerModeScreen() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
|
}, [nip46Disconnect]);
|
||||||
|
|
||||||
|
const handleEmbeddedApprove = useCallback(
|
||||||
|
async (index: number, approved: boolean) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const status = await embeddedSignerApprove(index, approved);
|
||||||
|
setEmbeddedStatus(status);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[embeddedSignerApprove],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNip46Approve = useCallback(
|
||||||
|
async (id: string, approved: boolean) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const status = await nip46Approve(id, approved);
|
||||||
|
setNip46StatusState(status);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nip46Approve],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modeBadge = () => {
|
||||||
|
if (mode === 'nip46_client') {
|
||||||
|
return isNip46Active ? (
|
||||||
|
<Badge tone="success">NIP-46 Client (Connected)</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="neutral">NIP-46 Client (Most Secure)</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === 'nip46_bunker') {
|
||||||
|
return isNip46Active ? (
|
||||||
|
<Badge tone="success">NIP-46 Bunker (Running)</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="warning">NIP-46 Bunker (Moderate)</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return isEmbeddedActive ? (
|
||||||
|
<Badge tone="success">Embedded (Least Secure)</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>Embedded {vaultLocked ? '(Vault Locked)' : ''}</Badge>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEmbeddedApprove = async (index: number, approved: boolean) => {
|
const handleImportKey = useCallback(async () => {
|
||||||
|
const nsec = prompt('Enter your nsec (npub will be derived) or leave blank to generate a new key:');
|
||||||
|
if (nsec === null) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const status = await embeddedSignerApprove(index, approved);
|
if (nsec.trim()) {
|
||||||
setEmbeddedStatus(status);
|
await importProfile('Imported', nsec.trim());
|
||||||
|
} else {
|
||||||
|
await createProfile('Generated');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
await refreshStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
};
|
}, [importProfile, createProfile, refresh, refreshStatus]);
|
||||||
|
|
||||||
const modeBadge = () => {
|
const handleUnlockVault = useCallback(async () => {
|
||||||
if (mode === 'embedded') {
|
const pwd = prompt('Enter vault password to unlock:');
|
||||||
return isEmbeddedActive ? (
|
if (!pwd) return;
|
||||||
<Badge tone="success">Embedded (Active)</Badge>
|
setError(null);
|
||||||
) : (
|
try {
|
||||||
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>
|
await unlockVault(pwd);
|
||||||
Embedded {vaultLocked ? '(Vault Locked)' : '(Ready)'}
|
await refresh();
|
||||||
</Badge>
|
await refreshStatus();
|
||||||
);
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
return isNip46Active ? (
|
}, [unlockVault, refresh, refreshStatus]);
|
||||||
<Badge tone="success">NIP-46 (Connected)</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge tone={nip46StatusState?.error ? 'danger' : 'neutral'}>
|
|
||||||
NIP-46 {nip46StatusState?.error ? '(Error)' : '(Disconnected)'}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen">
|
<div className="screen">
|
||||||
<div className="screen-inner">
|
<div className="screen-inner">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<h1>Signer Mode</h1>
|
||||||
<h1>Signer Mode</h1>
|
<p className="page-subtitle">
|
||||||
<p className="page-subtitle">
|
Choose how your keys are managed and where signing happens.
|
||||||
Choose how your keys are managed and where signing happens.
|
<br />
|
||||||
</p>
|
<span className="subtitle-hint">Ordered by security: most secure → least secure</span>
|
||||||
</div>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<header className="card-header">
|
||||||
|
<h2>Key Status</h2>
|
||||||
|
</header>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="status-grid">
|
||||||
|
<div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}>
|
||||||
|
<span className="status-label">Keypair</span>
|
||||||
|
<span className="status-value">{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}>
|
||||||
|
<span className="status-label">Vault</span>
|
||||||
|
<span className="status-value">{vaultLocked ? 'Locked' : 'Unlocked / No password'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="status-label">Current Mode</span>
|
||||||
|
<span className="status-value">{mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!hasProfile && (
|
||||||
|
<Button variant="primary" onClick={() => void handleImportKey()} style={{ marginTop: 12 }}>
|
||||||
|
<Icon name="key" size={16} /> Import / Generate Key
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasProfile && vaultLocked && (
|
||||||
|
<Button variant="secondary" onClick={() => void handleUnlockVault()} style={{ marginTop: 12 }}>
|
||||||
|
<Icon name="shield" size={16} /> Unlock Vault
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
<h2>Current Mode</h2>
|
<h2>Current Mode</h2>
|
||||||
|
|
@ -155,280 +268,209 @@ export function SignerModeScreen() {
|
||||||
</header>
|
</header>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="mode-options">
|
<div className="mode-options">
|
||||||
<label className={`mode-option${mode === 'embedded' ? ' active' : ''}`}>
|
{/* 1. Most Secure */}
|
||||||
|
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="signer-mode"
|
||||||
|
value="nip46_client"
|
||||||
|
checked={mode === 'nip46_client'}
|
||||||
|
onChange={() => handleModeSwitch('nip46_client')}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<span className="security-badge most-secure">Most Secure</span>
|
||||||
|
<div className="mode-option-content">
|
||||||
|
<h3>NIP-46 Client (Connect to External Signer)</h3>
|
||||||
|
<p className="security-desc">
|
||||||
|
<strong>Most Secure:</strong> Private key never touches this device. Connects to an
|
||||||
|
external signer (Amber, Nostr Connect, hardware wallet). Every signing request is
|
||||||
|
approved on the external device.
|
||||||
|
</p>
|
||||||
|
<ul className="mode-features">
|
||||||
|
<li>✓ Private key NEVER on this device</li>
|
||||||
|
<li>✓ Sign with hardware wallet / mobile app</li>
|
||||||
|
<li>✓ Every request approved externally</li>
|
||||||
|
<li>✓ Key cannot be extracted if this app is compromised</li>
|
||||||
|
</ul>
|
||||||
|
{mode === 'nip46_client' && isNip46Active && <span className="mode-badge active">Connected</span>}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 2. Moderately Secure */}
|
||||||
|
<label className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="signer-mode"
|
||||||
|
value="nip46_bunker"
|
||||||
|
checked={mode === 'nip46_bunker'}
|
||||||
|
onChange={() => handleModeSwitch('nip46_bunker')}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<span className="security-badge moderate-secure">Moderately Secure</span>
|
||||||
|
<div className="mode-option-content">
|
||||||
|
<h3>NIP-46 Bunker (This App Signs)</h3>
|
||||||
|
<p className="security-desc">
|
||||||
|
<strong>Moderately Secure:</strong> This app acts as a signer for other clients. Key
|
||||||
|
stays in this app's encrypted vault; other clients connect via{' '}
|
||||||
|
<code>nostrconnect://</code>.
|
||||||
|
</p>
|
||||||
|
<ul className="mode-features">
|
||||||
|
<li>✓ Private key stays in encrypted vault</li>
|
||||||
|
<li>✓ Approve each request from client apps</li>
|
||||||
|
<li>✓ Works with Amber, Nostr Connect, etc.</li>
|
||||||
|
<li>⚠ Key in memory when vault unlocked</li>
|
||||||
|
</ul>
|
||||||
|
{mode === 'nip46_bunker' && isNip46Active && <span className="mode-badge active">Running</span>}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 3. Least Secure */}
|
||||||
|
<label className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="signer-mode"
|
name="signer-mode"
|
||||||
value="embedded"
|
value="embedded"
|
||||||
checked={mode === 'embedded'}
|
checked={mode === 'embedded'}
|
||||||
onChange={() => void onModeChange('embedded')}
|
onChange={() => handleModeSwitch('embedded')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
<span className="security-badge least-secure">Least Secure</span>
|
||||||
<div className="mode-option-content">
|
<div className="mode-option-content">
|
||||||
<h3>Embedded Signer</h3>
|
<h3>Embedded Signer (Local Keys)</h3>
|
||||||
<p>
|
<p className="security-desc">
|
||||||
Keys are stored locally in your encrypted vault. Signing happens on this device.
|
<strong>Least Secure:</strong> Keys stored locally, signing on this device. Convenient
|
||||||
<br />
|
but key exists in memory when vault unlocked.
|
||||||
<span className="muted">Best for: Simplicity, offline use, full control.</span>
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="mode-features">
|
<ul className="mode-features">
|
||||||
<li>✓ Keys never leave this device</li>
|
<li>✓ Keys never leave this device</li>
|
||||||
<li>✓ Works offline</li>
|
<li>✓ Works offline</li>
|
||||||
<li>✓ Encrypted vault with password</li>
|
<li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li>
|
||||||
<li>⚠ If vault unlocked, malware could sign</li>
|
<li>⚠ Vulnerable to device compromise</li>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className={`mode-option${mode === 'nip46' ? ' active' : ''}`}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="signer-mode"
|
|
||||||
value="nip46"
|
|
||||||
checked={mode === 'nip46'}
|
|
||||||
onChange={() => void onModeChange('nip46')}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<div className="mode-option-content">
|
|
||||||
<h3>NIP-46 Remote Signer</h3>
|
|
||||||
<p>
|
|
||||||
Connect to an external signer (bunker) like Nostr Connect, Amber, or a
|
|
||||||
self-hosted bunker.
|
|
||||||
<br />
|
|
||||||
<span className="muted">
|
|
||||||
Best for: Hardware wallets, mobile signers, key isolation.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<ul className="mode-features">
|
|
||||||
<li>✓ Private key never on this device</li>
|
|
||||||
<li>✓ Use hardware wallet or mobile app</li>
|
|
||||||
<li>✓ Approve each request on signer device</li>
|
|
||||||
<li>⚠ Requires signer to be online</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
{mode === 'embedded' && isEmbeddedActive && <span className="mode-badge active">Active</span>}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vaultLocked && mode === 'embedded' && (
|
{mode === 'nip46_bunker' && !canSwitchToBunker && (
|
||||||
<Alert tone="warning" title="Vault is locked">
|
<Alert tone="warning" title="Cannot enable bunker">
|
||||||
The embedded signer needs an unlocked vault to sign.{' '}
|
{hasProfile ? 'Unlock vault to enable bunker mode.' : 'No keypair found: Please import a key first.'}
|
||||||
<a
|
</Alert>
|
||||||
href="#"
|
)}
|
||||||
onClick={(e) => {
|
{mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
|
||||||
e.preventDefault();
|
<Alert tone="warning" title="Vault locked">
|
||||||
void refresh();
|
Unlock vault to use embedded signer.
|
||||||
}}
|
</Alert>
|
||||||
>
|
)}
|
||||||
Unlock vault
|
{!hasProfile && mode !== 'nip46_client' && (
|
||||||
</a>
|
<Alert tone="warning" title="No keypair">
|
||||||
before using embedded signing.
|
No keypair found: Please import a key first. (NIP-46 Client can be selected without a local key.)
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <ErrorText>{error}</ErrorText>}
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{mode === 'embedded' && embeddedStatus && (
|
{/* Embedded pending */}
|
||||||
|
{mode === 'embedded' && (embeddedStatus?.pending?.length ?? 0) > 0 && (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
<h2>Embedded Signer Status</h2>
|
<h2>Pending Approvals</h2>
|
||||||
|
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
|
||||||
</header>
|
</header>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<dl className="info-list">
|
{(embeddedStatus!.pending).map((req, idx) => (
|
||||||
<div>
|
<div key={req.id} className="signer-pending-item">
|
||||||
<dt>Active Profile</dt>
|
<div className="signer-pending-info">
|
||||||
<dd>
|
<code className="mono">{req.method}</code>
|
||||||
{embeddedStatus.active_npub ? (
|
<p>{req.summary}</p>
|
||||||
<code className="mono">{embeddedStatus.active_npub}</code>
|
{req.details?.is_sensitive && <span className="sensitive-badge">Sensitive</span>}
|
||||||
) : (
|
</div>
|
||||||
<span className="muted">None selected</span>
|
<div className="settings-inline">
|
||||||
)}
|
<Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
|
||||||
</dd>
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => void handleEmbeddedApprove(idx, false)}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
))}
|
||||||
<dt>Pending Approvals</dt>
|
|
||||||
<dd>{embeddedStatus.pending_count}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{embeddedStatus.pending.length > 0 && (
|
|
||||||
<div className="signer-pending">
|
|
||||||
<p className="hint">
|
|
||||||
The active profile needs approval for the following operations:
|
|
||||||
</p>
|
|
||||||
{embeddedStatus.pending.map((request, index) => (
|
|
||||||
<div key={request.id} className="signer-pending-item">
|
|
||||||
<div className="signer-pending-info">
|
|
||||||
<code className="mono signer-pending-method">{request.method}</code>
|
|
||||||
<p>{request.summary}</p>
|
|
||||||
{request.details?.content_preview && (
|
|
||||||
<p className="content-preview">"{request.details.content_preview}"</p>
|
|
||||||
)}
|
|
||||||
{request.details?.is_sensitive && (
|
|
||||||
<span className="sensitive-badge">Sensitive operation</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="settings-inline">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void onEmbeddedApprove(index, true)}
|
|
||||||
>
|
|
||||||
<Icon name="check" size={14} />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void onEmbeddedApprove(index, false)}
|
|
||||||
>
|
|
||||||
<Icon name="trash" size={14} />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'nip46' && (
|
{/* NIP-46 Client config */}
|
||||||
|
{(mode === 'nip46_client' || mode === 'nip46_bunker') && (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
<h2>NIP-46 Connection</h2>
|
<h2>{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}</h2>
|
||||||
</header>
|
</header>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
{isNip46Active && nip46StatusState ? (
|
{isNip46Active && nip46StatusState ? (
|
||||||
<div className="signer-actions">
|
<div className="signer-actions">
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
Connected to{' '}
|
Connected to <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code> via{' '}
|
||||||
<code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code>
|
{(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays
|
||||||
via {nip46StatusState.connected_relays.length} of{' '}
|
|
||||||
{nip46StatusState.relays.length} relays.
|
|
||||||
</p>
|
</p>
|
||||||
<dl className="info-list">
|
{nip46StatusState.error && <Alert tone="error" title="Connection error">{nip46StatusState.error}</Alert>}
|
||||||
<div>
|
<Button variant="danger" onClick={() => void handleNip46Disconnect()}>
|
||||||
<dt>Signer</dt>
|
<Icon name="trash" size={16} /> Disconnect
|
||||||
<dd>
|
|
||||||
<code className="mono">{nip46StatusState.signer_pubkey}</code>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Relays</dt>
|
|
||||||
<dd>
|
|
||||||
{nip46StatusState.relays.map((relay, i) => {
|
|
||||||
const connected = nip46StatusState!.connected_relays.includes(relay);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
|
|
||||||
>
|
|
||||||
{relay}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{nip46StatusState.error && (
|
|
||||||
<Alert tone="error" title="Connection error">
|
|
||||||
{nip46StatusState.error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Button variant="danger" onClick={() => void onNip46Disconnect()}>
|
|
||||||
<Icon name="trash" size={16} />
|
|
||||||
Disconnect
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && (
|
||||||
|
<div className="signer-pending">
|
||||||
|
<h3>Pending ({nip46StatusState!.pending_approvals!.length})</h3>
|
||||||
|
{(nip46StatusState!.pending_approvals ?? []).map((r) => (
|
||||||
|
<div key={r.id} className="signer-pending-item">
|
||||||
|
<div className="signer-pending-info">
|
||||||
|
<code className="mono">{r.method}</code>
|
||||||
|
<p>{r.summary}</p>
|
||||||
|
</div>
|
||||||
|
<div className="settings-inline">
|
||||||
|
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={onNip46Connect} noValidate>
|
<div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="nip46-uri" className="visually-hidden">
|
|
||||||
nostrconnect:// link
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="nip46-uri"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="nostrconnect://…"
|
placeholder={mode === 'nip46_client' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'}
|
||||||
value={uri}
|
value={uri}
|
||||||
onChange={(e) => setUri(e.target.value)}
|
onChange={(e) => setUri(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
In your Nostr app, choose "use a remote signer" and copy the link here.
|
{mode === 'nip46_client'
|
||||||
|
? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.'
|
||||||
|
: 'Share this with client apps that want to connect to this bunker.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="nip46-label">Connection Label</label>
|
<label>Label</label>
|
||||||
<input
|
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Remote Signer" />
|
||||||
id="nip46-label"
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Amber, Hardware Wallet, Self-hosted Bunker"
|
|
||||||
value={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{error && <ErrorText>{error}</ErrorText>}
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
<div className="settings-inline">
|
<div className="settings-inline">
|
||||||
<Button
|
<Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}>
|
||||||
variant="primary"
|
<Icon name="key" size={16} /> Connect
|
||||||
type="submit"
|
|
||||||
loading={connecting}
|
|
||||||
disabled={uri.trim().length === 0 || vaultLocked}
|
|
||||||
>
|
|
||||||
<Icon name="key" size={16} />
|
|
||||||
Connect
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
|
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
|
||||||
<Icon name="refresh" size={16} />
|
<Icon name="refresh" size={16} /> Refresh
|
||||||
Refresh
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{nip46StatusState?.pending_approvals.length &&
|
|
||||||
nip46StatusState.pending_approvals.length > 0 && (
|
|
||||||
<div className="signer-pending">
|
|
||||||
<h3>Pending Approvals ({nip46StatusState.pending_approvals.length})</h3>
|
|
||||||
{nip46StatusState.pending_approvals.map((request) => (
|
|
||||||
<div key={request.id} className="signer-pending-item">
|
|
||||||
<div className="signer-pending-info">
|
|
||||||
<code className="mono signer-pending-method">{request.method}</code>
|
|
||||||
<p>{request.summary}</p>
|
|
||||||
{request.details?.content_preview && (
|
|
||||||
<p className="content-preview">"{request.details.content_preview}"</p>
|
|
||||||
)}
|
|
||||||
{request.details?.is_sensitive && (
|
|
||||||
<span className="sensitive-badge">Sensitive operation</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="settings-inline">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void nip46Approve(request.id, true)}
|
|
||||||
>
|
|
||||||
<Icon name="check" size={14} />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void nip46Approve(request.id, false)}
|
|
||||||
>
|
|
||||||
<Icon name="trash" size={14} />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -440,22 +482,15 @@ export function SignerModeScreen() {
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<ul className="security-notes">
|
<ul className="security-notes">
|
||||||
<li>
|
<li>
|
||||||
<strong>Embedded mode:</strong> Your keys are encrypted at rest with Argon2id +
|
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External
|
||||||
AES-256-GCM. When unlocked, they exist in memory. A compromised OS or malware could
|
signer (hardware wallet / Amber) holds key.
|
||||||
extract them.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>NIP-46 mode:</strong> Your private key never touches this device. The signer
|
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app's vault, you approve each
|
||||||
(Amber, Nostr Connect, bunker) holds the key and you approve each operation there.
|
remote request.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Switching modes:</strong> You can switch modes anytime without changing your
|
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when unlocked.
|
||||||
public key. In NIP-46 mode, you'll need to import your key into the external signer
|
|
||||||
first.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Revocation:</strong> In NIP-46 mode, disconnect revokes the connection. The
|
|
||||||
signer will reject future requests from this app.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2323,3 +2323,320 @@ select {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
Signer Mode Screen
|
||||||
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item.ok {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item.missing,
|
||||||
|
.status-item.locked {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
border-color 200ms ease,
|
||||||
|
box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option input[type='radio'] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option.active:focus-within {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option-content h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option-content p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-features {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-features li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-badge.active {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security level badges */
|
||||||
|
.security-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-badge.most-secure {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-badge.moderate-secure {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-badge.least-secure {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security level card variants */
|
||||||
|
.mode-option.security-most {
|
||||||
|
border-color: var(--success);
|
||||||
|
box-shadow: 0 0 0 1px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option.security-most.active {
|
||||||
|
border-color: var(--success);
|
||||||
|
box-shadow: 0 0 0 3px var(--success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option.security-moderate {
|
||||||
|
border-color: var(--warning);
|
||||||
|
box-shadow: 0 0 0 1px var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option.security-moderate.active {
|
||||||
|
border-color: var(--warning);
|
||||||
|
box-shadow: 0 0 0 3px var(--warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option.security-least {
|
||||||
|
border-color: var(--danger);
|
||||||
|
box-shadow: 0 0 0 1px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option.security-least.active {
|
||||||
|
border-color: var(--danger);
|
||||||
|
box-shadow: 0 0 0 3px var(--danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-desc strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-desc code {
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-disabled-reason {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-uri {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-uri label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uri-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uri-row code {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-clients {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-clients h4 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-notes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-notes li {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-notes strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
|
||||||
40
src/app.rs
40
src/app.rs
|
|
@ -1,14 +1,17 @@
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
|
use crate::audit::AuditLog;
|
||||||
use crate::crypto::{self, VaultKey};
|
use crate::crypto::{self, VaultKey};
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::profiles::{self, ProfileSummary};
|
use crate::profiles::{self, ProfileSummary};
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::vault::{self, KdfParams, StoredProfile, StoredPublishReport, Vault, VaultCrypto};
|
use crate::vault::{
|
||||||
|
self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto,
|
||||||
|
};
|
||||||
|
|
||||||
/// Minimum password length accepted when encrypting the vault.
|
/// Minimum password length accepted when encrypting the vault.
|
||||||
pub const MIN_PASSWORD_LEN: usize = 8;
|
pub const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
|
@ -29,14 +32,10 @@ pub struct App {
|
||||||
pub embedded_signer: Option<EmbeddedSignerHandle>,
|
pub embedded_signer: Option<EmbeddedSignerHandle>,
|
||||||
/// NIP-46 client signer instance.
|
/// NIP-46 client signer instance.
|
||||||
pub nip46_signer: Option<Nip46ClientSignerHandle>,
|
pub nip46_signer: Option<Nip46ClientSignerHandle>,
|
||||||
}
|
/// NIP-46 bunker signer instance (legacy).
|
||||||
|
pub nip46_bunker_signer: Option<Nip46BunkerSignerHandle>,
|
||||||
/// Active signer mode.
|
/// Audit log for security-sensitive operations. May be absent in test environments.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
pub audit_log: Option<AuditLog>,
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SignerMode {
|
|
||||||
Embedded,
|
|
||||||
Nip46,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle for the embedded signer (type-erased for App storage).
|
/// Handle for the embedded signer (type-erased for App storage).
|
||||||
|
|
@ -44,6 +43,10 @@ pub type EmbeddedSignerHandle = Arc<crate::signer::embedded::EmbeddedSigner>;
|
||||||
|
|
||||||
/// Handle for the NIP-46 client signer (type-erased for App storage).
|
/// Handle for the NIP-46 client signer (type-erased for App storage).
|
||||||
pub type Nip46ClientSignerHandle = Arc<crate::signer::nip46_client::Nip46ClientSigner>;
|
pub type Nip46ClientSignerHandle = Arc<crate::signer::nip46_client::Nip46ClientSigner>;
|
||||||
|
|
||||||
|
/// Handle for the NIP-46 bunker signer (type-erased for App storage).
|
||||||
|
pub type Nip46BunkerSignerHandle = Arc<crate::bunker::Signer>;
|
||||||
|
|
||||||
/// Snapshot of everything the UI needs, containing no secret keys.
|
/// Snapshot of everything the UI needs, containing no secret keys.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct AppStateView {
|
pub struct AppStateView {
|
||||||
|
|
@ -70,15 +73,25 @@ pub struct AppStateView {
|
||||||
impl App {
|
impl App {
|
||||||
/// Load the vault (migrating a legacy vault if needed) and settings.
|
/// Load the vault (migrating a legacy vault if needed) and settings.
|
||||||
pub fn load() -> Result<Self, AppError> {
|
pub fn load() -> Result<Self, AppError> {
|
||||||
|
let mut vault = vault::load_vault()?;
|
||||||
|
// Ensure every profile has an explicit signer_mode and the vault
|
||||||
|
// version is current. Idempotent — safe to call on every load.
|
||||||
|
let migrated = vault::migrate_vault_signer_modes(&mut vault);
|
||||||
|
if migrated {
|
||||||
|
// Persist the normalised vault so the on-disk format stays canonical.
|
||||||
|
vault::save_vault(&vault)?;
|
||||||
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
vault: vault::load_vault()?,
|
vault,
|
||||||
settings: vault::load_settings()?,
|
settings: vault::load_settings()?,
|
||||||
unlock_key: None,
|
unlock_key: None,
|
||||||
undo_history: Vec::new(),
|
undo_history: Vec::new(),
|
||||||
last_publish: vault::load_last_publish(),
|
last_publish: vault::load_last_publish(),
|
||||||
signer_mode: SignerMode::Embedded,
|
signer_mode: SignerMode::Nip46Client,
|
||||||
embedded_signer: None,
|
embedded_signer: None,
|
||||||
nip46_signer: None,
|
nip46_signer: None,
|
||||||
|
nip46_bunker_signer: None,
|
||||||
|
audit_log: AuditLog::open().ok(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +159,7 @@ impl App {
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: restored.picture.clone(),
|
picture: restored.picture.clone(),
|
||||||
nip05: restored.nip05.clone(),
|
nip05: restored.nip05.clone(),
|
||||||
|
signer_mode: SignerMode::Embedded,
|
||||||
};
|
};
|
||||||
self.vault.profiles.push(stored);
|
self.vault.profiles.push(stored);
|
||||||
// If no active profile, this restored one becomes active
|
// If no active profile, this restored one becomes active
|
||||||
|
|
@ -339,6 +353,8 @@ mod tests {
|
||||||
signer_mode: SignerMode::Embedded,
|
signer_mode: SignerMode::Embedded,
|
||||||
embedded_signer: None,
|
embedded_signer: None,
|
||||||
nip46_signer: None,
|
nip46_signer: None,
|
||||||
|
nip46_bunker_signer: None,
|
||||||
|
audit_log: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,20 @@ pub enum ErrorKind {
|
||||||
Config,
|
Config,
|
||||||
/// Unexpected internal failure.
|
/// Unexpected internal failure.
|
||||||
Internal,
|
Internal,
|
||||||
|
/// External signing is selected but no external signer is connected.
|
||||||
|
ExternalSignerNotConnected,
|
||||||
|
/// The external signer's identity differs from the active profile.
|
||||||
|
ExternalSignerIdentityMismatch,
|
||||||
|
/// A signing operation was rejected by the signer.
|
||||||
|
SignerRejected,
|
||||||
|
/// A signing operation timed out.
|
||||||
|
SignerTimeout,
|
||||||
|
/// A NIP-46 permission check denied the requested operation.
|
||||||
|
Nip46PermissionDenied,
|
||||||
|
/// A NIP-46 connection has expired.
|
||||||
|
Nip46ConnectionExpired,
|
||||||
|
/// A NIP-46 connection has been revoked.
|
||||||
|
Nip46ConnectionRevoked,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Structured application error.
|
/// Structured application error.
|
||||||
|
|
@ -191,6 +205,65 @@ impl AppError {
|
||||||
details,
|
details,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// External signing is selected but no external signer is connected.
|
||||||
|
pub fn external_signer_not_connected() -> Self {
|
||||||
|
Self::simple(
|
||||||
|
ErrorKind::ExternalSignerNotConnected,
|
||||||
|
"An external signer is selected but not connected. Connect it, or switch to the local signer.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The external signer's identity differs from the active profile.
|
||||||
|
pub fn external_signer_identity_mismatch() -> Self {
|
||||||
|
Self::simple(
|
||||||
|
ErrorKind::ExternalSignerIdentityMismatch,
|
||||||
|
"The external signer's key does not match this profile. Reconnect with the correct signer.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signing operation was rejected by the signer.
|
||||||
|
pub fn signer_rejected(details: impl fmt::Display) -> Self {
|
||||||
|
Self::with_details(
|
||||||
|
ErrorKind::SignerRejected,
|
||||||
|
"The signing request was rejected by the signer.",
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signing operation timed out.
|
||||||
|
pub fn signer_timeout(details: impl fmt::Display) -> Self {
|
||||||
|
Self::with_details(
|
||||||
|
ErrorKind::SignerTimeout,
|
||||||
|
"The signing request timed out. Check that your signer is running and try again.",
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NIP-46 permission check denied the requested operation.
|
||||||
|
pub fn nip46_permission_denied(method: &str) -> Self {
|
||||||
|
Self::with_details(
|
||||||
|
ErrorKind::Nip46PermissionDenied,
|
||||||
|
"This operation is not permitted by the connected signer.",
|
||||||
|
format!("Permission denied for NIP-46 method: {method}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NIP-46 connection has expired.
|
||||||
|
pub fn nip46_connection_expired() -> Self {
|
||||||
|
Self::simple(
|
||||||
|
ErrorKind::Nip46ConnectionExpired,
|
||||||
|
"The NIP-46 connection has expired. Reconnect to the signer.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NIP-46 connection has been revoked.
|
||||||
|
pub fn nip46_connection_revoked() -> Self {
|
||||||
|
Self::simple(
|
||||||
|
ErrorKind::Nip46ConnectionRevoked,
|
||||||
|
"The NIP-46 connection has been revoked. Reconnect to the signer.",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for AppError {
|
impl fmt::Display for AppError {
|
||||||
|
|
|
||||||
51
src/ipc.rs
51
src/ipc.rs
|
|
@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::app::{App, SignerMode};
|
use crate::app::App;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::feed;
|
use crate::feed;
|
||||||
use crate::profiles;
|
use crate::profiles;
|
||||||
|
|
@ -16,6 +16,7 @@ use crate::signer::embedded::EmbeddedSigner;
|
||||||
use crate::signer::nip46_client::Nip46ClientSigner;
|
use crate::signer::nip46_client::Nip46ClientSigner;
|
||||||
use crate::signer::Signer as SignerTrait;
|
use crate::signer::Signer as SignerTrait;
|
||||||
use crate::updates;
|
use crate::updates;
|
||||||
|
use crate::vault::SignerMode;
|
||||||
|
|
||||||
/// How long to wait for a relay connection test.
|
/// How long to wait for a relay connection test.
|
||||||
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||||
|
|
@ -338,20 +339,26 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
||||||
SignerMode::Embedded => {
|
SignerMode::Embedded => {
|
||||||
if guard.embedded_signer.is_none() {
|
if guard.embedded_signer.is_none() {
|
||||||
let signer = Arc::new(EmbeddedSigner::new(app.clone()));
|
let signer = Arc::new(EmbeddedSigner::new(app.clone()));
|
||||||
// Set active profile
|
|
||||||
if let Some(npub) = &guard.vault.active_profile {
|
if let Some(npub) = &guard.vault.active_profile {
|
||||||
signer.set_active_profile(Some(npub.clone())).await;
|
signer.set_active_profile(Some(npub.clone())).await;
|
||||||
}
|
}
|
||||||
guard.embedded_signer = Some(signer);
|
guard.embedded_signer = Some(signer);
|
||||||
}
|
}
|
||||||
guard.nip46_signer = None; // Drop NIP-46 signer
|
guard.nip46_signer = None;
|
||||||
|
guard.nip46_bunker_signer = None;
|
||||||
}
|
}
|
||||||
SignerMode::Nip46 => {
|
SignerMode::Nip46Bunker => {
|
||||||
|
// Legacy bunker mode - not fully implemented
|
||||||
|
guard.embedded_signer = None;
|
||||||
|
guard.nip46_signer = None;
|
||||||
|
}
|
||||||
|
SignerMode::Nip46Client => {
|
||||||
if guard.nip46_signer.is_none() {
|
if guard.nip46_signer.is_none() {
|
||||||
let signer = Arc::new(Nip46ClientSigner::new(app.clone()));
|
let signer = Arc::new(Nip46ClientSigner::new(app.clone()));
|
||||||
guard.nip46_signer = Some(signer);
|
guard.nip46_signer = Some(signer);
|
||||||
}
|
}
|
||||||
guard.embedded_signer = None; // Drop embedded signer
|
guard.embedded_signer = None;
|
||||||
|
guard.nip46_bunker_signer = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard.signer_mode = mode;
|
guard.signer_mode = mode;
|
||||||
|
|
@ -425,10 +432,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
||||||
// Legacy NIP-46 bunker (server mode)
|
// Legacy NIP-46 bunker (server mode)
|
||||||
Request::SignerConnect { uri } => {
|
Request::SignerConnect { uri } => {
|
||||||
let guard = app.lock().await;
|
let guard = app.lock().await;
|
||||||
// Initialize legacy signer if needed
|
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
||||||
// Note: This uses the old bunker-style signer
|
|
||||||
// For now, delegate to the new NIP-46 client if in that mode
|
|
||||||
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
|
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
if let Some(signer) = &guard.nip46_signer {
|
||||||
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
|
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
|
||||||
return Ok(json!(status));
|
return Ok(json!(status));
|
||||||
|
|
@ -440,7 +444,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
||||||
}
|
}
|
||||||
Request::SignerDisconnect => {
|
Request::SignerDisconnect => {
|
||||||
let guard = app.lock().await;
|
let guard = app.lock().await;
|
||||||
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
|
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
if let Some(signer) = &guard.nip46_signer {
|
||||||
signer.disconnect().await?;
|
signer.disconnect().await?;
|
||||||
let status = signer.status().await;
|
let status = signer.status().await;
|
||||||
|
|
@ -451,7 +455,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
||||||
}
|
}
|
||||||
Request::SignerStatus => {
|
Request::SignerStatus => {
|
||||||
let guard = app.lock().await;
|
let guard = app.lock().await;
|
||||||
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
|
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
if let Some(signer) = &guard.nip46_signer {
|
||||||
let status = signer.status().await;
|
let status = signer.status().await;
|
||||||
return Ok(json!(status));
|
return Ok(json!(status));
|
||||||
|
|
@ -461,7 +465,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
||||||
}
|
}
|
||||||
Request::SignerApprove { id, approved } => {
|
Request::SignerApprove { id, approved } => {
|
||||||
let guard = app.lock().await;
|
let guard = app.lock().await;
|
||||||
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
|
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
if let Some(signer) = &guard.nip46_signer {
|
||||||
signer.respond_to_approval(&id, approved).await?;
|
signer.respond_to_approval(&id, approved).await?;
|
||||||
let status = signer.status().await;
|
let status = signer.status().await;
|
||||||
|
|
@ -541,11 +545,15 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
let key = app.vault_key().copied();
|
let key = app.vault_key().copied();
|
||||||
let summary =
|
let summary =
|
||||||
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
|
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
|
||||||
// Update embedded signer if active
|
// Update active signer profile
|
||||||
if app.signer_mode == SignerMode::Embedded {
|
if app.signer_mode == SignerMode::Embedded {
|
||||||
if let Some(signer) = &app.embedded_signer {
|
if let Some(signer) = &app.embedded_signer {
|
||||||
signer.set_active_profile(Some(summary.npub.clone())).await;
|
signer.set_active_profile(Some(summary.npub.clone())).await;
|
||||||
}
|
}
|
||||||
|
} else if app.signer_mode == SignerMode::Nip46Client {
|
||||||
|
if let Some(signer) = &app.nip46_signer {
|
||||||
|
signer.set_active_profile(Some(summary.npub.clone())).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
||||||
|
|
@ -565,6 +573,10 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
if let Some(signer) = &app.embedded_signer {
|
if let Some(signer) = &app.embedded_signer {
|
||||||
signer.set_active_profile(Some(summary.npub.clone())).await;
|
signer.set_active_profile(Some(summary.npub.clone())).await;
|
||||||
}
|
}
|
||||||
|
} else if app.signer_mode == SignerMode::Nip46Client {
|
||||||
|
if let Some(signer) = &app.nip46_signer {
|
||||||
|
signer.set_active_profile(Some(summary.npub.clone())).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
||||||
|
|
@ -576,6 +588,10 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
if let Some(signer) = &app.embedded_signer {
|
if let Some(signer) = &app.embedded_signer {
|
||||||
signer.set_active_profile(Some(npub)).await;
|
signer.set_active_profile(Some(npub)).await;
|
||||||
}
|
}
|
||||||
|
} else if app.signer_mode == SignerMode::Nip46Client {
|
||||||
|
if let Some(signer) = &app.nip46_signer {
|
||||||
|
signer.set_active_profile(Some(npub)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
Ok(json!(app.state_view()))
|
Ok(json!(app.state_view()))
|
||||||
|
|
@ -682,6 +698,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
signer.set_active_profile(Some(npub.clone())).await;
|
signer.set_active_profile(Some(npub.clone())).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if app.signer_mode == SignerMode::Nip46Client {
|
||||||
|
if let Some(signer) = &app.nip46_signer {
|
||||||
|
if let Some(npub) = &app.vault.active_profile {
|
||||||
|
signer.set_active_profile(Some(npub.clone())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(json!(app.state_view()))
|
Ok(json!(app.state_view()))
|
||||||
}
|
}
|
||||||
|
|
@ -692,6 +714,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
if let Some(signer) = &app.embedded_signer {
|
if let Some(signer) = &app.embedded_signer {
|
||||||
signer.set_active_profile(None).await;
|
signer.set_active_profile(None).await;
|
||||||
}
|
}
|
||||||
|
if let Some(signer) = &app.nip46_signer {
|
||||||
|
signer.set_active_profile(None).await;
|
||||||
|
}
|
||||||
Ok(json!(app.state_view()))
|
Ok(json!(app.state_view()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -682,6 +682,7 @@ fn cli_undo_delete() -> Result<String, AppError> {
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: restored.picture,
|
picture: restored.picture,
|
||||||
nip05: restored.nip05,
|
nip05: restored.nip05,
|
||||||
|
signer_mode: keynectr::vault::SignerMode::Embedded,
|
||||||
};
|
};
|
||||||
app.vault.profiles.push(stored);
|
app.vault.profiles.push(stored);
|
||||||
if app.vault.active_profile.is_none() {
|
if app.vault.active_profile.is_none() {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ pub fn create_profile(
|
||||||
created_at,
|
created_at,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
|
signer_mode: crate::vault::SignerMode::Embedded,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_active = vault.active_profile.is_none();
|
let is_active = vault.active_profile.is_none();
|
||||||
|
|
@ -157,6 +158,7 @@ pub fn import_profile(
|
||||||
created_at,
|
created_at,
|
||||||
picture: metadata.as_ref().and_then(|m| m.picture.clone()),
|
picture: metadata.as_ref().and_then(|m| m.picture.clone()),
|
||||||
nip05: metadata.as_ref().and_then(|m| m.nip05.clone()),
|
nip05: metadata.as_ref().and_then(|m| m.nip05.clone()),
|
||||||
|
signer_mode: crate::vault::SignerMode::Embedded,
|
||||||
});
|
});
|
||||||
|
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
let relay_urls = relays::enabled_urls(settings);
|
||||||
|
|
@ -449,6 +451,18 @@ fn validate_picture_url(url: &str) -> Result<(), AppError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Look up a stored profile by npub (public access for signer-mode checks).
|
||||||
|
pub fn find_stored_profile<'a>(
|
||||||
|
vault: &'a Vault,
|
||||||
|
npub: &str,
|
||||||
|
) -> Result<&'a StoredProfile, AppError> {
|
||||||
|
vault
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.public_key == npub)
|
||||||
|
.ok_or_else(|| AppError::profile_not_found(npub))
|
||||||
|
}
|
||||||
|
|
||||||
fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
|
fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
|
||||||
vault
|
vault
|
||||||
.profiles
|
.profiles
|
||||||
|
|
@ -767,6 +781,7 @@ mod tests {
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
|
signer_mode: crate::vault::SignerMode::Embedded,
|
||||||
});
|
});
|
||||||
vault.profiles.push(StoredProfile {
|
vault.profiles.push(StoredProfile {
|
||||||
label: "Bob".to_string(),
|
label: "Bob".to_string(),
|
||||||
|
|
@ -775,6 +790,7 @@ mod tests {
|
||||||
created_at: 2,
|
created_at: 2,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
|
signer_mode: crate::vault::SignerMode::Embedded,
|
||||||
});
|
});
|
||||||
vault
|
vault
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
pub mod embedded;
|
pub mod embedded;
|
||||||
pub mod nip46_client;
|
pub mod nip46_client;
|
||||||
|
pub mod permissions;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType};
|
use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType};
|
||||||
|
|
@ -16,6 +18,23 @@ pub trait Signer: Send + Sync {
|
||||||
/// Get the public key of the active signing identity.
|
/// Get the public key of the active signing identity.
|
||||||
async fn get_public_key(&self) -> Result<PublicKey, AppError>;
|
async fn get_public_key(&self) -> Result<PublicKey, AppError>;
|
||||||
|
|
||||||
|
/// Resolve the public key this signer will sign user content with,
|
||||||
|
/// enforcing that it matches the active profile's canonical identity.
|
||||||
|
///
|
||||||
|
/// The default implementation compares `get_public_key()` against
|
||||||
|
/// `profile_pubkey` using canonical hex, returning
|
||||||
|
/// [`AppError::external_signer_identity_mismatch`] on any difference.
|
||||||
|
/// External signers may override this to consult the remote signer's
|
||||||
|
/// identity. Callers must use the returned key as the event's `pubkey`
|
||||||
|
/// and must never sign user content when this errors.
|
||||||
|
async fn pubkey_for(&self, profile_pubkey: &PublicKey) -> Result<PublicKey, AppError> {
|
||||||
|
let signer_pubkey = self.get_public_key().await?;
|
||||||
|
if signer_pubkey.to_hex() != profile_pubkey.to_hex() {
|
||||||
|
return Err(AppError::external_signer_identity_mismatch());
|
||||||
|
}
|
||||||
|
Ok(signer_pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign an event with the active key.
|
/// Sign an event with the active key.
|
||||||
async fn sign_event(&self, event: UnsignedEvent) -> Result<Event, AppError>;
|
async fn sign_event(&self, event: UnsignedEvent) -> Result<Event, AppError>;
|
||||||
|
|
||||||
|
|
@ -40,4 +59,132 @@ pub trait Signer: Send + Sync {
|
||||||
|
|
||||||
/// Get detailed status for UI (connection state, pending requests, etc.).
|
/// Get detailed status for UI (connection state, pending requests, etc.).
|
||||||
async fn detailed_status(&self) -> serde_json::Value;
|
async fn detailed_status(&self) -> serde_json::Value;
|
||||||
|
|
||||||
|
// ── Permission checks ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The permissions granted to this signer, if any.
|
||||||
|
///
|
||||||
|
/// Local (embedded) signers always return `None` — they have full
|
||||||
|
/// access to the local key and do not need permission checks. NIP-46
|
||||||
|
/// client signers return the permissions parsed from the connection
|
||||||
|
/// URI or stored configuration.
|
||||||
|
///
|
||||||
|
/// Returns an owned value because the NIP-46 client must lock an async
|
||||||
|
/// mutex internally.
|
||||||
|
fn permissions(&self) -> Option<permissions::Nip46Permissions> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `sign_event` is permitted for the given event kind.
|
||||||
|
///
|
||||||
|
/// The default implementation returns `true` when there are no
|
||||||
|
/// permissions (local signers) and `false` when permissions exist but
|
||||||
|
/// do not allow the operation.
|
||||||
|
fn can_sign_event(&self, kind: u16) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `nip44_encrypt` is permitted.
|
||||||
|
fn can_encrypt(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_encrypt_allowed(),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `nip44_decrypt` is permitted.
|
||||||
|
fn can_decrypt(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_decrypt_allowed(),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `get_public_key` is permitted.
|
||||||
|
fn can_get_public_key(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_get_public_key_allowed(),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `get_relays` is permitted.
|
||||||
|
fn can_get_relays(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_get_relays_allowed(),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the connection is currently valid (not expired, not revoked).
|
||||||
|
///
|
||||||
|
/// Local signers always return `true`.
|
||||||
|
fn is_connection_valid(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source that can produce the active profile's public key and sign an
|
||||||
|
/// unsigned event.
|
||||||
|
///
|
||||||
|
/// Every user-content signing path (publishing, upload auth, metadata) builds
|
||||||
|
/// an `EventBuilder` exactly as before, then routes it through a `Signing`
|
||||||
|
/// instead of a raw `Keys`. This is what makes "external signer not connected"
|
||||||
|
/// a hard error rather than a silent fall back to the local vault key: the
|
||||||
|
/// caller never holds the local secret when external mode is selected.
|
||||||
|
///
|
||||||
|
/// - [`Signing::Local`] signs with a key resolved from the vault (embedded
|
||||||
|
/// mode and the CLI, which are always local).
|
||||||
|
/// - [`Signing::External`] signs through a live [`Signer`], validating that the
|
||||||
|
/// signer's identity matches the active profile before any event is signed.
|
||||||
|
pub enum Signing {
|
||||||
|
Local(Keys),
|
||||||
|
External {
|
||||||
|
signer: Arc<dyn Signer>,
|
||||||
|
profile_pubkey: PublicKey,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Signing {
|
||||||
|
/// The public key user content will be signed with.
|
||||||
|
///
|
||||||
|
/// For [`Signing::External`] this enforces identity validation and returns
|
||||||
|
/// the signer's key; it returns [`AppError::external_signer_identity_mismatch`]
|
||||||
|
/// when the signer does not control the active profile. Callers MUST use the
|
||||||
|
/// returned key as the event's `pubkey`.
|
||||||
|
pub async fn pubkey(&self) -> Result<PublicKey, AppError> {
|
||||||
|
match self {
|
||||||
|
Signing::Local(keys) => Ok(keys.public_key()),
|
||||||
|
Signing::External {
|
||||||
|
signer,
|
||||||
|
profile_pubkey,
|
||||||
|
} => signer.pubkey_for(profile_pubkey).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign `unsigned` (which must have been built with the key from
|
||||||
|
/// [`Signing::pubkey`]).
|
||||||
|
///
|
||||||
|
/// For [`Signing::External`] the returned event is re-checked against the
|
||||||
|
/// validated identity and verified as a well-formed signature before it is
|
||||||
|
/// returned, so a misbehaving signer cannot substitute a different key.
|
||||||
|
pub async fn sign(&self, unsigned: UnsignedEvent) -> Result<Event, AppError> {
|
||||||
|
match self {
|
||||||
|
Signing::Local(keys) => keys.sign_event(unsigned).map_err(AppError::sign_failed),
|
||||||
|
Signing::External {
|
||||||
|
signer,
|
||||||
|
profile_pubkey,
|
||||||
|
} => {
|
||||||
|
let event = signer.sign_event(unsigned).await?;
|
||||||
|
if event.pubkey != *profile_pubkey {
|
||||||
|
return Err(AppError::external_signer_identity_mismatch());
|
||||||
|
}
|
||||||
|
event.verify().map_err(AppError::sign_failed)?;
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use tokio::sync::{oneshot, Mutex};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::profiles;
|
use crate::profiles;
|
||||||
|
use crate::signer::permissions::Nip46Permissions;
|
||||||
use crate::signer::types::{
|
use crate::signer::types::{
|
||||||
ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType,
|
ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType,
|
||||||
};
|
};
|
||||||
|
|
@ -42,6 +43,7 @@ struct ConnectUri {
|
||||||
peer: PublicKey,
|
peer: PublicKey,
|
||||||
relays: Vec<RelayUrl>,
|
relays: Vec<RelayUrl>,
|
||||||
secret: Option<String>,
|
secret: Option<String>,
|
||||||
|
permissions: Option<Nip46Permissions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The NIP-46 client signer.
|
/// The NIP-46 client signer.
|
||||||
|
|
@ -59,6 +61,7 @@ struct Nip46Inner {
|
||||||
pending: HashMap<String, PendingApprovalInner>,
|
pending: HashMap<String, PendingApprovalInner>,
|
||||||
keys: Option<Keys>,
|
keys: Option<Keys>,
|
||||||
connect_secret: Option<String>,
|
connect_secret: Option<String>,
|
||||||
|
active_npub: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -83,11 +86,34 @@ impl Nip46ClientSigner {
|
||||||
pending: HashMap::new(),
|
pending: HashMap::new(),
|
||||||
keys: None,
|
keys: None,
|
||||||
connect_secret: None,
|
connect_secret: None,
|
||||||
|
active_npub: None,
|
||||||
})),
|
})),
|
||||||
app,
|
app,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep active profile in sync (mirrors EmbeddedSigner).
|
||||||
|
pub async fn set_active_profile(&self, npub: Option<String>) {
|
||||||
|
self.inner.lock().await.active_npub = npub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit an audit event for a permission-denied NIP-46 operation.
|
||||||
|
async fn audit_permission_denied(&self, method: &str) {
|
||||||
|
let npub = self.inner.lock().await.active_npub.clone();
|
||||||
|
if let Some(npub) = npub {
|
||||||
|
let mut app = self.app.lock().await;
|
||||||
|
if let Some(ref mut log) = app.audit_log {
|
||||||
|
let _ = log.record(
|
||||||
|
&npub,
|
||||||
|
crate::audit::AuditAction::ConnectionPermissionDenied,
|
||||||
|
method,
|
||||||
|
false,
|
||||||
|
Some(format!("NIP-46 permission denied for method: {method}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a nostrconnect:// URI.
|
/// Parse a nostrconnect:// URI.
|
||||||
fn parse_connect_uri(raw: &str) -> Result<ConnectUri, AppError> {
|
fn parse_connect_uri(raw: &str) -> Result<ConnectUri, AppError> {
|
||||||
let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| {
|
let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| {
|
||||||
|
|
@ -105,6 +131,7 @@ impl Nip46ClientSigner {
|
||||||
|
|
||||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||||
let mut secret: Option<String> = None;
|
let mut secret: Option<String> = None;
|
||||||
|
let mut perms_raw: Option<String> = None;
|
||||||
|
|
||||||
if let Some(query) = query {
|
if let Some(query) = query {
|
||||||
for pair in query.split('&') {
|
for pair in query.split('&') {
|
||||||
|
|
@ -121,6 +148,7 @@ impl Nip46ClientSigner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"secret" => secret = decoded,
|
"secret" => secret = decoded,
|
||||||
|
"perms" => perms_raw = decoded,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,10 +160,16 @@ impl Nip46ClientSigner {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let permissions = match perms_raw {
|
||||||
|
Some(raw) => Some(Nip46Permissions::parse(&raw)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(ConnectUri {
|
Ok(ConnectUri {
|
||||||
peer,
|
peer,
|
||||||
relays,
|
relays,
|
||||||
secret,
|
secret,
|
||||||
|
permissions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,35 +177,83 @@ impl Nip46ClientSigner {
|
||||||
pub async fn connect(&self, uri: &str, label: String) -> Result<Nip46Status, AppError> {
|
pub async fn connect(&self, uri: &str, label: String) -> Result<Nip46Status, AppError> {
|
||||||
let parsed = Self::parse_connect_uri(uri)?;
|
let parsed = Self::parse_connect_uri(uri)?;
|
||||||
|
|
||||||
// Resolve our active profile's keys for NIP-44 encryption
|
// For NIP-46 Client the identity lives on the external signer, so local
|
||||||
let keys = {
|
// profile is optional. Use ephemeral keys for the session, preferring
|
||||||
|
// the local vault profile if available and unlocked.
|
||||||
|
let (keys, active_npub) = {
|
||||||
let app = self.app.lock().await;
|
let app = self.app.lock().await;
|
||||||
let npub =
|
if let Some(npub) = app.vault.active_profile.clone() {
|
||||||
app.vault.active_profile.as_ref().ok_or_else(|| {
|
if !app.is_locked() {
|
||||||
AppError::config("No active profile. Select a profile first.")
|
let vault_key = app.vault_key().copied();
|
||||||
})?;
|
if let Ok(secret_hex) =
|
||||||
if app.is_locked() {
|
profiles::resolve_secret_key(&app.vault, &npub, vault_key.as_ref())
|
||||||
return Err(AppError::vault_locked());
|
{
|
||||||
|
if let Ok(secret_key) = profiles::parse_secret_key(&secret_hex) {
|
||||||
|
(Keys::new(secret_key), Some(npub))
|
||||||
|
} else {
|
||||||
|
(Keys::generate(), Some(npub))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Keys::generate(), Some(npub))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Keys::generate(), Some(npub))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Keys::generate(), None)
|
||||||
}
|
}
|
||||||
let vault_key = app.vault_key().copied();
|
|
||||||
let secret_hex = profiles::resolve_secret_key(&app.vault, npub, vault_key.as_ref())?;
|
|
||||||
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
|
||||||
Keys::new(secret_key)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check for permission broadening against stored connections for
|
||||||
|
// the active profile.
|
||||||
|
if let Some(ref npub) = active_npub {
|
||||||
|
if let Some(new_perms) = &parsed.permissions {
|
||||||
|
let app = self.app.lock().await;
|
||||||
|
for stored_conn in &app.vault.nip46_connections {
|
||||||
|
// Only check connections belonging to the same profile
|
||||||
|
// AND the same remote signer. Legacy connections without
|
||||||
|
// profile_npub are skipped (they cannot pass auth).
|
||||||
|
if stored_conn.profile_npub.as_deref() == Some(npub.as_str())
|
||||||
|
&& stored_conn.signer_pubkey == parsed.peer.to_hex()
|
||||||
|
{
|
||||||
|
if let Some(existing_perms) = &stored_conn.permissions {
|
||||||
|
existing_perms.validate_no_broadening(new_perms)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Derive conversation key with the signer
|
// Derive conversation key with the signer
|
||||||
let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer)
|
let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer)
|
||||||
.map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?;
|
.map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?;
|
||||||
|
|
||||||
// Build connection config
|
// Build connection config
|
||||||
let connection = Nip46Connection {
|
let connection = Nip46Connection {
|
||||||
|
profile_npub: active_npub.clone(),
|
||||||
signer_pubkey: parsed.peer.to_hex(),
|
signer_pubkey: parsed.peer.to_hex(),
|
||||||
relays: parsed.relays.iter().map(|r| r.to_string()).collect(),
|
relays: parsed.relays.iter().map(|r| r.to_string()).collect(),
|
||||||
secret: parsed.secret.clone(),
|
secret: parsed.secret.clone(),
|
||||||
label,
|
label,
|
||||||
created_at: crate::vault::unix_timestamp()?,
|
created_at: crate::vault::unix_timestamp()?,
|
||||||
|
permissions: parsed.permissions.clone(),
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Persist the connection in the vault.
|
||||||
|
{
|
||||||
|
let mut app = self.app.lock().await;
|
||||||
|
// Remove any existing connection for the same signer from the
|
||||||
|
// same profile (reconnect replaces the old connection).
|
||||||
|
app.vault.nip46_connections.retain(|c| {
|
||||||
|
!(c.signer_pubkey == connection.signer_pubkey
|
||||||
|
&& c.profile_npub == connection.profile_npub)
|
||||||
|
});
|
||||||
|
app.vault.nip46_connections.push(connection.clone());
|
||||||
|
let _ = app.save_vault();
|
||||||
|
}
|
||||||
|
|
||||||
// Update state to connecting
|
// Update state to connecting
|
||||||
{
|
{
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
|
|
@ -210,6 +292,21 @@ impl Nip46ClientSigner {
|
||||||
if let Some(client) = inner.client.take() {
|
if let Some(client) = inner.client.take() {
|
||||||
let _ = client.disconnect().await;
|
let _ = client.disconnect().await;
|
||||||
}
|
}
|
||||||
|
// Mark the connection as revoked in the vault, scoped to profile.
|
||||||
|
if let Some(ref conn) = inner.connection {
|
||||||
|
let signer_pubkey = conn.signer_pubkey.clone();
|
||||||
|
let profile_npub = conn.profile_npub.clone();
|
||||||
|
let mut app = self.app.lock().await;
|
||||||
|
if let Some(stored) = app
|
||||||
|
.vault
|
||||||
|
.nip46_connections
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.signer_pubkey == signer_pubkey && c.profile_npub == profile_npub)
|
||||||
|
{
|
||||||
|
stored.revoked_at = crate::vault::unix_timestamp().ok();
|
||||||
|
}
|
||||||
|
let _ = app.save_vault();
|
||||||
|
}
|
||||||
inner.phase = Nip46Phase::Stopped;
|
inner.phase = Nip46Phase::Stopped;
|
||||||
inner.connection = None;
|
inner.connection = None;
|
||||||
inner.conversation_key = None;
|
inner.conversation_key = None;
|
||||||
|
|
@ -434,7 +531,7 @@ impl Nip46ClientSigner {
|
||||||
let response = if self.requires_approval(&request.method) {
|
let response = if self.requires_approval(&request.method) {
|
||||||
self.gated_response(&keys, &request).await
|
self.gated_response(&keys, &request).await
|
||||||
} else {
|
} else {
|
||||||
self.handle_request(&keys, &uri, &request)
|
self.handle_request(&keys, &uri, &request).await
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(response) = response {
|
if let Some(response) = response {
|
||||||
|
|
@ -449,6 +546,38 @@ impl Nip46ClientSigner {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
|
async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
|
||||||
|
// Check connection validity
|
||||||
|
if !self.is_connection_valid() {
|
||||||
|
return Some(response_err(
|
||||||
|
&request.id,
|
||||||
|
"Connection is expired or revoked".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check method permissions
|
||||||
|
let allowed = match request.method.as_str() {
|
||||||
|
"sign_event" => {
|
||||||
|
let kind = request
|
||||||
|
.params
|
||||||
|
.first()
|
||||||
|
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||||
|
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
|
||||||
|
.unwrap_or(0) as u16;
|
||||||
|
self.can_sign_event(kind)
|
||||||
|
}
|
||||||
|
"nip44_encrypt" => self.can_encrypt(),
|
||||||
|
"nip44_decrypt" => self.can_decrypt(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
self.audit_permission_denied(&request.method).await;
|
||||||
|
return Some(response_err(
|
||||||
|
&request.id,
|
||||||
|
format!("Permission denied: {} not authorized", request.method),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
self.inner.lock().await.phase = Nip46Phase::Connected;
|
self.inner.lock().await.phase = Nip46Phase::Connected;
|
||||||
let details = self.describe_request(request);
|
let details = self.describe_request(request);
|
||||||
match self.await_approval(details).await {
|
match self.await_approval(details).await {
|
||||||
|
|
@ -458,7 +587,7 @@ impl Nip46ClientSigner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_request(
|
async fn handle_request(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
uri: &ConnectUri,
|
uri: &ConnectUri,
|
||||||
|
|
@ -467,6 +596,14 @@ impl Nip46ClientSigner {
|
||||||
// Note: we can't await here, so phase update is best-effort
|
// Note: we can't await here, so phase update is best-effort
|
||||||
// The phase is updated in gated_response for key-using methods
|
// The phase is updated in gated_response for key-using methods
|
||||||
|
|
||||||
|
// Check connection validity before processing
|
||||||
|
if !self.is_connection_valid() {
|
||||||
|
return Some(response_err(
|
||||||
|
&request.id,
|
||||||
|
"Connection is expired or revoked".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
match request.method.as_str() {
|
match request.method.as_str() {
|
||||||
"connect" => {
|
"connect" => {
|
||||||
if let Some(expected) = &uri.secret {
|
if let Some(expected) = &uri.secret {
|
||||||
|
|
@ -479,8 +616,26 @@ impl Nip46ClientSigner {
|
||||||
}
|
}
|
||||||
Some(response_ok(&request.id, "ack".to_string()))
|
Some(response_ok(&request.id, "ack".to_string()))
|
||||||
}
|
}
|
||||||
"get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())),
|
"get_public_key" => {
|
||||||
"get_relays" => Some(response_ok(&request.id, json!(uri.relays).to_string())),
|
if !self.can_get_public_key() {
|
||||||
|
self.audit_permission_denied("get_public_key").await;
|
||||||
|
return Some(response_err(
|
||||||
|
&request.id,
|
||||||
|
"Permission denied: get_public_key not authorized".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(response_ok(&request.id, keys.public_key().to_hex()))
|
||||||
|
}
|
||||||
|
"get_relays" => {
|
||||||
|
if !self.can_get_relays() {
|
||||||
|
self.audit_permission_denied("get_relays").await;
|
||||||
|
return Some(response_err(
|
||||||
|
&request.id,
|
||||||
|
"Permission denied: get_relays not authorized".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(response_ok(&request.id, json!(uri.relays).to_string()))
|
||||||
|
}
|
||||||
"ping" => Some(response_ok(&request.id, "pong".to_string())),
|
"ping" => Some(response_ok(&request.id, "pong".to_string())),
|
||||||
"logout" => Some(response_ok(&request.id, "ack".to_string())),
|
"logout" => Some(response_ok(&request.id, "ack".to_string())),
|
||||||
other => Some(response_err(&request.id, format!("Unsupported: {other}"))),
|
other => Some(response_err(&request.id, format!("Unsupported: {other}"))),
|
||||||
|
|
@ -724,6 +879,70 @@ impl Signer for Nip46ClientSigner {
|
||||||
let status = self.status().await;
|
let status = self.status().await;
|
||||||
serde_json::to_value(status).unwrap_or(serde_json::json!({}))
|
serde_json::to_value(status).unwrap_or(serde_json::json!({}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Permission checks ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn permissions(&self) -> Option<Nip46Permissions> {
|
||||||
|
// We need to block on the async lock here; this is safe because
|
||||||
|
// `permissions()` is only called from synchronous contexts that do
|
||||||
|
// not hold the inner lock.
|
||||||
|
let inner = self.inner.blocking_lock();
|
||||||
|
inner
|
||||||
|
.connection
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.permissions.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_sign_event(&self, kind: u16) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_encrypt(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_encrypt_allowed(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_decrypt(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_decrypt_allowed(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_get_public_key(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_get_public_key_allowed(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_get_relays(&self) -> bool {
|
||||||
|
match self.permissions() {
|
||||||
|
Some(ref perms) => perms.is_get_relays_allowed(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_connection_valid(&self) -> bool {
|
||||||
|
let inner = self.inner.blocking_lock();
|
||||||
|
match &inner.connection {
|
||||||
|
None => false,
|
||||||
|
Some(conn) => {
|
||||||
|
let now = crate::vault::unix_timestamp().unwrap_or(0);
|
||||||
|
if let Some(expires_at) = conn.expires_at {
|
||||||
|
if now >= expires_at {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.revoked_at.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal decrypted NIP-46 request.
|
/// Minimal decrypted NIP-46 request.
|
||||||
|
|
|
||||||
663
src/signer/permissions.rs
Normal file
663
src/signer/permissions.rs
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
//! NIP-46 per-connection permission model.
|
||||||
|
//!
|
||||||
|
//! Permissions are parsed from the `perms` query parameter in a
|
||||||
|
//! `nostrconnect://` URI or from stored connection metadata. The format
|
||||||
|
//! follows the NIP-46 convention:
|
||||||
|
//!
|
||||||
|
//! `method` or `method:#kind1,#kind2`
|
||||||
|
//!
|
||||||
|
//! Multiple permissions are comma-separated. Unknown methods, malformed
|
||||||
|
//! strings, and empty permission sets are rejected.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Known NIP-46 method names.
|
||||||
|
const KNOWN_METHODS: &[&str] = &[
|
||||||
|
"sign_event",
|
||||||
|
"nip44_encrypt",
|
||||||
|
"nip44_decrypt",
|
||||||
|
"get_public_key",
|
||||||
|
"get_relays",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A single NIP-46 permission granting access to one method.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Nip46Permission {
|
||||||
|
/// The NIP-46 method this permission covers.
|
||||||
|
pub method: String,
|
||||||
|
/// Optional event-kind restrictions for `sign_event`.
|
||||||
|
///
|
||||||
|
/// * Empty — all event kinds are permitted.
|
||||||
|
/// * Non-empty — only the listed kinds are permitted.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub allowed_kinds: Vec<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed, validated permissions for a NIP-46 connection.
|
||||||
|
///
|
||||||
|
/// An empty `granted` list means **no** operations are allowed (deny-by-
|
||||||
|
/// default). Permissions can only be narrowed after creation, never broadened.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Nip46Permissions {
|
||||||
|
/// All granted permissions.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub granted: Vec<Nip46Permission>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nip46Permissions {
|
||||||
|
/// Parse a raw permission string.
|
||||||
|
///
|
||||||
|
/// Format: `"sign_event:#1,#3; nip44_encrypt; get_public_key"`
|
||||||
|
///
|
||||||
|
/// Permissions are semicolon-separated. Within a single permission,
|
||||||
|
/// event kinds follow a `:` and are themselves comma-separated with
|
||||||
|
/// optional `#` prefixes. An empty or blank string is treated as *no
|
||||||
|
/// permissions* and returns an empty list.
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, AppError> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut granted = Vec::new();
|
||||||
|
let mut seen_methods = std::collections::HashSet::new();
|
||||||
|
for part in trimmed.split(';') {
|
||||||
|
let part = part.trim();
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let perm = Self::parse_one(part)?;
|
||||||
|
if !seen_methods.insert(perm.method.clone()) {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"Duplicate NIP-46 permission method: {}",
|
||||||
|
perm.method,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
granted.push(perm);
|
||||||
|
}
|
||||||
|
Ok(Self { granted })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single permission token like `"sign_event:#1,#3"`.
|
||||||
|
///
|
||||||
|
/// The method name comes before the first `:` (if any). Everything
|
||||||
|
/// after that colon is treated as a comma-separated list of event
|
||||||
|
/// kinds (with optional `#` prefixes).
|
||||||
|
fn parse_one(token: &str) -> Result<Nip46Permission, AppError> {
|
||||||
|
let (method_part, kinds_part) = match token.split_once(':') {
|
||||||
|
Some((m, k)) => (m.trim(), Some(k.trim())),
|
||||||
|
None => (token.trim(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !KNOWN_METHODS.contains(&method_part) {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"Unknown NIP-46 permission method: {method_part}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed_kinds = match kinds_part {
|
||||||
|
Some(kinds_str) if !kinds_str.is_empty() => {
|
||||||
|
let mut kinds = Vec::new();
|
||||||
|
for k in kinds_str.split(',') {
|
||||||
|
let k = k.trim().trim_start_matches('#');
|
||||||
|
if k.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kind: u16 = k.parse().map_err(|_| {
|
||||||
|
AppError::config(format!("Invalid event kind in NIP-46 permission: {k}"))
|
||||||
|
})?;
|
||||||
|
kinds.push(kind);
|
||||||
|
}
|
||||||
|
kinds
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Nip46Permission {
|
||||||
|
method: method_part.to_string(),
|
||||||
|
allowed_kinds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the given method is permitted at all.
|
||||||
|
pub fn is_method_allowed(&self, method: &str) -> bool {
|
||||||
|
self.granted.iter().any(|p| p.method == method)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the given event kind is allowed for `sign_event`.
|
||||||
|
///
|
||||||
|
/// Returns `false` if `sign_event` is not permitted. If permitted with
|
||||||
|
/// no kind restrictions (empty `allowed_kinds`) returns `true`. If
|
||||||
|
/// permitted with specific kinds, returns `true` only when `kind` is in
|
||||||
|
/// the list.
|
||||||
|
pub fn is_sign_event_kind_allowed(&self, kind: u16) -> bool {
|
||||||
|
match self.granted.iter().find(|p| p.method == "sign_event") {
|
||||||
|
Some(p) if p.allowed_kinds.is_empty() => true,
|
||||||
|
Some(p) => p.allowed_kinds.contains(&kind),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `nip44_encrypt` is permitted.
|
||||||
|
pub fn is_encrypt_allowed(&self) -> bool {
|
||||||
|
self.is_method_allowed("nip44_encrypt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `nip44_decrypt` is permitted.
|
||||||
|
pub fn is_decrypt_allowed(&self) -> bool {
|
||||||
|
self.is_method_allowed("nip44_decrypt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `get_public_key` is permitted.
|
||||||
|
pub fn is_get_public_key_allowed(&self) -> bool {
|
||||||
|
self.is_method_allowed("get_public_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `get_relays` is permitted.
|
||||||
|
pub fn is_get_relays_allowed(&self) -> bool {
|
||||||
|
self.is_method_allowed("get_relays")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether `other` can be added to these permissions without
|
||||||
|
/// broadening them. Returns `Ok(())` if the addition is safe, or an
|
||||||
|
/// error describing which permission would be expanded.
|
||||||
|
pub fn validate_no_broadening(&self, other: &Nip46Permissions) -> Result<(), AppError> {
|
||||||
|
for new_perm in &other.granted {
|
||||||
|
match self.granted.iter().find(|p| p.method == new_perm.method) {
|
||||||
|
Some(existing) => {
|
||||||
|
// If the existing permission has kind restrictions and
|
||||||
|
// the new one does not, that broadens access.
|
||||||
|
if !existing.allowed_kinds.is_empty() && new_perm.allowed_kinds.is_empty() {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"Cannot broaden permission for {}: \
|
||||||
|
existing restriction to kinds {:?} would be removed",
|
||||||
|
new_perm.method, existing.allowed_kinds,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// If both have kind restrictions, check that the new
|
||||||
|
// set is a subset of the existing one.
|
||||||
|
if !existing.allowed_kinds.is_empty() && !new_perm.allowed_kinds.is_empty() {
|
||||||
|
for &k in &new_perm.allowed_kinds {
|
||||||
|
if !existing.allowed_kinds.contains(&k) {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"Cannot broaden permission for {}: \
|
||||||
|
kind {k} is not in the existing allowed kinds",
|
||||||
|
new_perm.method,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Method was not previously granted — adding it broadens.
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"Cannot grant new permission for {}: \
|
||||||
|
method was not previously authorized",
|
||||||
|
new_perm.method,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::errors::ErrorKind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_string() {
|
||||||
|
let perms = Nip46Permissions::parse("").unwrap();
|
||||||
|
assert!(perms.granted.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_blank_string() {
|
||||||
|
let perms = Nip46Permissions::parse(" ").unwrap();
|
||||||
|
assert!(perms.granted.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_single_method() {
|
||||||
|
let perms = Nip46Permissions::parse("sign_event").unwrap();
|
||||||
|
assert_eq!(perms.granted.len(), 1);
|
||||||
|
assert_eq!(perms.granted[0].method, "sign_event");
|
||||||
|
assert!(perms.granted[0].allowed_kinds.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_method_with_kinds() {
|
||||||
|
let perms = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap();
|
||||||
|
assert_eq!(perms.granted.len(), 1);
|
||||||
|
assert_eq!(perms.granted[0].method, "sign_event");
|
||||||
|
assert_eq!(perms.granted[0].allowed_kinds, vec![1, 3, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multiple_methods() {
|
||||||
|
let perms =
|
||||||
|
Nip46Permissions::parse("sign_event:#1; nip44_encrypt; get_public_key").unwrap();
|
||||||
|
assert_eq!(perms.granted.len(), 3);
|
||||||
|
assert!(perms.is_method_allowed("sign_event"));
|
||||||
|
assert!(perms.is_method_allowed("nip44_encrypt"));
|
||||||
|
assert!(perms.is_method_allowed("get_public_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_with_whitespace() {
|
||||||
|
let perms = Nip46Permissions::parse(" sign_event : #1 , #3 ; nip44_encrypt ").unwrap();
|
||||||
|
assert_eq!(perms.granted.len(), 2);
|
||||||
|
assert_eq!(perms.granted[0].allowed_kinds, vec![1, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_method_rejected() {
|
||||||
|
let err = Nip46Permissions::parse("unknown_method").unwrap_err();
|
||||||
|
assert!(err.message().contains("Unknown NIP-46 permission method"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_invalid_kind_rejected() {
|
||||||
|
let err = Nip46Permissions::parse("sign_event:#abc").unwrap_err();
|
||||||
|
assert!(err.message().contains("Invalid event kind"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_trailing_semicolon_ignored() {
|
||||||
|
let perms = Nip46Permissions::parse("sign_event;").unwrap();
|
||||||
|
assert_eq!(perms.granted.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_method_allowed() {
|
||||||
|
let perms = Nip46Permissions::parse("sign_event; nip44_encrypt").unwrap();
|
||||||
|
assert!(perms.is_method_allowed("sign_event"));
|
||||||
|
assert!(perms.is_method_allowed("nip44_encrypt"));
|
||||||
|
assert!(!perms.is_method_allowed("nip44_decrypt"));
|
||||||
|
assert!(!perms.is_method_allowed("get_public_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_event_kind_allowed_no_restrictions() {
|
||||||
|
let perms = Nip46Permissions::parse("sign_event").unwrap();
|
||||||
|
assert!(perms.is_sign_event_kind_allowed(1));
|
||||||
|
assert!(perms.is_sign_event_kind_allowed(9999));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_event_kind_allowed_with_restrictions() {
|
||||||
|
let perms = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
|
||||||
|
assert!(perms.is_sign_event_kind_allowed(1));
|
||||||
|
assert!(perms.is_sign_event_kind_allowed(3));
|
||||||
|
assert!(!perms.is_sign_event_kind_allowed(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_event_not_permitted() {
|
||||||
|
let perms = Nip46Permissions::parse("nip44_encrypt").unwrap();
|
||||||
|
assert!(!perms.is_sign_event_kind_allowed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_checks() {
|
||||||
|
let perms = Nip46Permissions::parse("nip44_encrypt").unwrap();
|
||||||
|
assert!(perms.is_encrypt_allowed());
|
||||||
|
assert!(!perms.is_decrypt_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_public_key_check() {
|
||||||
|
let perms = Nip46Permissions::parse("get_public_key").unwrap();
|
||||||
|
assert!(perms.is_get_public_key_allowed());
|
||||||
|
assert!(!perms.is_get_relays_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_relays_check() {
|
||||||
|
let perms = Nip46Permissions::parse("get_relays").unwrap();
|
||||||
|
assert!(perms.is_get_relays_allowed());
|
||||||
|
assert!(!perms.is_get_public_key_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_by_default_empty_permissions() {
|
||||||
|
let perms = Nip46Permissions::default();
|
||||||
|
assert!(!perms.is_method_allowed("sign_event"));
|
||||||
|
assert!(!perms.is_encrypt_allowed());
|
||||||
|
assert!(!perms.is_decrypt_allowed());
|
||||||
|
assert!(!perms.is_get_public_key_allowed());
|
||||||
|
assert!(!perms.is_get_relays_allowed());
|
||||||
|
assert!(!perms.is_sign_event_kind_allowed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_no_broadening_adds_method() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("nip44_encrypt").unwrap();
|
||||||
|
assert!(existing.validate_no_broadening(&proposed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_no_broadening_removes_kind_restriction() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event").unwrap();
|
||||||
|
let err = existing.validate_no_broadening(&proposed).unwrap_err();
|
||||||
|
assert!(err.message().contains("broaden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_no_broadening_adds_kind() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1,#5").unwrap();
|
||||||
|
let err = existing.validate_no_broadening(&proposed).unwrap_err();
|
||||||
|
assert!(err.message().contains("kind 5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_no_broadening_narrows_is_ok() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
assert!(existing.validate_no_broadening(&proposed).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_no_broadening_same_is_ok() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
|
||||||
|
assert!(existing.validate_no_broadening(&proposed).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_serialization() {
|
||||||
|
let perms = Nip46Permissions::parse("sign_event:#1,#3; nip44_encrypt").unwrap();
|
||||||
|
let json = serde_json::to_string(&perms).unwrap();
|
||||||
|
let restored: Nip46Permissions = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(perms, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_empty() {
|
||||||
|
let perms = Nip46Permissions::default();
|
||||||
|
let json = serde_json::to_string(&perms).unwrap();
|
||||||
|
let restored: Nip46Permissions = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(perms, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_with_permissions_serializes() {
|
||||||
|
use crate::signer::types::Nip46Connection;
|
||||||
|
|
||||||
|
let conn = Nip46Connection {
|
||||||
|
profile_npub: Some("npub1test".to_string()),
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec!["wss://relay.example.com".to_string()],
|
||||||
|
secret: None,
|
||||||
|
label: "Test".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: Some(Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap()),
|
||||||
|
expires_at: Some(1700003600),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&conn).unwrap();
|
||||||
|
let restored: Nip46Connection = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(restored.permissions.is_some());
|
||||||
|
let perms = restored.permissions.unwrap();
|
||||||
|
assert!(perms.is_method_allowed("sign_event"));
|
||||||
|
assert!(perms.is_sign_event_kind_allowed(1));
|
||||||
|
assert!(!perms.is_sign_event_kind_allowed(99));
|
||||||
|
assert!(perms.is_encrypt_allowed());
|
||||||
|
assert_eq!(restored.expires_at, Some(1700003600));
|
||||||
|
assert!(restored.revoked_at.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_without_permissions_serializes() {
|
||||||
|
use crate::signer::types::Nip46Connection;
|
||||||
|
|
||||||
|
let conn = Nip46Connection {
|
||||||
|
profile_npub: Some("npub1test".to_string()),
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec![],
|
||||||
|
secret: None,
|
||||||
|
label: "Test".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: None,
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&conn).unwrap();
|
||||||
|
let restored: Nip46Connection = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(restored.permissions.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_permissions_deny_all_operations() {
|
||||||
|
let perms = Nip46Permissions::default();
|
||||||
|
assert!(!perms.is_sign_event_kind_allowed(1));
|
||||||
|
assert!(!perms.is_encrypt_allowed());
|
||||||
|
assert!(!perms.is_decrypt_allowed());
|
||||||
|
assert!(!perms.is_get_public_key_allowed());
|
||||||
|
assert!(!perms.is_get_relays_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_broadening_rejected_when_adding_method() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event; nip44_encrypt").unwrap();
|
||||||
|
let err = existing.validate_no_broadening(&proposed).unwrap_err();
|
||||||
|
assert!(err.message().contains("nip44_encrypt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_broadening_rejected_when_removing_kind_restriction() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event").unwrap();
|
||||||
|
let err = existing.validate_no_broadening(&proposed).unwrap_err();
|
||||||
|
assert!(err.message().contains("broaden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_broadening_rejected_when_adding_kind() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1,#5").unwrap();
|
||||||
|
let err = existing.validate_no_broadening(&proposed).unwrap_err();
|
||||||
|
assert!(err.message().contains("kind 5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_narrowing_is_allowed() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
assert!(existing.validate_no_broadening(&proposed).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_same_is_allowed() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
|
||||||
|
assert!(existing.validate_no_broadening(&proposed).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_expiry_check() {
|
||||||
|
use crate::signer::types::Nip46Connection;
|
||||||
|
|
||||||
|
let conn = Nip46Connection {
|
||||||
|
profile_npub: Some("npub1test".to_string()),
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec![],
|
||||||
|
secret: None,
|
||||||
|
label: "Test".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: None,
|
||||||
|
expires_at: Some(1700000001), // Expired immediately
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = crate::vault::unix_timestamp().unwrap_or(0);
|
||||||
|
if now >= conn.expires_at.unwrap() {
|
||||||
|
assert!(conn.expires_at.unwrap() <= now, "connection is expired");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_revocation_check() {
|
||||||
|
use crate::signer::types::Nip46Connection;
|
||||||
|
|
||||||
|
let conn = Nip46Connection {
|
||||||
|
profile_npub: Some("npub1test".to_string()),
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec![],
|
||||||
|
secret: None,
|
||||||
|
label: "Test".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: None,
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: Some(1700000001),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(conn.revoked_at.is_some(), "connection is revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_empty_method_name() {
|
||||||
|
let err = Nip46Permissions::parse(":1;").expect_err("empty method should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_unknown_method() {
|
||||||
|
let err =
|
||||||
|
Nip46Permissions::parse("sign_event:#1; unknown_method").expect_err("unknown method");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_invalid_kind_format() {
|
||||||
|
let err =
|
||||||
|
Nip46Permissions::parse("sign_event:abc").expect_err("non-numeric kind should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_negative_kind() {
|
||||||
|
let err = Nip46Permissions::parse("sign_event:#-1").expect_err("negative kind should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_overflowing_kind() {
|
||||||
|
let err = Nip46Permissions::parse("sign_event:#99999999999999")
|
||||||
|
.expect_err("overflowing kind should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_duplicate_method() {
|
||||||
|
let err = Nip46Permissions::parse("sign_event:#1; sign_event:#3")
|
||||||
|
.expect_err("duplicate method should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
assert!(err.message().contains("Duplicate NIP-46 permission method"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_duplicate_method_no_spaces() {
|
||||||
|
let err = Nip46Permissions::parse("sign_event:#1;sign_event:#3")
|
||||||
|
.expect_err("duplicate method should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_duplicate_method_same_kinds() {
|
||||||
|
let err = Nip46Permissions::parse("sign_event:#1; sign_event:#1")
|
||||||
|
.expect_err("duplicate method should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_duplicate_method_encrypt() {
|
||||||
|
let err = Nip46Permissions::parse("nip44_encrypt;nip44_encrypt")
|
||||||
|
.expect_err("duplicate method should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_duplicate_method_get_public_key() {
|
||||||
|
let err = Nip46Permissions::parse("get_public_key; get_public_key")
|
||||||
|
.expect_err("duplicate method should fail");
|
||||||
|
assert!(matches!(err.kind(), ErrorKind::Config));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_duplicate_method_first_wins() {
|
||||||
|
// Error should mention the duplicated method name
|
||||||
|
let err = Nip46Permissions::parse("nip44_decrypt; nip44_decrypt; get_public_key")
|
||||||
|
.expect_err("duplicate method should fail");
|
||||||
|
assert!(err.message().contains("nip44_decrypt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_allows_trailing_semicolons() {
|
||||||
|
// Trailing semicolons produce empty parts which are skipped.
|
||||||
|
let perms = Nip46Permissions::parse("sign_event:#1;").unwrap();
|
||||||
|
assert!(perms.is_method_allowed("sign_event"));
|
||||||
|
assert!(perms.is_sign_event_kind_allowed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_allows_leading_semicolons() {
|
||||||
|
// Leading semicolons produce empty parts which are skipped.
|
||||||
|
let perms = Nip46Permissions::parse(";sign_event:#1").unwrap();
|
||||||
|
assert!(perms.is_method_allowed("sign_event"));
|
||||||
|
assert!(perms.is_sign_event_kind_allowed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialization_roundtrip_canonical() {
|
||||||
|
let perms =
|
||||||
|
Nip46Permissions::parse("get_public_key;nip44_decrypt;sign_event:#1,#4").unwrap();
|
||||||
|
let json = serde_json::to_string(&perms).unwrap();
|
||||||
|
let restored: Nip46Permissions = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(perms, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broadening_rejected_when_adding_kind() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
|
||||||
|
existing
|
||||||
|
.validate_no_broadening(&proposed)
|
||||||
|
.expect_err("adding kind should fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broadening_rejected_when_adding_encryption_to_signonly() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap();
|
||||||
|
existing
|
||||||
|
.validate_no_broadening(&proposed)
|
||||||
|
.expect_err("adding encrypt should fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broadening_accepted_when_restricting() {
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1,#3; nip44_encrypt").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
existing.validate_no_broadening(&proposed).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broadening_accepted_when_removing_method() {
|
||||||
|
// validate_no_broadening only checks for broadening, not narrowing.
|
||||||
|
// Removing a method is narrowing and is accepted.
|
||||||
|
let existing = Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap();
|
||||||
|
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
|
||||||
|
existing.validate_no_broadening(&proposed).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,13 @@ pub enum ApprovalResult {
|
||||||
/// Configuration for a NIP-46 connection.
|
/// Configuration for a NIP-46 connection.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Nip46Connection {
|
pub struct Nip46Connection {
|
||||||
|
/// The profile npub this connection belongs to.
|
||||||
|
///
|
||||||
|
/// `None` indicates a legacy connection from before profile ownership
|
||||||
|
/// tracking was added. These connections cannot pass authorization
|
||||||
|
/// checks and must be re-created to regain access.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub profile_npub: Option<String>,
|
||||||
/// The signer's public key (hex).
|
/// The signer's public key (hex).
|
||||||
pub signer_pubkey: String,
|
pub signer_pubkey: String,
|
||||||
/// Relays to use for the connection.
|
/// Relays to use for the connection.
|
||||||
|
|
@ -49,8 +56,24 @@ pub struct Nip46Connection {
|
||||||
pub secret: Option<String>,
|
pub secret: Option<String>,
|
||||||
/// Human-readable label for this connection.
|
/// Human-readable label for this connection.
|
||||||
pub label: String,
|
pub label: String,
|
||||||
/// When this connection was created.
|
/// When this connection was created (unix timestamp).
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
|
/// Parsed per-connection permissions.
|
||||||
|
///
|
||||||
|
/// When absent the connection carries no permissions and all operations
|
||||||
|
/// are denied (deny-by-default).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub permissions: Option<super::permissions::Nip46Permissions>,
|
||||||
|
/// When this connection expires (unix timestamp).
|
||||||
|
///
|
||||||
|
/// `None` means the connection does not expire.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<u64>,
|
||||||
|
/// When this connection was revoked (unix timestamp).
|
||||||
|
///
|
||||||
|
/// `None` means the connection is still active.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub revoked_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of a NIP-46 connection.
|
/// Status of a NIP-46 connection.
|
||||||
|
|
|
||||||
253
src/vault.rs
253
src/vault.rs
|
|
@ -12,8 +12,23 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Active signer mode per profile.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SignerMode {
|
||||||
|
Embedded,
|
||||||
|
Nip46Bunker,
|
||||||
|
Nip46Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serde default for `StoredProfile::signer_mode`: legacy profiles without
|
||||||
|
/// the field are treated as local (embedded) signers.
|
||||||
|
fn default_embedded() -> SignerMode {
|
||||||
|
SignerMode::Embedded
|
||||||
|
}
|
||||||
|
|
||||||
/// Current vault schema version.
|
/// Current vault schema version.
|
||||||
pub const VAULT_VERSION: u32 = 2;
|
pub const VAULT_VERSION: u32 = 3;
|
||||||
/// Filename of the profiles vault.
|
/// Filename of the profiles vault.
|
||||||
pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
|
pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
|
||||||
/// Filename of the settings file.
|
/// Filename of the settings file.
|
||||||
|
|
@ -46,6 +61,10 @@ pub struct StoredProfile {
|
||||||
/// part of kind 0 metadata so clients show a human handle.
|
/// part of kind 0 metadata so clients show a human handle.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub nip05: Option<String>,
|
pub nip05: Option<String>,
|
||||||
|
/// Per-profile signer mode. Defaults to `Embedded` for legacy profiles
|
||||||
|
/// that predate signer-mode tracking.
|
||||||
|
#[serde(default = "default_embedded")]
|
||||||
|
pub signer_mode: SignerMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
||||||
|
|
@ -86,6 +105,9 @@ pub struct Vault {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub crypto: Option<VaultCrypto>,
|
pub crypto: Option<VaultCrypto>,
|
||||||
pub profiles: Vec<StoredProfile>,
|
pub profiles: Vec<StoredProfile>,
|
||||||
|
/// Stored NIP-46 connections, keyed by the profile npub they belong to.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub nip46_connections: Vec<crate::signer::types::Nip46Connection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Vault {
|
impl Vault {
|
||||||
|
|
@ -97,6 +119,7 @@ impl Vault {
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
crypto: None,
|
crypto: None,
|
||||||
profiles: Vec::new(),
|
profiles: Vec::new(),
|
||||||
|
nip46_connections: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,12 +310,50 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
crypto: None,
|
crypto: None,
|
||||||
profiles,
|
profiles,
|
||||||
|
nip46_connections: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}")))
|
serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrate all profiles in the vault to have an explicit `signer_mode`.
|
||||||
|
///
|
||||||
|
/// This is idempotent: profiles that already have a `signer_mode` are
|
||||||
|
/// left untouched. Only profiles with the legacy `None` value (or
|
||||||
|
/// missing the field entirely) are assigned `Embedded`.
|
||||||
|
///
|
||||||
|
/// Returns `true` if any profiles were migrated (i.e. the vault should
|
||||||
|
/// be re-saved).
|
||||||
|
pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
for _profile in &mut vault.profiles {
|
||||||
|
// The serde default already handles missing fields during
|
||||||
|
// deserialization, but once loaded, profiles that were stored
|
||||||
|
// before signer_mode was introduced will have the default value.
|
||||||
|
// We write it explicitly so the on-disk format is canonical.
|
||||||
|
//
|
||||||
|
// After the first save, every profile will have an explicit
|
||||||
|
// signer_mode and this becomes a no-op.
|
||||||
|
//
|
||||||
|
// We cannot distinguish "user explicitly set Embedded" from
|
||||||
|
// "serde defaulted to Embedded", so we always write it — this is
|
||||||
|
// safe because Embedded is the correct default and the write is
|
||||||
|
// idempotent.
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Also ensure the nip46_connections vector exists (serde default
|
||||||
|
// handles this during deserialization, but we normalise here too).
|
||||||
|
if vault.version < VAULT_VERSION {
|
||||||
|
vault.version = VAULT_VERSION;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Legacy connections without profile_npub (None) are left as-is.
|
||||||
|
// Ownership cannot be reliably inferred from active_profile, so these
|
||||||
|
// connections remain unusable until the user re-creates them.
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist the vault to the stable application-data location with
|
/// Persist the vault to the stable application-data location with
|
||||||
/// restrictive permissions.
|
/// restrictive permissions.
|
||||||
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
|
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
|
||||||
|
|
@ -500,6 +561,7 @@ mod tests {
|
||||||
created_at: 1_700_000_000,
|
created_at: 1_700_000_000,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
|
signer_mode: SignerMode::Embedded,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -654,4 +716,193 @@ mod tests {
|
||||||
fs::write(&path, encrypted).unwrap();
|
fs::write(&path, encrypted).unwrap();
|
||||||
assert!(is_populated_vault_file(&path));
|
assert!(is_populated_vault_file(&path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_profile_without_signer_mode_loads_as_embedded() {
|
||||||
|
// A vault written before signer_mode was introduced has no
|
||||||
|
// signer_mode field. The serde default must produce Embedded.
|
||||||
|
let json = r#"{
|
||||||
|
"version": 2,
|
||||||
|
"profiles": [
|
||||||
|
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef", "created_at": 1700000000 }
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let vault = parse_vault(json).expect("should parse");
|
||||||
|
assert_eq!(vault.profiles.len(), 1);
|
||||||
|
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrate_vault_signer_modes_is_idempotent() {
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
vault.profiles.push(StoredProfile {
|
||||||
|
label: "Alice".to_string(),
|
||||||
|
public_key: "npub1abc".to_string(),
|
||||||
|
secret_key: "deadbeef".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
picture: None,
|
||||||
|
nip05: None,
|
||||||
|
signer_mode: SignerMode::Embedded,
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed1 = migrate_vault_signer_modes(&mut vault);
|
||||||
|
assert!(changed1, "first migration should report change");
|
||||||
|
|
||||||
|
let _changed2 = migrate_vault_signer_modes(&mut vault);
|
||||||
|
// The function always returns true because it normalises the version.
|
||||||
|
// The important thing is that running it twice doesn't corrupt data.
|
||||||
|
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
|
||||||
|
assert_eq!(vault.version, VAULT_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrate_vault_signer_modes_bumps_version() {
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
vault.version = 1; // Simulate an old vault
|
||||||
|
let changed = migrate_vault_signer_modes(&mut vault);
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(vault.version, VAULT_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nip46_connections_serialization_roundtrip() {
|
||||||
|
use crate::signer::types::Nip46Connection;
|
||||||
|
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
vault.nip46_connections.push(Nip46Connection {
|
||||||
|
profile_npub: Some("npub1test".to_string()),
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec!["wss://relay.example.com".to_string()],
|
||||||
|
secret: None,
|
||||||
|
label: "Test Bunker".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: None,
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&vault).unwrap();
|
||||||
|
let restored: Vault = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(restored.nip46_connections.len(), 1);
|
||||||
|
assert_eq!(restored.nip46_connections[0].signer_pubkey, "abc123");
|
||||||
|
assert_eq!(restored.nip46_connections[0].label, "Test Bunker");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nip46_connections_absent_in_legacy_vault() {
|
||||||
|
// A vault without nip46_connections should deserialize with an
|
||||||
|
// empty vector.
|
||||||
|
let json = r#"{
|
||||||
|
"version": 2,
|
||||||
|
"profiles": []
|
||||||
|
}"#;
|
||||||
|
let vault: Vault = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(vault.nip46_connections.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_signer_mode_value_fails_deserialization() {
|
||||||
|
let json = r#"{
|
||||||
|
"version": 3,
|
||||||
|
"profiles": [
|
||||||
|
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef",
|
||||||
|
"created_at": 1700000000, "signer_mode": "unknown_value" }
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let err = parse_vault(json).expect_err("unknown signer_mode must fail");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::VaultMalformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_without_profile_npub_deserializes() {
|
||||||
|
// Old connections without profile_npub should deserialize with
|
||||||
|
// an empty string (serde default).
|
||||||
|
let json = r#"{
|
||||||
|
"signer_pubkey": "abc123",
|
||||||
|
"relays": ["wss://relay.example.com"],
|
||||||
|
"secret": null,
|
||||||
|
"label": "Test",
|
||||||
|
"created_at": 1700000000,
|
||||||
|
"permissions": null,
|
||||||
|
"expires_at": null,
|
||||||
|
"revoked_at": null
|
||||||
|
}"#;
|
||||||
|
let conn: crate::signer::types::Nip46Connection = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(conn.profile_npub.is_none());
|
||||||
|
assert_eq!(conn.signer_pubkey, "abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_connection_without_profile_npub_is_none() {
|
||||||
|
// Connections from old vaults without profile_npub deserialize as None.
|
||||||
|
// None connections fail authorization checks.
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
vault.active_profile = Some("npub1alice".to_string());
|
||||||
|
vault
|
||||||
|
.nip46_connections
|
||||||
|
.push(crate::signer::types::Nip46Connection {
|
||||||
|
profile_npub: None, // Legacy connection
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec![],
|
||||||
|
secret: None,
|
||||||
|
label: "Legacy".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: None,
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _changed = migrate_vault_signer_modes(&mut vault);
|
||||||
|
// Legacy connection remains None - ownership cannot be inferred
|
||||||
|
assert!(vault.nip46_connections[0].profile_npub.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_preserves_existing_profile_npub() {
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
vault.active_profile = Some("npub1alice".to_string());
|
||||||
|
vault
|
||||||
|
.nip46_connections
|
||||||
|
.push(crate::signer::types::Nip46Connection {
|
||||||
|
profile_npub: Some("npub1bob".to_string()),
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec![],
|
||||||
|
secret: None,
|
||||||
|
label: "Bob's".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: None,
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _changed = migrate_vault_signer_modes(&mut vault);
|
||||||
|
// Already-owned connection is not modified
|
||||||
|
assert_eq!(
|
||||||
|
vault.nip46_connections[0].profile_npub.as_deref(),
|
||||||
|
Some("npub1bob")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_legacy_connection_without_active_profile() {
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
// No active profile set
|
||||||
|
vault
|
||||||
|
.nip46_connections
|
||||||
|
.push(crate::signer::types::Nip46Connection {
|
||||||
|
profile_npub: None,
|
||||||
|
signer_pubkey: "abc123".to_string(),
|
||||||
|
relays: vec![],
|
||||||
|
secret: None,
|
||||||
|
label: "Legacy".to_string(),
|
||||||
|
created_at: 1700000000,
|
||||||
|
permissions: None,
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _changed = migrate_vault_signer_modes(&mut vault);
|
||||||
|
// Connection remains None - cannot infer ownership
|
||||||
|
assert!(vault.nip46_connections[0].profile_npub.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue