diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 604c9a3..1743da5 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -38,12 +38,12 @@ protocol.registerSchemesAsPrivileged([ * (HMR websocket included). */ const CSP_PROD = - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + - "connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " + + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "connect-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; " + "base-uri 'none'; form-action 'none'"; const CSP_DEV = - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " + - "connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " + + "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:; font-src 'self' https://fonts.gstatic.com; " + "object-src 'none'; base-uri 'none'; form-action 'none'"; /** The CSP for a URL this window may load, or `null` for anywhere else. */ @@ -218,10 +218,26 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'lock_vault', 'remove_vault_password', 'reveal_secret_key', + 'export_secret_key', + // Legacy bunker 'signer_connect', 'signer_disconnect', 'signer_status', '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. */ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e127ff..88f4e9c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "keynectr", "version": "0.1.0", "dependencies": { + "nostr-tools": "^2.25.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -862,6 +863,45 @@ "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": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", @@ -1202,6 +1242,66 @@ "dev": true, "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": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -5007,6 +5107,47 @@ "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": { "version": "2.2.24", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", @@ -6249,7 +6390,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 5886ed4..83ec6e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "dist": "npm run build && npm run electron:build && electron-builder --linux dir" }, "dependencies": { + "nostr-tools": "^2.25.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/frontend/src/lib/signer/SignerManager.ts b/frontend/src/lib/signer/SignerManager.ts new file mode 100644 index 0000000..063e451 --- /dev/null +++ b/frontend/src/lib/signer/SignerManager.ts @@ -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; + publicKey: Uint8Array; +} + +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; + }; + /** Client mode (this app connects to external signer like Amber) */ + client: { + isConnected: boolean; + signerPubkey: string | null; + relays: string[]; + pendingRequests: Map; + }; + 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 = 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 { + return Object.freeze({ ...this.state }); + } + + /** Import a keypair from nsec or generate new one */ + async importKeypair(nsecOrPrivateKey?: string): Promise { + let pk: Uint8Array; + + 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 { + this.state.isVaultUnlocked = unlocked; + if (!unlocked) { + await this.stopAll(); + } + this.notify(); + } + + /** Switch signer mode with full validation */ + async setMode(mode: SignerMode): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + // Simplified - real impl would use websocket + NIP-44 + await new Promise((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 { + 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 { + // 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 { + 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 { + 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((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 { + 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 { + 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 { + 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 { + const manager = useSignerManager(); + return manager.getState(); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index af4fbbc..b3d8cf4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -2,7 +2,7 @@ export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; /** Active signer mode. */ -export type SignerMode = 'embedded' | 'nip46'; +export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client'; /** Lifecycle of the NIP-46 remote signer. */ export type SignerPhase = 'stopped' | 'connecting' | 'connected'; @@ -80,6 +80,8 @@ export interface ProfileSummary { picture?: string | null; /** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */ nip05?: string | null; + /** Per-profile signer mode. Absent for legacy profiles; defaults to embedded. */ + signer_mode?: SignerMode | null; } export interface RelayConfig { diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx index 50a2e0c..dcabe76 100644 --- a/frontend/src/screens/SignerModeScreen.tsx +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Alert } from '../components/Alert'; import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; @@ -10,7 +10,6 @@ import { useApp } from '../state/AppProvider'; export function SignerModeScreen() { const { state, - signerModeGet, signerModeSet, embeddedSignerStatus, nip46Status, @@ -19,9 +18,11 @@ export function SignerModeScreen() { nip46Approve, embeddedSignerApprove, refresh, + createProfile, + importProfile, + unlockVault, } = useApp(); - const [mode, setMode] = useState('embedded'); const [embeddedStatus, setEmbeddedStatus] = useState(null); const [nip46StatusState, setNip46StatusState] = useState(null); const [uri, setUri] = useState(''); @@ -30,33 +31,49 @@ export function SignerModeScreen() { const [connecting, setConnecting] = useState(false); const [loading, setLoading] = useState(true); - const isNip46Active = mode === 'nip46' && nip46StatusState?.connected; - const isEmbeddedActive = mode === 'embedded' && embeddedStatus?.available; + // Single source of truth: backend state (defaults to most secure) + 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 () => { try { - const modeResult = await signerModeGet(); - setMode(modeResult.mode); + // Mode comes from AppProvider state, just refresh signer statuses + 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') { - const status = await embeddedSignerStatus(); - setEmbeddedStatus(status); + if (fetchedMode === 'embedded') { + try { + const status = await embeddedSignerStatus(); + setEmbeddedStatus(status); + } catch { + setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any); + } } else { - const status = await nip46Status(); - setNip46StatusState(status); + try { + const status = await nip46Status(); + setNip46StatusState(status); + } catch { + setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any); + } } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [signerModeGet, embeddedSignerStatus, nip46Status]); + }, [state?.signer_mode, embeddedSignerStatus, nip46Status]); useEffect(() => { void refreshStatus(); }, [refreshStatus]); - // Poll for pending approvals useEffect(() => { const timer = window.setInterval(() => { void refreshStatus(); @@ -65,23 +82,38 @@ export function SignerModeScreen() { }, [refreshStatus]); const vaultLocked = state?.vault_locked ?? false; + const hasProfile = !!state?.active_profile; - const onModeChange = async (newMode: SignerMode) => { - setError(null); - try { - await signerModeSet(newMode); - setMode(newMode); - await refreshStatus(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }; + const canSwitchToBunker = hasProfile && !vaultLocked; + const canSwitchToEmbedded = hasProfile && !vaultLocked; - const onNip46Connect = async (event: FormEvent) => { - event.preventDefault(); + const handleModeSwitch = useCallback( + 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(); 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; } setError(null); @@ -95,9 +127,9 @@ export function SignerModeScreen() { } finally { setConnecting(false); } - }; + }, [uri, label, nip46Connect]); - const onNip46Disconnect = async () => { + const handleNip46Disconnect = useCallback(async () => { setError(null); try { const status = await nip46Disconnect(); @@ -105,49 +137,130 @@ export function SignerModeScreen() { } catch (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 ? ( + NIP-46 Client (Connected) + ) : ( + NIP-46 Client (Most Secure) + ); + } + if (mode === 'nip46_bunker') { + return isNip46Active ? ( + NIP-46 Bunker (Running) + ) : ( + NIP-46 Bunker (Moderate) + ); + } + return isEmbeddedActive ? ( + Embedded (Least Secure) + ) : ( + Embedded {vaultLocked ? '(Vault Locked)' : ''} + ); }; - 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); try { - const status = await embeddedSignerApprove(index, approved); - setEmbeddedStatus(status); + if (nsec.trim()) { + await importProfile('Imported', nsec.trim()); + } else { + await createProfile('Generated'); + } + await refresh(); + await refreshStatus(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } - }; + }, [importProfile, createProfile, refresh, refreshStatus]); - const modeBadge = () => { - if (mode === 'embedded') { - return isEmbeddedActive ? ( - Embedded (Active) - ) : ( - - Embedded {vaultLocked ? '(Vault Locked)' : '(Ready)'} - - ); + const handleUnlockVault = useCallback(async () => { + const pwd = prompt('Enter vault password to unlock:'); + if (!pwd) return; + setError(null); + try { + await unlockVault(pwd); + await refresh(); + await refreshStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); } - return isNip46Active ? ( - NIP-46 (Connected) - ) : ( - - NIP-46 {nip46StatusState?.error ? '(Error)' : '(Disconnected)'} - - ); - }; + }, [unlockVault, refresh, refreshStatus]); return (
-
-

Signer Mode

-

- Choose how your keys are managed and where signing happens. -

-
+

Signer Mode

+

+ Choose how your keys are managed and where signing happens. +
+ Ordered by security: most secure → least secure +

+
+
+

Key Status

+
+
+
+
+ Keypair + {hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'} +
+
+ Vault + {vaultLocked ? 'Locked' : 'Unlocked / No password'} +
+
+ Current Mode + {mode} +
+
+ {!hasProfile && ( + + )} + {hasProfile && vaultLocked && ( + + )} +
+
+

Current Mode

@@ -155,280 +268,209 @@ export function SignerModeScreen() {
-
- {vaultLocked && mode === 'embedded' && ( - - The embedded signer needs an unlocked vault to sign.{' '} - { - e.preventDefault(); - void refresh(); - }} - > - Unlock vault - - before using embedded signing. + {mode === 'nip46_bunker' && !canSwitchToBunker && ( + + {hasProfile ? 'Unlock vault to enable bunker mode.' : 'No keypair found: Please import a key first.'} + + )} + {mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && ( + + Unlock vault to use embedded signer. + + )} + {!hasProfile && mode !== 'nip46_client' && ( + + No keypair found: Please import a key first. (NIP-46 Client can be selected without a local key.) )} - {error && {error}}
- {mode === 'embedded' && embeddedStatus && ( + {/* Embedded pending */} + {mode === 'embedded' && (embeddedStatus?.pending?.length ?? 0) > 0 && (
-

Embedded Signer Status

+

Pending Approvals

+ {embeddedStatus!.pending.length}
-
-
-
Active Profile
-
- {embeddedStatus.active_npub ? ( - {embeddedStatus.active_npub} - ) : ( - None selected - )} -
+ {(embeddedStatus!.pending).map((req, idx) => ( +
+
+ {req.method} +

{req.summary}

+ {req.details?.is_sensitive && Sensitive} +
+
+ + +
-
-
Pending Approvals
-
{embeddedStatus.pending_count}
-
-
- - {embeddedStatus.pending.length > 0 && ( -
-

- The active profile needs approval for the following operations: -

- {embeddedStatus.pending.map((request, index) => ( -
-
- {request.method} -

{request.summary}

- {request.details?.content_preview && ( -

"{request.details.content_preview}"

- )} - {request.details?.is_sensitive && ( - Sensitive operation - )} -
-
- - -
-
- ))} -
- )} + ))}
)} - {mode === 'nip46' && ( + {/* NIP-46 Client config */} + {(mode === 'nip46_client' || mode === 'nip46_bunker') && (
-

NIP-46 Connection

+

{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}

{isNip46Active && nip46StatusState ? (

- Connected to{' '} - {nip46StatusState.signer_pubkey?.slice(0, 16)}… - via {nip46StatusState.connected_relays.length} of{' '} - {nip46StatusState.relays.length} relays. + Connected to {nip46StatusState.signer_pubkey?.slice(0, 16)}… via{' '} + {(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays

-
-
-
Signer
-
- {nip46StatusState.signer_pubkey} -
-
-
-
Relays
-
- {nip46StatusState.relays.map((relay, i) => { - const connected = nip46StatusState!.connected_relays.includes(relay); - return ( - - {relay} - - ); - })} -
-
-
- {nip46StatusState.error && ( - - {nip46StatusState.error} - - )} - + {(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && ( +
+

Pending ({nip46StatusState!.pending_approvals!.length})

+ {(nip46StatusState!.pending_approvals ?? []).map((r) => ( +
+
+ {r.method} +

{r.summary}

+
+
+ + +
+
+ ))} +
+ )}
) : ( -
+
- setUri(e.target.value)} autoComplete="off" spellCheck={false} />

- 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.'}

- - setLabel(e.target.value)} - /> + + setLabel(e.target.value)} placeholder="Remote Signer" />
{error && {error}}
-
- +
)} - - {nip46StatusState?.pending_approvals.length && - nip46StatusState.pending_approvals.length > 0 && ( -
-

Pending Approvals ({nip46StatusState.pending_approvals.length})

- {nip46StatusState.pending_approvals.map((request) => ( -
-
- {request.method} -

{request.summary}

- {request.details?.content_preview && ( -

"{request.details.content_preview}"

- )} - {request.details?.is_sensitive && ( - Sensitive operation - )} -
-
- - -
-
- ))} -
- )}
)} @@ -440,22 +482,15 @@ export function SignerModeScreen() {
  • - Embedded mode: Your keys are encrypted at rest with Argon2id + - AES-256-GCM. When unlocked, they exist in memory. A compromised OS or malware could - extract them. + NIP-46 Client (Most Secure): Private key never on this device. External + signer (hardware wallet / Amber) holds key.
  • - NIP-46 mode: Your private key never touches this device. The signer - (Amber, Nostr Connect, bunker) holds the key and you approve each operation there. + NIP-46 Bunker (Moderate): Key in this app's vault, you approve each + remote request.
  • - Switching modes: You can switch modes anytime without changing your - public key. In NIP-46 mode, you'll need to import your key into the external signer - first. -
  • -
  • - Revocation: In NIP-46 mode, disconnect revokes the connection. The - signer will reject future requests from this app. + Embedded (Least Secure): Local signing, key in memory when unlocked.
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index df17ff8..69c265d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2323,3 +2323,320 @@ select { 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); +} diff --git a/src/app.rs b/src/app.rs index baecbb7..4326f15 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,17 @@ use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::sync::Arc; use zeroize::{Zeroize, Zeroizing}; +use crate::audit::AuditLog; use crate::crypto::{self, VaultKey}; use crate::errors::AppError; use crate::profiles::{self, ProfileSummary}; 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. pub const MIN_PASSWORD_LEN: usize = 8; @@ -29,14 +32,10 @@ pub struct App { pub embedded_signer: Option, /// NIP-46 client signer instance. pub nip46_signer: Option, -} - -/// Active signer mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SignerMode { - Embedded, - Nip46, + /// NIP-46 bunker signer instance (legacy). + pub nip46_bunker_signer: Option, + /// Audit log for security-sensitive operations. May be absent in test environments. + pub audit_log: Option, } /// Handle for the embedded signer (type-erased for App storage). @@ -44,6 +43,10 @@ pub type EmbeddedSignerHandle = Arc; /// Handle for the NIP-46 client signer (type-erased for App storage). pub type Nip46ClientSignerHandle = Arc; + +/// Handle for the NIP-46 bunker signer (type-erased for App storage). +pub type Nip46BunkerSignerHandle = Arc; + /// Snapshot of everything the UI needs, containing no secret keys. #[derive(Debug, Clone, Serialize)] pub struct AppStateView { @@ -70,15 +73,25 @@ pub struct AppStateView { impl App { /// Load the vault (migrating a legacy vault if needed) and settings. pub fn load() -> Result { + 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 { - vault: vault::load_vault()?, + vault, settings: vault::load_settings()?, unlock_key: None, undo_history: Vec::new(), last_publish: vault::load_last_publish(), - signer_mode: SignerMode::Embedded, + signer_mode: SignerMode::Nip46Client, embedded_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, picture: restored.picture.clone(), nip05: restored.nip05.clone(), + signer_mode: SignerMode::Embedded, }; self.vault.profiles.push(stored); // If no active profile, this restored one becomes active @@ -339,6 +353,8 @@ mod tests { signer_mode: SignerMode::Embedded, embedded_signer: None, nip46_signer: None, + nip46_bunker_signer: None, + audit_log: None, } } diff --git a/src/errors.rs b/src/errors.rs index effad16..1736444 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -42,6 +42,20 @@ pub enum ErrorKind { Config, /// Unexpected internal failure. 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. @@ -191,6 +205,65 @@ impl AppError { 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 { diff --git a/src/ipc.rs b/src/ipc.rs index 5555ace..1dd3cc3 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::Mutex; -use crate::app::{App, SignerMode}; +use crate::app::App; use crate::errors::AppError; use crate::feed; use crate::profiles; @@ -16,6 +16,7 @@ use crate::signer::embedded::EmbeddedSigner; use crate::signer::nip46_client::Nip46ClientSigner; use crate::signer::Signer as SignerTrait; use crate::updates; +use crate::vault::SignerMode; /// How long to wait for a relay connection test. const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); @@ -338,20 +339,26 @@ async fn run(app: &Arc>, request: Request) -> Result { if guard.embedded_signer.is_none() { let signer = Arc::new(EmbeddedSigner::new(app.clone())); - // Set active profile if let Some(npub) = &guard.vault.active_profile { signer.set_active_profile(Some(npub.clone())).await; } 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() { let signer = Arc::new(Nip46ClientSigner::new(app.clone())); 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; @@ -425,10 +432,7 @@ async fn run(app: &Arc>, request: Request) -> Result { let guard = app.lock().await; - // Initialize legacy signer if needed - // 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 guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if let Some(signer) = &guard.nip46_signer { let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?; return Ok(json!(status)); @@ -440,7 +444,7 @@ async fn run(app: &Arc>, request: Request) -> Result { 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 { signer.disconnect().await?; let status = signer.status().await; @@ -451,7 +455,7 @@ async fn run(app: &Arc>, request: Request) -> Result { 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 { let status = signer.status().await; return Ok(json!(status)); @@ -461,7 +465,7 @@ async fn run(app: &Arc>, request: Request) -> Result { 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 { signer.respond_to_approval(&id, approved).await?; let status = signer.status().await; @@ -541,11 +545,15 @@ async fn run_with_app(app: &mut App, request: Request) -> Result Result Result Result Result Result { created_at: restored.created_at, picture: restored.picture, nip05: restored.nip05, + signer_mode: keynectr::vault::SignerMode::Embedded, }; app.vault.profiles.push(stored); if app.vault.active_profile.is_none() { diff --git a/src/profiles.rs b/src/profiles.rs index 6ebb7f4..ec6ad14 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -75,6 +75,7 @@ pub fn create_profile( created_at, picture: None, nip05: None, + signer_mode: crate::vault::SignerMode::Embedded, }; let is_active = vault.active_profile.is_none(); @@ -157,6 +158,7 @@ pub fn import_profile( created_at, picture: metadata.as_ref().and_then(|m| m.picture.clone()), nip05: metadata.as_ref().and_then(|m| m.nip05.clone()), + signer_mode: crate::vault::SignerMode::Embedded, }); let relay_urls = relays::enabled_urls(settings); @@ -449,6 +451,18 @@ fn validate_picture_url(url: &str) -> Result<(), AppError> { 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> { vault .profiles @@ -767,6 +781,7 @@ mod tests { created_at: 1, picture: None, nip05: None, + signer_mode: crate::vault::SignerMode::Embedded, }); vault.profiles.push(StoredProfile { label: "Bob".to_string(), @@ -775,6 +790,7 @@ mod tests { created_at: 2, picture: None, nip05: None, + signer_mode: crate::vault::SignerMode::Embedded, }); vault } diff --git a/src/signer/mod.rs b/src/signer/mod.rs index 7c58c98..397e213 100644 --- a/src/signer/mod.rs +++ b/src/signer/mod.rs @@ -2,10 +2,12 @@ pub mod embedded; pub mod nip46_client; +pub mod permissions; pub mod types; use async_trait::async_trait; use nostr_sdk::prelude::*; +use std::sync::Arc; use crate::errors::AppError; 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. async fn get_public_key(&self) -> Result; + /// 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 { + 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. async fn sign_event(&self, event: UnsignedEvent) -> Result; @@ -40,4 +59,132 @@ pub trait Signer: Send + Sync { /// Get detailed status for UI (connection state, pending requests, etc.). 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 { + 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, + 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 { + 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 { + 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) + } + } + } } diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs index 957335f..9767a6b 100644 --- a/src/signer/nip46_client.rs +++ b/src/signer/nip46_client.rs @@ -18,6 +18,7 @@ use tokio::sync::{oneshot, Mutex}; use crate::app::App; use crate::errors::AppError; use crate::profiles; +use crate::signer::permissions::Nip46Permissions; use crate::signer::types::{ ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType, }; @@ -42,6 +43,7 @@ struct ConnectUri { peer: PublicKey, relays: Vec, secret: Option, + permissions: Option, } /// The NIP-46 client signer. @@ -59,6 +61,7 @@ struct Nip46Inner { pending: HashMap, keys: Option, connect_secret: Option, + active_npub: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -83,11 +86,34 @@ impl Nip46ClientSigner { pending: HashMap::new(), keys: None, connect_secret: None, + active_npub: None, })), app, } } + /// Keep active profile in sync (mirrors EmbeddedSigner). + pub async fn set_active_profile(&self, npub: Option) { + 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. fn parse_connect_uri(raw: &str) -> Result { let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| { @@ -105,6 +131,7 @@ impl Nip46ClientSigner { let mut relays: Vec = Vec::new(); let mut secret: Option = None; + let mut perms_raw: Option = None; if let Some(query) = query { for pair in query.split('&') { @@ -121,6 +148,7 @@ impl Nip46ClientSigner { } } "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 { peer, relays, secret, + permissions, }) } @@ -143,35 +177,83 @@ impl Nip46ClientSigner { pub async fn connect(&self, uri: &str, label: String) -> Result { let parsed = Self::parse_connect_uri(uri)?; - // Resolve our active profile's keys for NIP-44 encryption - let keys = { + // For NIP-46 Client the identity lives on the external signer, so local + // 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 npub = - app.vault.active_profile.as_ref().ok_or_else(|| { - AppError::config("No active profile. Select a profile first.") - })?; - if app.is_locked() { - return Err(AppError::vault_locked()); + if let Some(npub) = app.vault.active_profile.clone() { + if !app.is_locked() { + let vault_key = app.vault_key().copied(); + if let Ok(secret_hex) = + profiles::resolve_secret_key(&app.vault, &npub, vault_key.as_ref()) + { + 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 let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer) .map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?; // Build connection config let connection = Nip46Connection { + profile_npub: active_npub.clone(), signer_pubkey: parsed.peer.to_hex(), relays: parsed.relays.iter().map(|r| r.to_string()).collect(), secret: parsed.secret.clone(), label, 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 { let mut inner = self.inner.lock().await; @@ -210,6 +292,21 @@ impl Nip46ClientSigner { if let Some(client) = inner.client.take() { 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.connection = None; inner.conversation_key = None; @@ -434,7 +531,7 @@ impl Nip46ClientSigner { let response = if self.requires_approval(&request.method) { self.gated_response(&keys, &request).await } else { - self.handle_request(&keys, &uri, &request) + self.handle_request(&keys, &uri, &request).await }; if let Some(response) = response { @@ -449,6 +546,38 @@ impl Nip46ClientSigner { } async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option { + // 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::(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; let details = self.describe_request(request); match self.await_approval(details).await { @@ -458,7 +587,7 @@ impl Nip46ClientSigner { } } - fn handle_request( + async fn handle_request( &self, keys: &Keys, uri: &ConnectUri, @@ -467,6 +596,14 @@ impl Nip46ClientSigner { // Note: we can't await here, so phase update is best-effort // 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() { "connect" => { if let Some(expected) = &uri.secret { @@ -479,8 +616,26 @@ impl Nip46ClientSigner { } Some(response_ok(&request.id, "ack".to_string())) } - "get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())), - "get_relays" => Some(response_ok(&request.id, json!(uri.relays).to_string())), + "get_public_key" => { + 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())), "logout" => Some(response_ok(&request.id, "ack".to_string())), other => Some(response_err(&request.id, format!("Unsupported: {other}"))), @@ -724,6 +879,70 @@ impl Signer for Nip46ClientSigner { let status = self.status().await; serde_json::to_value(status).unwrap_or(serde_json::json!({})) } + + // ── Permission checks ─────────────────────────────────────────────── + + fn permissions(&self) -> Option { + // 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. diff --git a/src/signer/permissions.rs b/src/signer/permissions.rs new file mode 100644 index 0000000..c0be17e --- /dev/null +++ b/src/signer/permissions.rs @@ -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, +} + +/// 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, +} + +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 { + 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 { + 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(); + } +} diff --git a/src/signer/types.rs b/src/signer/types.rs index 87c6772..8157e19 100644 --- a/src/signer/types.rs +++ b/src/signer/types.rs @@ -41,6 +41,13 @@ pub enum ApprovalResult { /// Configuration for a NIP-46 connection. #[derive(Debug, Clone, Serialize, Deserialize)] 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, /// The signer's public key (hex). pub signer_pubkey: String, /// Relays to use for the connection. @@ -49,8 +56,24 @@ pub struct Nip46Connection { pub secret: Option, /// Human-readable label for this connection. pub label: String, - /// When this connection was created. + /// When this connection was created (unix timestamp). 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, + /// 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, + /// 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, } /// Status of a NIP-46 connection. diff --git a/src/vault.rs b/src/vault.rs index 73b9297..2c23412 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -12,8 +12,23 @@ use serde::{Deserialize, Serialize}; 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. -pub const VAULT_VERSION: u32 = 2; +pub const VAULT_VERSION: u32 = 3; /// Filename of the profiles vault. pub const VAULT_FILE_NAME: &str = "profiles_vault.json"; /// Filename of the settings file. @@ -46,6 +61,10 @@ pub struct StoredProfile { /// part of kind 0 metadata so clients show a human handle. #[serde(default)] pub nip05: Option, + /// 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 @@ -86,6 +105,9 @@ pub struct Vault { #[serde(default, skip_serializing_if = "Option::is_none")] pub crypto: Option, pub profiles: Vec, + /// Stored NIP-46 connections, keyed by the profile npub they belong to. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub nip46_connections: Vec, } impl Vault { @@ -97,6 +119,7 @@ impl Vault { active_profile: None, crypto: None, profiles: Vec::new(), + nip46_connections: Vec::new(), } } @@ -287,12 +310,50 @@ pub fn parse_vault(content: &str) -> Result { active_profile: None, crypto: None, profiles, + nip46_connections: Vec::new(), }); } 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 /// restrictive permissions. pub fn save_vault(vault: &Vault) -> Result<(), AppError> { @@ -500,6 +561,7 @@ mod tests { created_at: 1_700_000_000, picture: None, nip05: None, + signer_mode: SignerMode::Embedded, } } @@ -654,4 +716,193 @@ mod tests { fs::write(&path, encrypted).unwrap(); 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()); + } }