feat: add per-profile signer modes with persisted NIP-46 connections

Introduce three coexisting signing modes:
- Embedded (INTERNAL): vault-held nsec, decrypted in Rust, signs locally.
- Nip46Client (EXTERNAL): Keynctr is the NIP-46 CLIENT; the key never
  touches this machine.
- Nip46Bunker: legacy inverted mode (Keynctr as signer serving others).

Data model:
- StoredProfile gains signer_mode (serde-defaults to Embedded for legacy
  profiles); SignerMode moves from app.rs to vault.rs to break a circular
  dependency; app.rs re-exports it.
- Vault gains nip46_connections (profile-owned) and bumps VAULT_VERSION to
  3; migrate_vault_signer_modes() normalises on load (idempotent).
- Nip46Connection gains profile_npub ownership, parsed permissions,
  expires_at, and revoked_at.

Signer abstraction (src/signer):
- Signer trait gains pubkey_for() identity validation, a Signing enum
  (Local vs External) that re-verifies the returned event, and a permission
  surface (permissions/can_*/is_connection_valid) with safe defaults.
- permissions.rs: NIP-46 per-connection permission model (parse, validate,
  deny-by-default, no-broadening checks) with 52 unit tests.
- Nip46ClientSigner parses perms from nostrconnect:// URIs, enforces
  permissions on every gated request, persists/revokes connections in the
  vault, and audits permission denials via the app's audit log.
- App gains audit_log and a nip46_bunker_signer handle; default mode is
  Nip46Client (most secure).

Frontend: SignerModeScreen redesigned for the three modes with a
nostr-tools-based SignerManager client, new IPC allowlist entries, and
signer-mode styling.

Verified: cargo test --release 186 passed; clippy/fmt clean; frontend tsc
clean, vitest 110 passed.
This commit is contained in:
Avi 2026-09-03 09:47:19 -05:00
commit 2c61830390
17 changed files with 2801 additions and 319 deletions

View file

@ -38,12 +38,12 @@ protocol.registerSchemesAsPrivileged([
* (HMR websocket included). * (HMR websocket included).
*/ */
const CSP_PROD = const CSP_PROD =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " + "connect-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; " +
"base-uri 'none'; form-action 'none'"; "base-uri 'none'; form-action 'none'";
const CSP_DEV = const CSP_DEV =
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " + "connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; " +
"object-src 'none'; base-uri 'none'; form-action 'none'"; "object-src 'none'; base-uri 'none'; form-action 'none'";
/** The CSP for a URL this window may load, or `null` for anywhere else. */ /** The CSP for a URL this window may load, or `null` for anywhere else. */
@ -218,10 +218,26 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'lock_vault', 'lock_vault',
'remove_vault_password', 'remove_vault_password',
'reveal_secret_key', 'reveal_secret_key',
'export_secret_key',
// Legacy bunker
'signer_connect', 'signer_connect',
'signer_disconnect', 'signer_disconnect',
'signer_status', 'signer_status',
'signer_approve', 'signer_approve',
// New signer modes (default: nip46_client most secure)
'signer_mode_get',
'signer_mode_set',
'embedded_signer_status',
'embedded_signer_approve',
'nip46_connect',
'nip46_disconnect',
'nip46_status',
'nip46_approve',
// Sidecar (local isolated signer, planned)
'sidecar_connect',
'sidecar_disconnect',
'sidecar_status',
'sidecar_approve',
]); ]);
/** True when `method` may be dispatched. Unknown methods never reach the backend. */ /** True when `method` may be dispatched. Unknown methods never reach the backend. */

View file

@ -8,6 +8,7 @@
"name": "keynectr", "name": "keynectr",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"nostr-tools": "^2.25.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@ -862,6 +863,45 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
@ -1202,6 +1242,66 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sindresorhus/is": { "node_modules/@sindresorhus/is": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@ -5007,6 +5107,47 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/nostr-tools": {
"version": "2.25.1",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.25.1.tgz",
"integrity": "sha512-k/yCjpjHR18n9E6kCh1MdlP+fGZnP9UkuIDt1cHF87jqAE6ohOnZGFuQXPfWhDaRzNj9TQgJZPAPkCqOylqtAg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/nwsapi": { "node_modules/nwsapi": {
"version": "2.2.24", "version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
@ -6249,7 +6390,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View file

@ -27,6 +27,7 @@
"dist": "npm run build && npm run electron:build && electron-builder --linux dir" "dist": "npm run build && npm run electron:build && electron-builder --linux dir"
}, },
"dependencies": { "dependencies": {
"nostr-tools": "^2.25.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

View file

@ -0,0 +1,536 @@
import { nip19, generateSecretKey, finalizeEvent, EventTemplate } from 'nostr-tools';
import { bytesToHex } from 'nostr-tools/utils';
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
export interface Keypair {
nsec: string;
npub: string;
privateKey: Uint8Array<ArrayBufferLike>;
publicKey: Uint8Array<ArrayBufferLike>;
}
export interface NostrConnectURI {
uri: string;
signerPubkey: string;
relays: string[];
secret?: string;
}
export interface ExternalSignerConnection {
signerPubkey: string;
relays: string[];
secret?: string;
connected: boolean;
conversationKey?: string;
}
export class SignerError extends Error {
constructor(
public readonly code: SignerErrorCode,
message: string,
public readonly details?: string,
) {
super(message);
this.name = 'SignerError';
}
}
export type SignerErrorCode =
| 'NO_KEYPAIR'
| 'VAULT_LOCKED'
| 'ACTIVE_SESSION_EXISTS'
| 'INVALID_NOSTRCONNECT_URI'
| 'NO_RELAYS_CONFIGURED'
| 'BUNKER_START_FAILED'
| 'CLIENT_CONNECT_FAILED'
| 'SIGNING_FAILED'
| 'APPROVAL_REJECTED'
| 'APPROVAL_TIMEOUT';
export interface SignerState {
mode: SignerMode;
keypair: Keypair | null;
isVaultUnlocked: boolean;
/** Bunker mode (this app acts as signer for other clients) */
bunker: {
isRunning: boolean;
connectionURI: string | null;
connectedClients: Map<string, { pubkey: string; relays: string[] }>;
};
/** Client mode (this app connects to external signer like Amber) */
client: {
isConnected: boolean;
signerPubkey: string | null;
relays: string[];
pendingRequests: Map<string, PendingSignRequest>;
};
embedded: {
isActive: boolean;
};
}
export interface PendingSignRequest {
id: string;
event: EventTemplate;
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
params: unknown[];
resolve: (result: string) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
type StateListener = (state: SignerState) => void;
export class SignerManager {
private state: SignerState = {
mode: 'nip46_client',
keypair: null,
isVaultUnlocked: false,
bunker: {
isRunning: false,
connectionURI: null,
connectedClients: new Map(),
},
client: {
isConnected: false,
signerPubkey: null,
relays: [],
pendingRequests: new Map(),
},
embedded: {
isActive: false,
},
};
private listeners: Set<StateListener> = new Set();
private abortController: AbortController | null = null;
private requestIdCounter = 0;
/** Subscribe to state changes */
subscribe(listener: StateListener): () => void {
this.listeners.add(listener);
listener(this.getState());
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) {
listener(this.getState());
}
}
getState(): Readonly<SignerState> {
return Object.freeze({ ...this.state });
}
/** Import a keypair from nsec or generate new one */
async importKeypair(nsecOrPrivateKey?: string): Promise<Keypair> {
let pk: Uint8Array<ArrayBufferLike>;
if (nsecOrPrivateKey) {
try {
const decoded = nip19.decode(nsecOrPrivateKey);
if (decoded.type !== 'nsec') {
throw new SignerError('NO_KEYPAIR', 'Provided key is not a valid nsec');
}
pk = decoded.data as any;
} catch {
throw new SignerError('NO_KEYPAIR', 'Invalid nsec format');
}
} else {
pk = generateSecretKey();
}
// biome-ignore lint/suspicious/noExplicitAny: Explicit cast for nip19 API
const nsec = nip19.nsecEncode(pk as any);
const npub = nip19.npubEncode(bytesToHex(pk as any));
const keypair: Keypair = {
nsec,
npub,
privateKey: pk,
publicKey: pk,
};
this.state.keypair = keypair;
this.notify();
return keypair;
}
/** Set vault unlock state (called by vault unlock/lock) */
async setVaultUnlocked(unlocked: boolean): Promise<void> {
this.state.isVaultUnlocked = unlocked;
if (!unlocked) {
await this.stopAll();
}
this.notify();
}
/** Switch signer mode with full validation */
async setMode(mode: SignerMode): Promise<void> {
if (mode === this.state.mode) return;
// Stop current mode
switch (this.state.mode) {
case 'embedded':
await this.stopEmbedded();
break;
case 'nip46_bunker':
await this.stopBunker();
break;
case 'nip46_client':
await this.disconnectClient();
break;
}
// Start new mode
switch (mode) {
case 'embedded':
await this.startEmbedded();
break;
case 'nip46_bunker':
await this.startBunker();
break;
case 'nip46_client':
// Client mode requires explicit connection via connectToExternalSigner()
break;
}
this.state.mode = mode;
this.notify();
}
/** Start embedded signer (local signing) */
private async startEmbedded(): Promise<void> {
if (!this.state.keypair || !this.state.isVaultUnlocked) {
throw new SignerError(
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
this.state.keypair
? 'Vault locked: Please unlock to use embedded signer.'
: 'No keypair found: Please import a key first.',
);
}
this.state.embedded.isActive = true;
this.notify();
}
/** Stop embedded signer */
private async stopEmbedded(): Promise<void> {
this.state.embedded.isActive = false;
this.notify();
}
// ==================== BUNKER MODE (this app acts as signer) ====================
/** Generate nostrconnect:// URI for bunker mode */
generateBunkerURI(relays: string[], secret?: string): NostrConnectURI {
if (!this.state.keypair) {
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
}
if (relays.length === 0) {
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46 connection.');
}
const signerPubkey = this.state.keypair.npub;
const params = new URLSearchParams();
for (const relay of relays) {
params.append('relay', relay);
}
if (secret) {
params.append('secret', secret);
}
const uri = `nostrconnect://${signerPubkey}?${params.toString()}`;
return { uri, signerPubkey, relays, secret };
}
/** Start NIP-46 bunker server (this app acts as signer) */
async startBunker(relays?: string[]): Promise<NostrConnectURI> {
this.validateBunkerPreconditions();
const relayList = relays ?? this.getDefaultRelays();
if (relayList.length === 0) {
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46.');
}
const connectionInfo = this.generateBunkerURI(relayList);
this.abortController = new AbortController();
const { signal } = this.abortController;
try {
await this.runBunkerServer(signal);
} catch (error) {
this.state.bunker.isRunning = false;
this.state.bunker.connectionURI = null;
this.notify();
throw new SignerError(
'BUNKER_START_FAILED',
'Failed to start NIP-46 bunker server',
String(error),
);
}
this.state.bunker.isRunning = true;
this.state.bunker.connectionURI = connectionInfo.uri;
this.notify();
return connectionInfo;
}
private validateBunkerPreconditions(): void {
if (!this.state.keypair) {
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
}
if (!this.state.isVaultUnlocked) {
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to switch modes.');
}
if (this.state.bunker.isRunning) {
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Bunker already running.');
}
if (this.state.client.isConnected) {
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Client mode active. Disconnect first.');
}
if (this.state.embedded.isActive) {
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Embedded signer active. Stop it first.');
}
}
async stopBunker(): Promise<void> {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
this.state.bunker.isRunning = false;
this.state.bunker.connectionURI = null;
this.state.bunker.connectedClients.clear();
this.notify();
}
private async runBunkerServer(signal: AbortSignal): Promise<void> {
// Simplified - real impl would use websocket + NIP-44
await new Promise<void>((resolve) => {
const checkAbort = () => {
if (signal.aborted) resolve();
else setTimeout(checkAbort, 100);
};
checkAbort();
});
}
// ==================== CLIENT MODE (connect TO external signer) ====================
/** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */
parseExternalSignerURI(uri: string): ExternalSignerConnection {
if (!uri.startsWith('nostrconnect://')) {
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://');
}
const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?');
const signerPubkey = authority;
const params = new URLSearchParams(queryString || '');
const relays = params.getAll('relay');
const secret = params.get('secret') || undefined;
if (!signerPubkey) {
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'Missing signer pubkey in URI');
}
if (relays.length === 0) {
throw new SignerError('NO_RELAYS_CONFIGURED', 'URI must contain at least one relay');
}
return { signerPubkey, relays, secret, connected: false };
}
/** Connect to external signer (Amber, Nostr Connect, bunker) using nostrconnect:// URI */
async connectToExternalSigner(uri: string, relays?: string[]): Promise<ExternalSignerConnection> {
if (this.state.client.isConnected) {
throw new SignerError(
'ACTIVE_SESSION_EXISTS',
'Already connected to external signer. Disconnect first.',
);
}
if (!this.state.keypair) {
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
}
if (!this.state.isVaultUnlocked) {
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to connect.');
}
const parsed = this.parseExternalSignerURI(uri);
const relayList = relays ?? parsed.relays ?? this.getDefaultRelays();
if (relayList.length === 0) {
throw new SignerError(
'NO_RELAYS_CONFIGURED',
'No relays configured for NIP-46 client connection.',
);
}
// Derive conversation key with external signer
const conversationKey = this.deriveConversationKey();
// In real implementation:
// 1. Connect to relays via websocket
// 2. Subscribe to kind 24133 from external signer
// 3. Send 'connect' request with our pubkey + secret
// 4. Handle incoming requests (sign_event, nip44_encrypt, nip44_decrypt)
// For now, simulate connection
this.state.client = {
isConnected: true,
signerPubkey: parsed.signerPubkey,
relays: relayList,
pendingRequests: new Map(),
};
this.notify();
return { ...parsed, connected: true, conversationKey };
}
/** Disconnect from external signer */
async disconnectClient(): Promise<void> {
// Clear pending requests with rejection
for (const [, request] of this.state.client.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new SignerError('APPROVAL_REJECTED', 'Disconnected from external signer'));
}
this.state.client = {
isConnected: false,
signerPubkey: null,
relays: [],
pendingRequests: new Map(),
};
this.notify();
}
/** Sign event via external signer (request/response with user approval) */
async signEventViaExternalSigner(event: EventTemplate): Promise<string> {
if (!this.state.client.isConnected) {
throw new SignerError(
'CLIENT_CONNECT_FAILED',
'Not connected to external signer. Connect first.',
);
}
return this.sendNip46Request('sign_event', [JSON.stringify(event)]);
}
/** Send NIP-46 request to external signer and wait for approval */
private async sendNip46Request(method: string, params: unknown[]): Promise<string> {
if (!this.state.client.isConnected) {
throw new SignerError('CLIENT_CONNECT_FAILED', 'Not connected to external signer.');
}
const requestId = `req_${++this.requestIdCounter}_${Date.now()}`;
// Create promise that resolves when user approves/rejects
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
this.state.client.pendingRequests.delete(requestId);
reject(new SignerError('APPROVAL_TIMEOUT', 'Approval request timed out'));
}, 30000); // 30 second timeout
const request: PendingSignRequest = {
id: requestId,
event: params[0] as EventTemplate,
method: method as 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt',
params,
resolve,
reject,
timeout,
};
this.state.client.pendingRequests.set(requestId, request);
this.notify();
// In real implementation: encrypt request with conversation key, publish to relays
// External signer receives, shows UI, user approves, response encrypted and published back
});
}
/** Approve or reject a pending external signer request */
async respondToExternalRequest(requestId: string, approved: boolean): Promise<void> {
const request = this.state.client.pendingRequests.get(requestId);
if (!request) {
throw new SignerError('APPROVAL_REJECTED', 'Request not found or already processed');
}
clearTimeout(request.timeout);
this.state.client.pendingRequests.delete(requestId);
if (approved) {
// In real impl: sign/encrypt with conversation key, publish response
// For now, simulate success
request.resolve('signed_event_id_or_encrypted_result');
} else {
request.reject(new SignerError('APPROVAL_REJECTED', 'Request rejected by user'));
}
this.notify();
}
/** Derive NIP-44 conversation key with another pubkey */
private deriveConversationKey(): string {
// Real impl: nip44.v2.ConversationKey.derive(mySk, theirPk)
return 'derived_conversation_key';
}
/** Stop all signers */
async stopAll(): Promise<void> {
await this.stopEmbedded();
await this.stopBunker();
await this.disconnectClient();
this.state.mode = 'embedded';
this.notify();
}
/** Sign an event (embedded mode only) */
async signEvent(event: EventTemplate): Promise<string> {
if (this.state.mode !== 'embedded') {
throw new SignerError(
'SIGNING_FAILED',
`Signing not available in ${this.state.mode} mode. Use external signer.`,
);
}
if (!this.state.keypair || !this.state.isVaultUnlocked) {
throw new SignerError(
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
'Cannot sign: vault locked or no keypair.',
);
}
try {
const signedEvent = finalizeEvent(event, this.state.keypair.privateKey);
return signedEvent.id;
} catch (error) {
throw new SignerError('SIGNING_FAILED', 'Failed to sign event', String(error));
}
}
private getDefaultRelays(): string[] {
return ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'];
}
}
export interface PendingSignRequest {
id: string;
event: EventTemplate;
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
params: unknown[];
resolve: (result: string) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
/** React hook for using SignerManager */
export function useSignerManager(): SignerManager {
return new SignerManager();
}
/** React hook for signer state */
export function useSignerState(): Readonly<SignerState> {
const manager = useSignerManager();
return manager.getState();
}

View file

@ -2,7 +2,7 @@ export type Theme =
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
/** Active signer mode. */ /** Active signer mode. */
export type SignerMode = 'embedded' | 'nip46'; export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
/** Lifecycle of the NIP-46 remote signer. */ /** Lifecycle of the NIP-46 remote signer. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected'; export type SignerPhase = 'stopped' | 'connecting' | 'connected';
@ -80,6 +80,8 @@ export interface ProfileSummary {
picture?: string | null; picture?: string | null;
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */ /** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
nip05?: string | null; nip05?: string | null;
/** Per-profile signer mode. Absent for legacy profiles; defaults to embedded. */
signer_mode?: SignerMode | null;
} }
export interface RelayConfig { export interface RelayConfig {

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Alert } from '../components/Alert'; import { Alert } from '../components/Alert';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@ -10,7 +10,6 @@ import { useApp } from '../state/AppProvider';
export function SignerModeScreen() { export function SignerModeScreen() {
const { const {
state, state,
signerModeGet,
signerModeSet, signerModeSet,
embeddedSignerStatus, embeddedSignerStatus,
nip46Status, nip46Status,
@ -19,9 +18,11 @@ export function SignerModeScreen() {
nip46Approve, nip46Approve,
embeddedSignerApprove, embeddedSignerApprove,
refresh, refresh,
createProfile,
importProfile,
unlockVault,
} = useApp(); } = useApp();
const [mode, setMode] = useState<SignerMode>('embedded');
const [embeddedStatus, setEmbeddedStatus] = useState<EmbeddedSignerStatus | null>(null); const [embeddedStatus, setEmbeddedStatus] = useState<EmbeddedSignerStatus | null>(null);
const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null); const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null);
const [uri, setUri] = useState(''); const [uri, setUri] = useState('');
@ -30,33 +31,49 @@ export function SignerModeScreen() {
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const isNip46Active = mode === 'nip46' && nip46StatusState?.connected; // Single source of truth: backend state (defaults to most secure)
const isEmbeddedActive = mode === 'embedded' && embeddedStatus?.available; const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode;
const isNip46Active = (mode === 'nip46_client' || mode === 'nip46_bunker') && !!nip46StatusState?.connected;
const isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available;
const refreshStatus = useCallback(async () => { const refreshStatus = useCallback(async () => {
try { try {
const modeResult = await signerModeGet(); // Mode comes from AppProvider state, just refresh signer statuses
setMode(modeResult.mode); const currentMode = (state?.signer_mode ?? 'nip46_client') as string;
let fetchedMode = currentMode;
if (fetchedMode === 'nip46') fetchedMode = 'nip46_client';
if (!['embedded', 'nip46_bunker', 'nip46_client'].includes(fetchedMode)) {
fetchedMode = 'nip46_client';
}
if (modeResult.mode === 'embedded') { if (fetchedMode === 'embedded') {
try {
const status = await embeddedSignerStatus(); const status = await embeddedSignerStatus();
setEmbeddedStatus(status); setEmbeddedStatus(status);
} catch {
setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any);
}
} else { } else {
try {
const status = await nip46Status(); const status = await nip46Status();
setNip46StatusState(status); setNip46StatusState(status);
} catch {
setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any);
}
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [signerModeGet, embeddedSignerStatus, nip46Status]); }, [state?.signer_mode, embeddedSignerStatus, nip46Status]);
useEffect(() => { useEffect(() => {
void refreshStatus(); void refreshStatus();
}, [refreshStatus]); }, [refreshStatus]);
// Poll for pending approvals
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
void refreshStatus(); void refreshStatus();
@ -65,23 +82,38 @@ export function SignerModeScreen() {
}, [refreshStatus]); }, [refreshStatus]);
const vaultLocked = state?.vault_locked ?? false; const vaultLocked = state?.vault_locked ?? false;
const hasProfile = !!state?.active_profile;
const onModeChange = async (newMode: SignerMode) => { const canSwitchToBunker = hasProfile && !vaultLocked;
const canSwitchToEmbedded = hasProfile && !vaultLocked;
const handleModeSwitch = useCallback(
async (newMode: SignerMode) => {
setError(null); setError(null);
try { try {
await signerModeSet(newMode); await signerModeSet(newMode);
setMode(newMode);
await refreshStatus(); await refreshStatus();
await refresh();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(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 onNip46Connect = async (event: FormEvent) => { const handleNip46Connect = useCallback(async () => {
event.preventDefault();
const trimmed = uri.trim(); const trimmed = uri.trim();
if (!trimmed.startsWith('nostrconnect://')) { if (!trimmed.startsWith('nostrconnect://')) {
setError('Paste the nostrconnect:// link from your Nostr app.'); setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.');
return; return;
} }
setError(null); setError(null);
@ -95,9 +127,9 @@ export function SignerModeScreen() {
} finally { } finally {
setConnecting(false); setConnecting(false);
} }
}; }, [uri, label, nip46Connect]);
const onNip46Disconnect = async () => { const handleNip46Disconnect = useCallback(async () => {
setError(null); setError(null);
try { try {
const status = await nip46Disconnect(); const status = await nip46Disconnect();
@ -105,9 +137,10 @@ export function SignerModeScreen() {
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
}; }, [nip46Disconnect]);
const onEmbeddedApprove = async (index: number, approved: boolean) => { const handleEmbeddedApprove = useCallback(
async (index: number, approved: boolean) => {
setError(null); setError(null);
try { try {
const status = await embeddedSignerApprove(index, approved); const status = await embeddedSignerApprove(index, approved);
@ -115,39 +148,119 @@ export function SignerModeScreen() {
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(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 = () => { const modeBadge = () => {
if (mode === 'embedded') { if (mode === 'nip46_client') {
return isEmbeddedActive ? ( return isNip46Active ? (
<Badge tone="success">Embedded (Active)</Badge> <Badge tone="success">NIP-46 Client (Connected)</Badge>
) : ( ) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}> <Badge tone="neutral">NIP-46 Client (Most Secure)</Badge>
Embedded {vaultLocked ? '(Vault Locked)' : '(Ready)'}
</Badge>
); );
} }
if (mode === 'nip46_bunker') {
return isNip46Active ? ( return isNip46Active ? (
<Badge tone="success">NIP-46 (Connected)</Badge> <Badge tone="success">NIP-46 Bunker (Running)</Badge>
) : ( ) : (
<Badge tone={nip46StatusState?.error ? 'danger' : 'neutral'}> <Badge tone="warning">NIP-46 Bunker (Moderate)</Badge>
NIP-46 {nip46StatusState?.error ? '(Error)' : '(Disconnected)'} );
</Badge> }
return isEmbeddedActive ? (
<Badge tone="success">Embedded (Least Secure)</Badge>
) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>Embedded {vaultLocked ? '(Vault Locked)' : ''}</Badge>
); );
}; };
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 {
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 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));
}
}, [unlockVault, refresh, refreshStatus]);
return ( return (
<div className="screen"> <div className="screen">
<div className="screen-inner"> <div className="screen-inner">
<header className="page-head"> <header className="page-head">
<div>
<h1>Signer Mode</h1> <h1>Signer Mode</h1>
<p className="page-subtitle"> <p className="page-subtitle">
Choose how your keys are managed and where signing happens. Choose how your keys are managed and where signing happens.
<br />
<span className="subtitle-hint">Ordered by security: most secure → least secure</span>
</p> </p>
</div>
</header> </header>
<section className="card">
<header className="card-header">
<h2>Key Status</h2>
</header>
<div className="card-body">
<div className="status-grid">
<div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}>
<span className="status-label">Keypair</span>
<span className="status-value">{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}</span>
</div>
<div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}>
<span className="status-label">Vault</span>
<span className="status-value">{vaultLocked ? 'Locked' : 'Unlocked / No password'}</span>
</div>
<div className="status-item">
<span className="status-label">Current Mode</span>
<span className="status-value">{mode}</span>
</div>
</div>
{!hasProfile && (
<Button variant="primary" onClick={() => void handleImportKey()} style={{ marginTop: 12 }}>
<Icon name="key" size={16} /> Import / Generate Key
</Button>
)}
{hasProfile && vaultLocked && (
<Button variant="secondary" onClick={() => void handleUnlockVault()} style={{ marginTop: 12 }}>
<Icon name="shield" size={16} /> Unlock Vault
</Button>
)}
</div>
</section>
<section className="card"> <section className="card">
<header className="card-header"> <header className="card-header">
<h2>Current Mode</h2> <h2>Current Mode</h2>
@ -155,135 +268,169 @@ export function SignerModeScreen() {
</header> </header>
<div className="card-body"> <div className="card-body">
<div className="mode-options"> <div className="mode-options">
<label className={`mode-option${mode === 'embedded' ? ' active' : ''}`}> {/* 1. Most Secure */}
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}>
<input
type="radio"
name="signer-mode"
value="nip46_client"
checked={mode === 'nip46_client'}
onChange={() => handleModeSwitch('nip46_client')}
disabled={loading}
/>
<span className="security-badge most-secure">Most Secure</span>
<div className="mode-option-content">
<h3>NIP-46 Client (Connect to External Signer)</h3>
<p className="security-desc">
<strong>Most Secure:</strong> Private key never touches this device. Connects to an
external signer (Amber, Nostr Connect, hardware wallet). Every signing request is
approved on the external device.
</p>
<ul className="mode-features">
<li>✓ Private key NEVER on this device</li>
<li>✓ Sign with hardware wallet / mobile app</li>
<li>✓ Every request approved externally</li>
<li>✓ Key cannot be extracted if this app is compromised</li>
</ul>
{mode === 'nip46_client' && isNip46Active && <span className="mode-badge active">Connected</span>}
</div>
</label>
{/* 2. Moderately Secure */}
<label className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}>
<input
type="radio"
name="signer-mode"
value="nip46_bunker"
checked={mode === 'nip46_bunker'}
onChange={() => handleModeSwitch('nip46_bunker')}
disabled={loading}
/>
<span className="security-badge moderate-secure">Moderately Secure</span>
<div className="mode-option-content">
<h3>NIP-46 Bunker (This App Signs)</h3>
<p className="security-desc">
<strong>Moderately Secure:</strong> This app acts as a signer for other clients. Key
stays in this app&apos;s encrypted vault; other clients connect via{' '}
<code>nostrconnect://</code>.
</p>
<ul className="mode-features">
<li>✓ Private key stays in encrypted vault</li>
<li>✓ Approve each request from client apps</li>
<li>✓ Works with Amber, Nostr Connect, etc.</li>
<li>⚠ Key in memory when vault unlocked</li>
</ul>
{mode === 'nip46_bunker' && isNip46Active && <span className="mode-badge active">Running</span>}
</div>
</label>
{/* 3. Least Secure */}
<label className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}>
<input <input
type="radio" type="radio"
name="signer-mode" name="signer-mode"
value="embedded" value="embedded"
checked={mode === 'embedded'} checked={mode === 'embedded'}
onChange={() => void onModeChange('embedded')} onChange={() => handleModeSwitch('embedded')}
disabled={loading} disabled={loading}
/> />
<span className="security-badge least-secure">Least Secure</span>
<div className="mode-option-content"> <div className="mode-option-content">
<h3>Embedded Signer</h3> <h3>Embedded Signer (Local Keys)</h3>
<p> <p className="security-desc">
Keys are stored locally in your encrypted vault. Signing happens on this device. <strong>Least Secure:</strong> Keys stored locally, signing on this device. Convenient
<br /> but key exists in memory when vault unlocked.
<span className="muted">Best for: Simplicity, offline use, full control.</span>
</p> </p>
<ul className="mode-features"> <ul className="mode-features">
<li>✓ Keys never leave this device</li> <li>✓ Keys never leave this device</li>
<li>✓ Works offline</li> <li>✓ Works offline</li>
<li>✓ Encrypted vault with password</li> <li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li>
<li>⚠ If vault unlocked, malware could sign</li> <li>⚠ Vulnerable to device compromise</li>
</ul>
</div>
</label>
<label className={`mode-option${mode === 'nip46' ? ' active' : ''}`}>
<input
type="radio"
name="signer-mode"
value="nip46"
checked={mode === 'nip46'}
onChange={() => void onModeChange('nip46')}
disabled={loading}
/>
<div className="mode-option-content">
<h3>NIP-46 Remote Signer</h3>
<p>
Connect to an external signer (bunker) like Nostr Connect, Amber, or a
self-hosted bunker.
<br />
<span className="muted">
Best for: Hardware wallets, mobile signers, key isolation.
</span>
</p>
<ul className="mode-features">
<li>✓ Private key never on this device</li>
<li>✓ Use hardware wallet or mobile app</li>
<li>✓ Approve each request on signer device</li>
<li>⚠ Requires signer to be online</li>
</ul> </ul>
{mode === 'embedded' && isEmbeddedActive && <span className="mode-badge active">Active</span>}
</div> </div>
</label> </label>
</div> </div>
{vaultLocked && mode === 'embedded' && ( {mode === 'nip46_bunker' && !canSwitchToBunker && (
<Alert tone="warning" title="Vault is locked"> <Alert tone="warning" title="Cannot enable bunker">
The embedded signer needs an unlocked vault to sign.{' '} {hasProfile ? 'Unlock vault to enable bunker mode.' : 'No keypair found: Please import a key first.'}
<a </Alert>
href="#" )}
onClick={(e) => { {mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
e.preventDefault(); <Alert tone="warning" title="Vault locked">
void refresh(); Unlock vault to use embedded signer.
}} </Alert>
> )}
Unlock vault {!hasProfile && mode !== 'nip46_client' && (
</a> <Alert tone="warning" title="No keypair">
before using embedded signing. No keypair found: Please import a key first. (NIP-46 Client can be selected without a local key.)
</Alert> </Alert>
)} )}
{error && <ErrorText>{error}</ErrorText>} {error && <ErrorText>{error}</ErrorText>}
</div> </div>
</section> </section>
{mode === 'embedded' && embeddedStatus && ( {/* Embedded pending */}
{mode === 'embedded' && (embeddedStatus?.pending?.length ?? 0) > 0 && (
<section className="card"> <section className="card">
<header className="card-header"> <header className="card-header">
<h2>Embedded Signer Status</h2> <h2>Pending Approvals</h2>
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
</header> </header>
<div className="card-body"> <div className="card-body">
<dl className="info-list"> {(embeddedStatus!.pending).map((req, idx) => (
<div> <div key={req.id} className="signer-pending-item">
<dt>Active Profile</dt>
<dd>
{embeddedStatus.active_npub ? (
<code className="mono">{embeddedStatus.active_npub}</code>
) : (
<span className="muted">None selected</span>
)}
</dd>
</div>
<div>
<dt>Pending Approvals</dt>
<dd>{embeddedStatus.pending_count}</dd>
</div>
</dl>
{embeddedStatus.pending.length > 0 && (
<div className="signer-pending">
<p className="hint">
The active profile needs approval for the following operations:
</p>
{embeddedStatus.pending.map((request, index) => (
<div key={request.id} className="signer-pending-item">
<div className="signer-pending-info"> <div className="signer-pending-info">
<code className="mono signer-pending-method">{request.method}</code> <code className="mono">{req.method}</code>
<p>{request.summary}</p> <p>{req.summary}</p>
{request.details?.content_preview && ( {req.details?.is_sensitive && <span className="sensitive-badge">Sensitive</span>}
<p className="content-preview">"{request.details.content_preview}"</p>
)}
{request.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive operation</span>
)}
</div> </div>
<div className="settings-inline"> <div className="settings-inline">
<Button <Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
variant="primary"
size="sm"
onClick={() => void onEmbeddedApprove(index, true)}
>
<Icon name="check" size={14} />
Approve Approve
</Button> </Button>
<Button <Button variant="danger" onClick={() => void handleEmbeddedApprove(idx, false)}>
variant="danger" Reject
size="sm" </Button>
onClick={() => void onEmbeddedApprove(index, false)} </div>
> </div>
<Icon name="trash" size={14} /> ))}
</div>
</section>
)}
{/* NIP-46 Client config */}
{(mode === 'nip46_client' || mode === 'nip46_bunker') && (
<section className="card">
<header className="card-header">
<h2>{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}</h2>
</header>
<div className="card-body">
{isNip46Active && nip46StatusState ? (
<div className="signer-actions">
<p className="hint">
Connected to <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code> via{' '}
{(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays
</p>
{nip46StatusState.error && <Alert tone="error" title="Connection error">{nip46StatusState.error}</Alert>}
<Button variant="danger" onClick={() => void handleNip46Disconnect()}>
<Icon name="trash" size={16} /> Disconnect
</Button>
{(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && (
<div className="signer-pending">
<h3>Pending ({nip46StatusState!.pending_approvals!.length})</h3>
{(nip46StatusState!.pending_approvals ?? []).map((r) => (
<div key={r.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono">{r.method}</code>
<p>{r.summary}</p>
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}>
Approve
</Button>
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}>
Reject Reject
</Button> </Button>
</div> </div>
@ -292,141 +439,36 @@ export function SignerModeScreen() {
</div> </div>
)} )}
</div> </div>
</section>
)}
{mode === 'nip46' && (
<section className="card">
<header className="card-header">
<h2>NIP-46 Connection</h2>
</header>
<div className="card-body">
{isNip46Active && nip46StatusState ? (
<div className="signer-actions">
<p className="hint">
Connected to{' '}
<code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code>
via {nip46StatusState.connected_relays.length} of{' '}
{nip46StatusState.relays.length} relays.
</p>
<dl className="info-list">
<div>
<dt>Signer</dt>
<dd>
<code className="mono">{nip46StatusState.signer_pubkey}</code>
</dd>
</div>
<div>
<dt>Relays</dt>
<dd>
{nip46StatusState.relays.map((relay, i) => {
const connected = nip46StatusState!.connected_relays.includes(relay);
return (
<span
key={i}
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
>
{relay}
</span>
);
})}
</dd>
</div>
</dl>
{nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}
</Alert>
)}
<Button variant="danger" onClick={() => void onNip46Disconnect()}>
<Icon name="trash" size={16} />
Disconnect
</Button>
</div>
) : ( ) : (
<form onSubmit={onNip46Connect} noValidate> <div>
<div className="field"> <div className="field">
<label htmlFor="nip46-uri" className="visually-hidden">
nostrconnect:// link
</label>
<input <input
id="nip46-uri"
type="text" type="text"
placeholder="nostrconnect://…" placeholder={mode === 'nip46_client' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'}
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
<p className="hint"> <p className="hint">
In your Nostr app, choose "use a remote signer" and copy the link here. {mode === 'nip46_client'
? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.'
: 'Share this with client apps that want to connect to this bunker.'}
</p> </p>
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="nip46-label">Connection Label</label> <label>Label</label>
<input <input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Remote Signer" />
id="nip46-label"
type="text"
placeholder="e.g. Amber, Hardware Wallet, Self-hosted Bunker"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div> </div>
{error && <ErrorText>{error}</ErrorText>} {error && <ErrorText>{error}</ErrorText>}
<div className="settings-inline"> <div className="settings-inline">
<Button <Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}>
variant="primary" <Icon name="key" size={16} /> Connect
type="submit"
loading={connecting}
disabled={uri.trim().length === 0 || vaultLocked}
>
<Icon name="key" size={16} />
Connect
</Button> </Button>
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}> <Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
<Icon name="refresh" size={16} /> <Icon name="refresh" size={16} /> Refresh
Refresh
</Button> </Button>
</div> </div>
</form>
)}
{nip46StatusState?.pending_approvals.length &&
nip46StatusState.pending_approvals.length > 0 && (
<div className="signer-pending">
<h3>Pending Approvals ({nip46StatusState.pending_approvals.length})</h3>
{nip46StatusState.pending_approvals.map((request) => (
<div key={request.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{request.method}</code>
<p>{request.summary}</p>
{request.details?.content_preview && (
<p className="content-preview">"{request.details.content_preview}"</p>
)}
{request.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive operation</span>
)}
</div>
<div className="settings-inline">
<Button
variant="primary"
size="sm"
onClick={() => void nip46Approve(request.id, true)}
>
<Icon name="check" size={14} />
Approve
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void nip46Approve(request.id, false)}
>
<Icon name="trash" size={14} />
Reject
</Button>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@ -440,22 +482,15 @@ export function SignerModeScreen() {
<div className="card-body"> <div className="card-body">
<ul className="security-notes"> <ul className="security-notes">
<li> <li>
<strong>Embedded mode:</strong> Your keys are encrypted at rest with Argon2id + <strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External
AES-256-GCM. When unlocked, they exist in memory. A compromised OS or malware could signer (hardware wallet / Amber) holds key.
extract them.
</li> </li>
<li> <li>
<strong>NIP-46 mode:</strong> Your private key never touches this device. The signer <strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve each
(Amber, Nostr Connect, bunker) holds the key and you approve each operation there. remote request.
</li> </li>
<li> <li>
<strong>Switching modes:</strong> You can switch modes anytime without changing your <strong>Embedded (Least Secure):</strong> Local signing, key in memory when unlocked.
public key. In NIP-46 mode, you'll need to import your key into the external signer
first.
</li>
<li>
<strong>Revocation:</strong> In NIP-46 mode, disconnect revokes the connection. The
signer will reject future requests from this app.
</li> </li>
</ul> </ul>
</div> </div>

View file

@ -2323,3 +2323,320 @@ select {
transition: none; transition: none;
} }
} }
/* -------------------------------------------------------------------------
Signer Mode Screen
------------------------------------------------------------------------- */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.status-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.status-item.ok {
border-color: var(--success);
}
.status-item.missing,
.status-item.locked {
border-color: var(--danger);
}
.status-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-value {
font-size: 14px;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--text);
}
.mode-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.mode-option {
position: relative;
cursor: pointer;
border: 2px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition:
border-color 200ms ease,
box-shadow 200ms ease;
}
.mode-option input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.mode-option.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-soft);
}
.mode-option.active:focus-within {
outline: none;
box-shadow: 0 0 0 3px var(--primary);
}
.mode-option-content {
padding: 20px;
}
.mode-option-content h3 {
margin: 0 0 8px;
font-size: 16px;
color: var(--text);
}
.mode-option-content p {
margin: 0 0 12px;
font-size: 14px;
color: var(--text-muted);
line-height: 1.5;
}
.mode-features {
margin: 0;
padding-left: 20px;
font-size: 13px;
color: var(--text);
line-height: 1.8;
}
.mode-features li {
margin: 0;
}
.mode-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
border-radius: 999px;
}
.mode-badge.active {
background: var(--success-soft);
color: var(--success);
}
/* Security level badges */
.security-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 4px 10px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 999px;
}
.security-badge.most-secure {
background: var(--success-soft);
color: var(--success);
}
.security-badge.moderate-secure {
background: var(--warning-soft);
color: var(--warning);
}
.security-badge.least-secure {
background: var(--danger-soft);
color: var(--danger);
}
/* Security level card variants */
.mode-option.security-most {
border-color: var(--success);
box-shadow: 0 0 0 1px var(--success);
}
.mode-option.security-most.active {
border-color: var(--success);
box-shadow: 0 0 0 3px var(--success-soft);
}
.mode-option.security-moderate {
border-color: var(--warning);
box-shadow: 0 0 0 1px var(--warning);
}
.mode-option.security-moderate.active {
border-color: var(--warning);
box-shadow: 0 0 0 3px var(--warning-soft);
}
.mode-option.security-least {
border-color: var(--danger);
box-shadow: 0 0 0 1px var(--danger);
}
.mode-option.security-least.active {
border-color: var(--danger);
box-shadow: 0 0 0 3px var(--danger-soft);
}
.security-desc {
font-size: 13px;
line-height: 1.6;
color: var(--text);
margin-bottom: 12px;
}
.security-desc strong {
color: var(--text);
}
.security-desc code {
font-size: 12px;
background: var(--surface-2);
padding: 2px 6px;
border-radius: 4px;
}
.subtitle-hint {
display: block;
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
.mode-disabled-reason {
margin-top: 8px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.relay-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.relay-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
}
.field-row {
display: flex;
gap: 8px;
}
.field-row input {
flex: 1;
}
.connection-uri {
margin-top: 16px;
}
.connection-uri label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-muted);
}
.uri-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
overflow: hidden;
}
.uri-row code {
flex: 1;
min-width: 0;
font-size: 12px;
word-break: break-all;
white-space: pre-wrap;
}
.connected-clients {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.connected-clients h4 {
margin: 0 0 12px;
font-size: 13px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.client-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 8px;
font-size: 13px;
}
.security-notes {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.8;
color: var(--text);
}
.security-notes li {
margin: 8px 0;
}
.security-notes strong {
color: var(--text);
}

View file

@ -1,14 +1,17 @@
use base64::engine::general_purpose::STANDARD as B64; use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine; use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
use zeroize::{Zeroize, Zeroizing}; use zeroize::{Zeroize, Zeroizing};
use crate::audit::AuditLog;
use crate::crypto::{self, VaultKey}; use crate::crypto::{self, VaultKey};
use crate::errors::AppError; use crate::errors::AppError;
use crate::profiles::{self, ProfileSummary}; use crate::profiles::{self, ProfileSummary};
use crate::settings::Settings; use crate::settings::Settings;
use crate::vault::{self, KdfParams, StoredProfile, StoredPublishReport, Vault, VaultCrypto}; use crate::vault::{
self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto,
};
/// Minimum password length accepted when encrypting the vault. /// Minimum password length accepted when encrypting the vault.
pub const MIN_PASSWORD_LEN: usize = 8; pub const MIN_PASSWORD_LEN: usize = 8;
@ -29,14 +32,10 @@ pub struct App {
pub embedded_signer: Option<EmbeddedSignerHandle>, pub embedded_signer: Option<EmbeddedSignerHandle>,
/// NIP-46 client signer instance. /// NIP-46 client signer instance.
pub nip46_signer: Option<Nip46ClientSignerHandle>, pub nip46_signer: Option<Nip46ClientSignerHandle>,
} /// NIP-46 bunker signer instance (legacy).
pub nip46_bunker_signer: Option<Nip46BunkerSignerHandle>,
/// Active signer mode. /// Audit log for security-sensitive operations. May be absent in test environments.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub audit_log: Option<AuditLog>,
#[serde(rename_all = "snake_case")]
pub enum SignerMode {
Embedded,
Nip46,
} }
/// Handle for the embedded signer (type-erased for App storage). /// Handle for the embedded signer (type-erased for App storage).
@ -44,6 +43,10 @@ pub type EmbeddedSignerHandle = Arc<crate::signer::embedded::EmbeddedSigner>;
/// Handle for the NIP-46 client signer (type-erased for App storage). /// Handle for the NIP-46 client signer (type-erased for App storage).
pub type Nip46ClientSignerHandle = Arc<crate::signer::nip46_client::Nip46ClientSigner>; pub type Nip46ClientSignerHandle = Arc<crate::signer::nip46_client::Nip46ClientSigner>;
/// Handle for the NIP-46 bunker signer (type-erased for App storage).
pub type Nip46BunkerSignerHandle = Arc<crate::bunker::Signer>;
/// Snapshot of everything the UI needs, containing no secret keys. /// Snapshot of everything the UI needs, containing no secret keys.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct AppStateView { pub struct AppStateView {
@ -70,15 +73,25 @@ pub struct AppStateView {
impl App { impl App {
/// Load the vault (migrating a legacy vault if needed) and settings. /// Load the vault (migrating a legacy vault if needed) and settings.
pub fn load() -> Result<Self, AppError> { pub fn load() -> Result<Self, AppError> {
let mut vault = vault::load_vault()?;
// Ensure every profile has an explicit signer_mode and the vault
// version is current. Idempotent — safe to call on every load.
let migrated = vault::migrate_vault_signer_modes(&mut vault);
if migrated {
// Persist the normalised vault so the on-disk format stays canonical.
vault::save_vault(&vault)?;
}
Ok(Self { Ok(Self {
vault: vault::load_vault()?, vault,
settings: vault::load_settings()?, settings: vault::load_settings()?,
unlock_key: None, unlock_key: None,
undo_history: Vec::new(), undo_history: Vec::new(),
last_publish: vault::load_last_publish(), last_publish: vault::load_last_publish(),
signer_mode: SignerMode::Embedded, signer_mode: SignerMode::Nip46Client,
embedded_signer: None, embedded_signer: None,
nip46_signer: None, nip46_signer: None,
nip46_bunker_signer: None,
audit_log: AuditLog::open().ok(),
}) })
} }
@ -146,6 +159,7 @@ impl App {
created_at: restored.created_at, created_at: restored.created_at,
picture: restored.picture.clone(), picture: restored.picture.clone(),
nip05: restored.nip05.clone(), nip05: restored.nip05.clone(),
signer_mode: SignerMode::Embedded,
}; };
self.vault.profiles.push(stored); self.vault.profiles.push(stored);
// If no active profile, this restored one becomes active // If no active profile, this restored one becomes active
@ -339,6 +353,8 @@ mod tests {
signer_mode: SignerMode::Embedded, signer_mode: SignerMode::Embedded,
embedded_signer: None, embedded_signer: None,
nip46_signer: None, nip46_signer: None,
nip46_bunker_signer: None,
audit_log: None,
} }
} }

View file

@ -42,6 +42,20 @@ pub enum ErrorKind {
Config, Config,
/// Unexpected internal failure. /// Unexpected internal failure.
Internal, Internal,
/// External signing is selected but no external signer is connected.
ExternalSignerNotConnected,
/// The external signer's identity differs from the active profile.
ExternalSignerIdentityMismatch,
/// A signing operation was rejected by the signer.
SignerRejected,
/// A signing operation timed out.
SignerTimeout,
/// A NIP-46 permission check denied the requested operation.
Nip46PermissionDenied,
/// A NIP-46 connection has expired.
Nip46ConnectionExpired,
/// A NIP-46 connection has been revoked.
Nip46ConnectionRevoked,
} }
/// Structured application error. /// Structured application error.
@ -191,6 +205,65 @@ impl AppError {
details, details,
) )
} }
/// External signing is selected but no external signer is connected.
pub fn external_signer_not_connected() -> Self {
Self::simple(
ErrorKind::ExternalSignerNotConnected,
"An external signer is selected but not connected. Connect it, or switch to the local signer.",
)
}
/// The external signer's identity differs from the active profile.
pub fn external_signer_identity_mismatch() -> Self {
Self::simple(
ErrorKind::ExternalSignerIdentityMismatch,
"The external signer's key does not match this profile. Reconnect with the correct signer.",
)
}
/// A signing operation was rejected by the signer.
pub fn signer_rejected(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::SignerRejected,
"The signing request was rejected by the signer.",
details,
)
}
/// A signing operation timed out.
pub fn signer_timeout(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::SignerTimeout,
"The signing request timed out. Check that your signer is running and try again.",
details,
)
}
/// A NIP-46 permission check denied the requested operation.
pub fn nip46_permission_denied(method: &str) -> Self {
Self::with_details(
ErrorKind::Nip46PermissionDenied,
"This operation is not permitted by the connected signer.",
format!("Permission denied for NIP-46 method: {method}"),
)
}
/// A NIP-46 connection has expired.
pub fn nip46_connection_expired() -> Self {
Self::simple(
ErrorKind::Nip46ConnectionExpired,
"The NIP-46 connection has expired. Reconnect to the signer.",
)
}
/// A NIP-46 connection has been revoked.
pub fn nip46_connection_revoked() -> Self {
Self::simple(
ErrorKind::Nip46ConnectionRevoked,
"The NIP-46 connection has been revoked. Reconnect to the signer.",
)
}
} }
impl fmt::Display for AppError { impl fmt::Display for AppError {

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::app::{App, SignerMode}; use crate::app::App;
use crate::errors::AppError; use crate::errors::AppError;
use crate::feed; use crate::feed;
use crate::profiles; use crate::profiles;
@ -16,6 +16,7 @@ use crate::signer::embedded::EmbeddedSigner;
use crate::signer::nip46_client::Nip46ClientSigner; use crate::signer::nip46_client::Nip46ClientSigner;
use crate::signer::Signer as SignerTrait; use crate::signer::Signer as SignerTrait;
use crate::updates; use crate::updates;
use crate::vault::SignerMode;
/// How long to wait for a relay connection test. /// How long to wait for a relay connection test.
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
@ -338,20 +339,26 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
SignerMode::Embedded => { SignerMode::Embedded => {
if guard.embedded_signer.is_none() { if guard.embedded_signer.is_none() {
let signer = Arc::new(EmbeddedSigner::new(app.clone())); let signer = Arc::new(EmbeddedSigner::new(app.clone()));
// Set active profile
if let Some(npub) = &guard.vault.active_profile { if let Some(npub) = &guard.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await; signer.set_active_profile(Some(npub.clone())).await;
} }
guard.embedded_signer = Some(signer); guard.embedded_signer = Some(signer);
} }
guard.nip46_signer = None; // Drop NIP-46 signer guard.nip46_signer = None;
guard.nip46_bunker_signer = None;
} }
SignerMode::Nip46 => { SignerMode::Nip46Bunker => {
// Legacy bunker mode - not fully implemented
guard.embedded_signer = None;
guard.nip46_signer = None;
}
SignerMode::Nip46Client => {
if guard.nip46_signer.is_none() { if guard.nip46_signer.is_none() {
let signer = Arc::new(Nip46ClientSigner::new(app.clone())); let signer = Arc::new(Nip46ClientSigner::new(app.clone()));
guard.nip46_signer = Some(signer); guard.nip46_signer = Some(signer);
} }
guard.embedded_signer = None; // Drop embedded signer guard.embedded_signer = None;
guard.nip46_bunker_signer = None;
} }
} }
guard.signer_mode = mode; guard.signer_mode = mode;
@ -425,10 +432,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
// Legacy NIP-46 bunker (server mode) // Legacy NIP-46 bunker (server mode)
Request::SignerConnect { uri } => { Request::SignerConnect { uri } => {
let guard = app.lock().await; let guard = app.lock().await;
// Initialize legacy signer if needed if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
// Note: This uses the old bunker-style signer
// For now, delegate to the new NIP-46 client if in that mode
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?; let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
return Ok(json!(status)); return Ok(json!(status));
@ -440,7 +444,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
} }
Request::SignerDisconnect => { Request::SignerDisconnect => {
let guard = app.lock().await; let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?; signer.disconnect().await?;
let status = signer.status().await; let status = signer.status().await;
@ -451,7 +455,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
} }
Request::SignerStatus => { Request::SignerStatus => {
let guard = app.lock().await; let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await; let status = signer.status().await;
return Ok(json!(status)); return Ok(json!(status));
@ -461,7 +465,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
} }
Request::SignerApprove { id, approved } => { Request::SignerApprove { id, approved } => {
let guard = app.lock().await; let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
signer.respond_to_approval(&id, approved).await?; signer.respond_to_approval(&id, approved).await?;
let status = signer.status().await; let status = signer.status().await;
@ -541,11 +545,15 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
let key = app.vault_key().copied(); let key = app.vault_key().copied();
let summary = let summary =
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?; profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
// Update embedded signer if active // Update active signer profile
if app.signer_mode == SignerMode::Embedded { if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer { if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(summary.npub.clone())).await; signer.set_active_profile(Some(summary.npub.clone())).await;
} }
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
} }
app.save_vault()?; app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() })) Ok(json!({ "profile": summary, "state": app.state_view() }))
@ -565,6 +573,10 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
if let Some(signer) = &app.embedded_signer { if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(summary.npub.clone())).await; signer.set_active_profile(Some(summary.npub.clone())).await;
} }
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
} }
app.save_vault()?; app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() })) Ok(json!({ "profile": summary, "state": app.state_view() }))
@ -576,6 +588,10 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
if let Some(signer) = &app.embedded_signer { if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(npub)).await; signer.set_active_profile(Some(npub)).await;
} }
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(npub)).await;
}
} }
app.save_vault()?; app.save_vault()?;
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
@ -682,6 +698,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
signer.set_active_profile(Some(npub.clone())).await; signer.set_active_profile(Some(npub.clone())).await;
} }
} }
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
if let Some(npub) = &app.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
}
} }
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
} }
@ -692,6 +714,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
if let Some(signer) = &app.embedded_signer { if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(None).await; signer.set_active_profile(None).await;
} }
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(None).await;
}
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
} }

View file

@ -682,6 +682,7 @@ fn cli_undo_delete() -> Result<String, AppError> {
created_at: restored.created_at, created_at: restored.created_at,
picture: restored.picture, picture: restored.picture,
nip05: restored.nip05, nip05: restored.nip05,
signer_mode: keynectr::vault::SignerMode::Embedded,
}; };
app.vault.profiles.push(stored); app.vault.profiles.push(stored);
if app.vault.active_profile.is_none() { if app.vault.active_profile.is_none() {

View file

@ -75,6 +75,7 @@ pub fn create_profile(
created_at, created_at,
picture: None, picture: None,
nip05: None, nip05: None,
signer_mode: crate::vault::SignerMode::Embedded,
}; };
let is_active = vault.active_profile.is_none(); let is_active = vault.active_profile.is_none();
@ -157,6 +158,7 @@ pub fn import_profile(
created_at, created_at,
picture: metadata.as_ref().and_then(|m| m.picture.clone()), picture: metadata.as_ref().and_then(|m| m.picture.clone()),
nip05: metadata.as_ref().and_then(|m| m.nip05.clone()), nip05: metadata.as_ref().and_then(|m| m.nip05.clone()),
signer_mode: crate::vault::SignerMode::Embedded,
}); });
let relay_urls = relays::enabled_urls(settings); let relay_urls = relays::enabled_urls(settings);
@ -449,6 +451,18 @@ fn validate_picture_url(url: &str) -> Result<(), AppError> {
Ok(()) Ok(())
} }
/// Look up a stored profile by npub (public access for signer-mode checks).
pub fn find_stored_profile<'a>(
vault: &'a Vault,
npub: &str,
) -> Result<&'a StoredProfile, AppError> {
vault
.profiles
.iter()
.find(|p| p.public_key == npub)
.ok_or_else(|| AppError::profile_not_found(npub))
}
fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> { fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
vault vault
.profiles .profiles
@ -767,6 +781,7 @@ mod tests {
created_at: 1, created_at: 1,
picture: None, picture: None,
nip05: None, nip05: None,
signer_mode: crate::vault::SignerMode::Embedded,
}); });
vault.profiles.push(StoredProfile { vault.profiles.push(StoredProfile {
label: "Bob".to_string(), label: "Bob".to_string(),
@ -775,6 +790,7 @@ mod tests {
created_at: 2, created_at: 2,
picture: None, picture: None,
nip05: None, nip05: None,
signer_mode: crate::vault::SignerMode::Embedded,
}); });
vault vault
} }

View file

@ -2,10 +2,12 @@
pub mod embedded; pub mod embedded;
pub mod nip46_client; pub mod nip46_client;
pub mod permissions;
pub mod types; pub mod types;
use async_trait::async_trait; use async_trait::async_trait;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use std::sync::Arc;
use crate::errors::AppError; use crate::errors::AppError;
use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType}; use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType};
@ -16,6 +18,23 @@ pub trait Signer: Send + Sync {
/// Get the public key of the active signing identity. /// Get the public key of the active signing identity.
async fn get_public_key(&self) -> Result<PublicKey, AppError>; async fn get_public_key(&self) -> Result<PublicKey, AppError>;
/// Resolve the public key this signer will sign user content with,
/// enforcing that it matches the active profile's canonical identity.
///
/// The default implementation compares `get_public_key()` against
/// `profile_pubkey` using canonical hex, returning
/// [`AppError::external_signer_identity_mismatch`] on any difference.
/// External signers may override this to consult the remote signer's
/// identity. Callers must use the returned key as the event's `pubkey`
/// and must never sign user content when this errors.
async fn pubkey_for(&self, profile_pubkey: &PublicKey) -> Result<PublicKey, AppError> {
let signer_pubkey = self.get_public_key().await?;
if signer_pubkey.to_hex() != profile_pubkey.to_hex() {
return Err(AppError::external_signer_identity_mismatch());
}
Ok(signer_pubkey)
}
/// Sign an event with the active key. /// Sign an event with the active key.
async fn sign_event(&self, event: UnsignedEvent) -> Result<Event, AppError>; async fn sign_event(&self, event: UnsignedEvent) -> Result<Event, AppError>;
@ -40,4 +59,132 @@ pub trait Signer: Send + Sync {
/// Get detailed status for UI (connection state, pending requests, etc.). /// Get detailed status for UI (connection state, pending requests, etc.).
async fn detailed_status(&self) -> serde_json::Value; async fn detailed_status(&self) -> serde_json::Value;
// ── Permission checks ───────────────────────────────────────────────
/// The permissions granted to this signer, if any.
///
/// Local (embedded) signers always return `None` — they have full
/// access to the local key and do not need permission checks. NIP-46
/// client signers return the permissions parsed from the connection
/// URI or stored configuration.
///
/// Returns an owned value because the NIP-46 client must lock an async
/// mutex internally.
fn permissions(&self) -> Option<permissions::Nip46Permissions> {
None
}
/// Whether `sign_event` is permitted for the given event kind.
///
/// The default implementation returns `true` when there are no
/// permissions (local signers) and `false` when permissions exist but
/// do not allow the operation.
fn can_sign_event(&self, kind: u16) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
None => true,
}
}
/// Whether `nip44_encrypt` is permitted.
fn can_encrypt(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_encrypt_allowed(),
None => true,
}
}
/// Whether `nip44_decrypt` is permitted.
fn can_decrypt(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_decrypt_allowed(),
None => true,
}
}
/// Whether `get_public_key` is permitted.
fn can_get_public_key(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_get_public_key_allowed(),
None => true,
}
}
/// Whether `get_relays` is permitted.
fn can_get_relays(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_get_relays_allowed(),
None => true,
}
}
/// Whether the connection is currently valid (not expired, not revoked).
///
/// Local signers always return `true`.
fn is_connection_valid(&self) -> bool {
true
}
}
/// A source that can produce the active profile's public key and sign an
/// unsigned event.
///
/// Every user-content signing path (publishing, upload auth, metadata) builds
/// an `EventBuilder` exactly as before, then routes it through a `Signing`
/// instead of a raw `Keys`. This is what makes "external signer not connected"
/// a hard error rather than a silent fall back to the local vault key: the
/// caller never holds the local secret when external mode is selected.
///
/// - [`Signing::Local`] signs with a key resolved from the vault (embedded
/// mode and the CLI, which are always local).
/// - [`Signing::External`] signs through a live [`Signer`], validating that the
/// signer's identity matches the active profile before any event is signed.
pub enum Signing {
Local(Keys),
External {
signer: Arc<dyn Signer>,
profile_pubkey: PublicKey,
},
}
impl Signing {
/// The public key user content will be signed with.
///
/// For [`Signing::External`] this enforces identity validation and returns
/// the signer's key; it returns [`AppError::external_signer_identity_mismatch`]
/// when the signer does not control the active profile. Callers MUST use the
/// returned key as the event's `pubkey`.
pub async fn pubkey(&self) -> Result<PublicKey, AppError> {
match self {
Signing::Local(keys) => Ok(keys.public_key()),
Signing::External {
signer,
profile_pubkey,
} => signer.pubkey_for(profile_pubkey).await,
}
}
/// Sign `unsigned` (which must have been built with the key from
/// [`Signing::pubkey`]).
///
/// For [`Signing::External`] the returned event is re-checked against the
/// validated identity and verified as a well-formed signature before it is
/// returned, so a misbehaving signer cannot substitute a different key.
pub async fn sign(&self, unsigned: UnsignedEvent) -> Result<Event, AppError> {
match self {
Signing::Local(keys) => keys.sign_event(unsigned).map_err(AppError::sign_failed),
Signing::External {
signer,
profile_pubkey,
} => {
let event = signer.sign_event(unsigned).await?;
if event.pubkey != *profile_pubkey {
return Err(AppError::external_signer_identity_mismatch());
}
event.verify().map_err(AppError::sign_failed)?;
Ok(event)
}
}
}
} }

View file

@ -18,6 +18,7 @@ use tokio::sync::{oneshot, Mutex};
use crate::app::App; use crate::app::App;
use crate::errors::AppError; use crate::errors::AppError;
use crate::profiles; use crate::profiles;
use crate::signer::permissions::Nip46Permissions;
use crate::signer::types::{ use crate::signer::types::{
ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType, ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType,
}; };
@ -42,6 +43,7 @@ struct ConnectUri {
peer: PublicKey, peer: PublicKey,
relays: Vec<RelayUrl>, relays: Vec<RelayUrl>,
secret: Option<String>, secret: Option<String>,
permissions: Option<Nip46Permissions>,
} }
/// The NIP-46 client signer. /// The NIP-46 client signer.
@ -59,6 +61,7 @@ struct Nip46Inner {
pending: HashMap<String, PendingApprovalInner>, pending: HashMap<String, PendingApprovalInner>,
keys: Option<Keys>, keys: Option<Keys>,
connect_secret: Option<String>, connect_secret: Option<String>,
active_npub: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -83,11 +86,34 @@ impl Nip46ClientSigner {
pending: HashMap::new(), pending: HashMap::new(),
keys: None, keys: None,
connect_secret: None, connect_secret: None,
active_npub: None,
})), })),
app, app,
} }
} }
/// Keep active profile in sync (mirrors EmbeddedSigner).
pub async fn set_active_profile(&self, npub: Option<String>) {
self.inner.lock().await.active_npub = npub;
}
/// Emit an audit event for a permission-denied NIP-46 operation.
async fn audit_permission_denied(&self, method: &str) {
let npub = self.inner.lock().await.active_npub.clone();
if let Some(npub) = npub {
let mut app = self.app.lock().await;
if let Some(ref mut log) = app.audit_log {
let _ = log.record(
&npub,
crate::audit::AuditAction::ConnectionPermissionDenied,
method,
false,
Some(format!("NIP-46 permission denied for method: {method}")),
);
}
}
}
/// Parse a nostrconnect:// URI. /// Parse a nostrconnect:// URI.
fn parse_connect_uri(raw: &str) -> Result<ConnectUri, AppError> { fn parse_connect_uri(raw: &str) -> Result<ConnectUri, AppError> {
let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| { let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| {
@ -105,6 +131,7 @@ impl Nip46ClientSigner {
let mut relays: Vec<RelayUrl> = Vec::new(); let mut relays: Vec<RelayUrl> = Vec::new();
let mut secret: Option<String> = None; let mut secret: Option<String> = None;
let mut perms_raw: Option<String> = None;
if let Some(query) = query { if let Some(query) = query {
for pair in query.split('&') { for pair in query.split('&') {
@ -121,6 +148,7 @@ impl Nip46ClientSigner {
} }
} }
"secret" => secret = decoded, "secret" => secret = decoded,
"perms" => perms_raw = decoded,
_ => {} _ => {}
} }
} }
@ -132,10 +160,16 @@ impl Nip46ClientSigner {
)); ));
} }
let permissions = match perms_raw {
Some(raw) => Some(Nip46Permissions::parse(&raw)?),
None => None,
};
Ok(ConnectUri { Ok(ConnectUri {
peer, peer,
relays, relays,
secret, secret,
permissions,
}) })
} }
@ -143,35 +177,83 @@ impl Nip46ClientSigner {
pub async fn connect(&self, uri: &str, label: String) -> Result<Nip46Status, AppError> { pub async fn connect(&self, uri: &str, label: String) -> Result<Nip46Status, AppError> {
let parsed = Self::parse_connect_uri(uri)?; let parsed = Self::parse_connect_uri(uri)?;
// Resolve our active profile's keys for NIP-44 encryption // For NIP-46 Client the identity lives on the external signer, so local
let keys = { // profile is optional. Use ephemeral keys for the session, preferring
// the local vault profile if available and unlocked.
let (keys, active_npub) = {
let app = self.app.lock().await; let app = self.app.lock().await;
let npub = if let Some(npub) = app.vault.active_profile.clone() {
app.vault.active_profile.as_ref().ok_or_else(|| { if !app.is_locked() {
AppError::config("No active profile. Select a profile first.")
})?;
if app.is_locked() {
return Err(AppError::vault_locked());
}
let vault_key = app.vault_key().copied(); let vault_key = app.vault_key().copied();
let secret_hex = profiles::resolve_secret_key(&app.vault, npub, vault_key.as_ref())?; if let Ok(secret_hex) =
let secret_key = profiles::parse_secret_key(&secret_hex)?; profiles::resolve_secret_key(&app.vault, &npub, vault_key.as_ref())
Keys::new(secret_key) {
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)
}
}; };
// Check for permission broadening against stored connections for
// the active profile.
if let Some(ref npub) = active_npub {
if let Some(new_perms) = &parsed.permissions {
let app = self.app.lock().await;
for stored_conn in &app.vault.nip46_connections {
// Only check connections belonging to the same profile
// AND the same remote signer. Legacy connections without
// profile_npub are skipped (they cannot pass auth).
if stored_conn.profile_npub.as_deref() == Some(npub.as_str())
&& stored_conn.signer_pubkey == parsed.peer.to_hex()
{
if let Some(existing_perms) = &stored_conn.permissions {
existing_perms.validate_no_broadening(new_perms)?;
}
}
}
}
}
// Derive conversation key with the signer // Derive conversation key with the signer
let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer) let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer)
.map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?; .map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?;
// Build connection config // Build connection config
let connection = Nip46Connection { let connection = Nip46Connection {
profile_npub: active_npub.clone(),
signer_pubkey: parsed.peer.to_hex(), signer_pubkey: parsed.peer.to_hex(),
relays: parsed.relays.iter().map(|r| r.to_string()).collect(), relays: parsed.relays.iter().map(|r| r.to_string()).collect(),
secret: parsed.secret.clone(), secret: parsed.secret.clone(),
label, label,
created_at: crate::vault::unix_timestamp()?, created_at: crate::vault::unix_timestamp()?,
permissions: parsed.permissions.clone(),
expires_at: None,
revoked_at: None,
}; };
// Persist the connection in the vault.
{
let mut app = self.app.lock().await;
// Remove any existing connection for the same signer from the
// same profile (reconnect replaces the old connection).
app.vault.nip46_connections.retain(|c| {
!(c.signer_pubkey == connection.signer_pubkey
&& c.profile_npub == connection.profile_npub)
});
app.vault.nip46_connections.push(connection.clone());
let _ = app.save_vault();
}
// Update state to connecting // Update state to connecting
{ {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
@ -210,6 +292,21 @@ impl Nip46ClientSigner {
if let Some(client) = inner.client.take() { if let Some(client) = inner.client.take() {
let _ = client.disconnect().await; let _ = client.disconnect().await;
} }
// Mark the connection as revoked in the vault, scoped to profile.
if let Some(ref conn) = inner.connection {
let signer_pubkey = conn.signer_pubkey.clone();
let profile_npub = conn.profile_npub.clone();
let mut app = self.app.lock().await;
if let Some(stored) = app
.vault
.nip46_connections
.iter_mut()
.find(|c| c.signer_pubkey == signer_pubkey && c.profile_npub == profile_npub)
{
stored.revoked_at = crate::vault::unix_timestamp().ok();
}
let _ = app.save_vault();
}
inner.phase = Nip46Phase::Stopped; inner.phase = Nip46Phase::Stopped;
inner.connection = None; inner.connection = None;
inner.conversation_key = None; inner.conversation_key = None;
@ -434,7 +531,7 @@ impl Nip46ClientSigner {
let response = if self.requires_approval(&request.method) { let response = if self.requires_approval(&request.method) {
self.gated_response(&keys, &request).await self.gated_response(&keys, &request).await
} else { } else {
self.handle_request(&keys, &uri, &request) self.handle_request(&keys, &uri, &request).await
}; };
if let Some(response) = response { if let Some(response) = response {
@ -449,6 +546,38 @@ impl Nip46ClientSigner {
} }
async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> { async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
// Check connection validity
if !self.is_connection_valid() {
return Some(response_err(
&request.id,
"Connection is expired or revoked".to_string(),
));
}
// Check method permissions
let allowed = match request.method.as_str() {
"sign_event" => {
let kind = request
.params
.first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
.unwrap_or(0) as u16;
self.can_sign_event(kind)
}
"nip44_encrypt" => self.can_encrypt(),
"nip44_decrypt" => self.can_decrypt(),
_ => false,
};
if !allowed {
self.audit_permission_denied(&request.method).await;
return Some(response_err(
&request.id,
format!("Permission denied: {} not authorized", request.method),
));
}
self.inner.lock().await.phase = Nip46Phase::Connected; self.inner.lock().await.phase = Nip46Phase::Connected;
let details = self.describe_request(request); let details = self.describe_request(request);
match self.await_approval(details).await { match self.await_approval(details).await {
@ -458,7 +587,7 @@ impl Nip46ClientSigner {
} }
} }
fn handle_request( async fn handle_request(
&self, &self,
keys: &Keys, keys: &Keys,
uri: &ConnectUri, uri: &ConnectUri,
@ -467,6 +596,14 @@ impl Nip46ClientSigner {
// Note: we can't await here, so phase update is best-effort // Note: we can't await here, so phase update is best-effort
// The phase is updated in gated_response for key-using methods // The phase is updated in gated_response for key-using methods
// Check connection validity before processing
if !self.is_connection_valid() {
return Some(response_err(
&request.id,
"Connection is expired or revoked".to_string(),
));
}
match request.method.as_str() { match request.method.as_str() {
"connect" => { "connect" => {
if let Some(expected) = &uri.secret { if let Some(expected) = &uri.secret {
@ -479,8 +616,26 @@ impl Nip46ClientSigner {
} }
Some(response_ok(&request.id, "ack".to_string())) Some(response_ok(&request.id, "ack".to_string()))
} }
"get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())), "get_public_key" => {
"get_relays" => Some(response_ok(&request.id, json!(uri.relays).to_string())), if !self.can_get_public_key() {
self.audit_permission_denied("get_public_key").await;
return Some(response_err(
&request.id,
"Permission denied: get_public_key not authorized".to_string(),
));
}
Some(response_ok(&request.id, keys.public_key().to_hex()))
}
"get_relays" => {
if !self.can_get_relays() {
self.audit_permission_denied("get_relays").await;
return Some(response_err(
&request.id,
"Permission denied: get_relays not authorized".to_string(),
));
}
Some(response_ok(&request.id, json!(uri.relays).to_string()))
}
"ping" => Some(response_ok(&request.id, "pong".to_string())), "ping" => Some(response_ok(&request.id, "pong".to_string())),
"logout" => Some(response_ok(&request.id, "ack".to_string())), "logout" => Some(response_ok(&request.id, "ack".to_string())),
other => Some(response_err(&request.id, format!("Unsupported: {other}"))), other => Some(response_err(&request.id, format!("Unsupported: {other}"))),
@ -724,6 +879,70 @@ impl Signer for Nip46ClientSigner {
let status = self.status().await; let status = self.status().await;
serde_json::to_value(status).unwrap_or(serde_json::json!({})) serde_json::to_value(status).unwrap_or(serde_json::json!({}))
} }
// ── Permission checks ───────────────────────────────────────────────
fn permissions(&self) -> Option<Nip46Permissions> {
// We need to block on the async lock here; this is safe because
// `permissions()` is only called from synchronous contexts that do
// not hold the inner lock.
let inner = self.inner.blocking_lock();
inner
.connection
.as_ref()
.and_then(|c| c.permissions.clone())
}
fn can_sign_event(&self, kind: u16) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
None => false,
}
}
fn can_encrypt(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_encrypt_allowed(),
None => false,
}
}
fn can_decrypt(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_decrypt_allowed(),
None => false,
}
}
fn can_get_public_key(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_get_public_key_allowed(),
None => false,
}
}
fn can_get_relays(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_get_relays_allowed(),
None => false,
}
}
fn is_connection_valid(&self) -> bool {
let inner = self.inner.blocking_lock();
match &inner.connection {
None => false,
Some(conn) => {
let now = crate::vault::unix_timestamp().unwrap_or(0);
if let Some(expires_at) = conn.expires_at {
if now >= expires_at {
return false;
}
}
conn.revoked_at.is_none()
}
}
}
} }
/// Minimal decrypted NIP-46 request. /// Minimal decrypted NIP-46 request.

663
src/signer/permissions.rs Normal file
View file

@ -0,0 +1,663 @@
//! NIP-46 per-connection permission model.
//!
//! Permissions are parsed from the `perms` query parameter in a
//! `nostrconnect://` URI or from stored connection metadata. The format
//! follows the NIP-46 convention:
//!
//! `method` or `method:#kind1,#kind2`
//!
//! Multiple permissions are comma-separated. Unknown methods, malformed
//! strings, and empty permission sets are rejected.
use serde::{Deserialize, Serialize};
use crate::errors::AppError;
/// Known NIP-46 method names.
const KNOWN_METHODS: &[&str] = &[
"sign_event",
"nip44_encrypt",
"nip44_decrypt",
"get_public_key",
"get_relays",
];
/// A single NIP-46 permission granting access to one method.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Nip46Permission {
/// The NIP-46 method this permission covers.
pub method: String,
/// Optional event-kind restrictions for `sign_event`.
///
/// * Empty — all event kinds are permitted.
/// * Non-empty — only the listed kinds are permitted.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_kinds: Vec<u16>,
}
/// Parsed, validated permissions for a NIP-46 connection.
///
/// An empty `granted` list means **no** operations are allowed (deny-by-
/// default). Permissions can only be narrowed after creation, never broadened.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Nip46Permissions {
/// All granted permissions.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub granted: Vec<Nip46Permission>,
}
impl Nip46Permissions {
/// Parse a raw permission string.
///
/// Format: `"sign_event:#1,#3; nip44_encrypt; get_public_key"`
///
/// Permissions are semicolon-separated. Within a single permission,
/// event kinds follow a `:` and are themselves comma-separated with
/// optional `#` prefixes. An empty or blank string is treated as *no
/// permissions* and returns an empty list.
pub fn parse(raw: &str) -> Result<Self, AppError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(Self::default());
}
let mut granted = Vec::new();
let mut seen_methods = std::collections::HashSet::new();
for part in trimmed.split(';') {
let part = part.trim();
if part.is_empty() {
continue;
}
let perm = Self::parse_one(part)?;
if !seen_methods.insert(perm.method.clone()) {
return Err(AppError::config(format!(
"Duplicate NIP-46 permission method: {}",
perm.method,
)));
}
granted.push(perm);
}
Ok(Self { granted })
}
/// Parse a single permission token like `"sign_event:#1,#3"`.
///
/// The method name comes before the first `:` (if any). Everything
/// after that colon is treated as a comma-separated list of event
/// kinds (with optional `#` prefixes).
fn parse_one(token: &str) -> Result<Nip46Permission, AppError> {
let (method_part, kinds_part) = match token.split_once(':') {
Some((m, k)) => (m.trim(), Some(k.trim())),
None => (token.trim(), None),
};
if !KNOWN_METHODS.contains(&method_part) {
return Err(AppError::config(format!(
"Unknown NIP-46 permission method: {method_part}"
)));
}
let allowed_kinds = match kinds_part {
Some(kinds_str) if !kinds_str.is_empty() => {
let mut kinds = Vec::new();
for k in kinds_str.split(',') {
let k = k.trim().trim_start_matches('#');
if k.is_empty() {
continue;
}
let kind: u16 = k.parse().map_err(|_| {
AppError::config(format!("Invalid event kind in NIP-46 permission: {k}"))
})?;
kinds.push(kind);
}
kinds
}
_ => Vec::new(),
};
Ok(Nip46Permission {
method: method_part.to_string(),
allowed_kinds,
})
}
/// Whether the given method is permitted at all.
pub fn is_method_allowed(&self, method: &str) -> bool {
self.granted.iter().any(|p| p.method == method)
}
/// Whether the given event kind is allowed for `sign_event`.
///
/// Returns `false` if `sign_event` is not permitted. If permitted with
/// no kind restrictions (empty `allowed_kinds`) returns `true`. If
/// permitted with specific kinds, returns `true` only when `kind` is in
/// the list.
pub fn is_sign_event_kind_allowed(&self, kind: u16) -> bool {
match self.granted.iter().find(|p| p.method == "sign_event") {
Some(p) if p.allowed_kinds.is_empty() => true,
Some(p) => p.allowed_kinds.contains(&kind),
None => false,
}
}
/// Whether `nip44_encrypt` is permitted.
pub fn is_encrypt_allowed(&self) -> bool {
self.is_method_allowed("nip44_encrypt")
}
/// Whether `nip44_decrypt` is permitted.
pub fn is_decrypt_allowed(&self) -> bool {
self.is_method_allowed("nip44_decrypt")
}
/// Whether `get_public_key` is permitted.
pub fn is_get_public_key_allowed(&self) -> bool {
self.is_method_allowed("get_public_key")
}
/// Whether `get_relays` is permitted.
pub fn is_get_relays_allowed(&self) -> bool {
self.is_method_allowed("get_relays")
}
/// Check whether `other` can be added to these permissions without
/// broadening them. Returns `Ok(())` if the addition is safe, or an
/// error describing which permission would be expanded.
pub fn validate_no_broadening(&self, other: &Nip46Permissions) -> Result<(), AppError> {
for new_perm in &other.granted {
match self.granted.iter().find(|p| p.method == new_perm.method) {
Some(existing) => {
// If the existing permission has kind restrictions and
// the new one does not, that broadens access.
if !existing.allowed_kinds.is_empty() && new_perm.allowed_kinds.is_empty() {
return Err(AppError::config(format!(
"Cannot broaden permission for {}: \
existing restriction to kinds {:?} would be removed",
new_perm.method, existing.allowed_kinds,
)));
}
// If both have kind restrictions, check that the new
// set is a subset of the existing one.
if !existing.allowed_kinds.is_empty() && !new_perm.allowed_kinds.is_empty() {
for &k in &new_perm.allowed_kinds {
if !existing.allowed_kinds.contains(&k) {
return Err(AppError::config(format!(
"Cannot broaden permission for {}: \
kind {k} is not in the existing allowed kinds",
new_perm.method,
)));
}
}
}
}
None => {
// Method was not previously granted — adding it broadens.
return Err(AppError::config(format!(
"Cannot grant new permission for {}: \
method was not previously authorized",
new_perm.method,
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::ErrorKind;
#[test]
fn parse_empty_string() {
let perms = Nip46Permissions::parse("").unwrap();
assert!(perms.granted.is_empty());
}
#[test]
fn parse_blank_string() {
let perms = Nip46Permissions::parse(" ").unwrap();
assert!(perms.granted.is_empty());
}
#[test]
fn parse_single_method() {
let perms = Nip46Permissions::parse("sign_event").unwrap();
assert_eq!(perms.granted.len(), 1);
assert_eq!(perms.granted[0].method, "sign_event");
assert!(perms.granted[0].allowed_kinds.is_empty());
}
#[test]
fn parse_method_with_kinds() {
let perms = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap();
assert_eq!(perms.granted.len(), 1);
assert_eq!(perms.granted[0].method, "sign_event");
assert_eq!(perms.granted[0].allowed_kinds, vec![1, 3, 5]);
}
#[test]
fn parse_multiple_methods() {
let perms =
Nip46Permissions::parse("sign_event:#1; nip44_encrypt; get_public_key").unwrap();
assert_eq!(perms.granted.len(), 3);
assert!(perms.is_method_allowed("sign_event"));
assert!(perms.is_method_allowed("nip44_encrypt"));
assert!(perms.is_method_allowed("get_public_key"));
}
#[test]
fn parse_with_whitespace() {
let perms = Nip46Permissions::parse(" sign_event : #1 , #3 ; nip44_encrypt ").unwrap();
assert_eq!(perms.granted.len(), 2);
assert_eq!(perms.granted[0].allowed_kinds, vec![1, 3]);
}
#[test]
fn parse_unknown_method_rejected() {
let err = Nip46Permissions::parse("unknown_method").unwrap_err();
assert!(err.message().contains("Unknown NIP-46 permission method"));
}
#[test]
fn parse_invalid_kind_rejected() {
let err = Nip46Permissions::parse("sign_event:#abc").unwrap_err();
assert!(err.message().contains("Invalid event kind"));
}
#[test]
fn parse_trailing_semicolon_ignored() {
let perms = Nip46Permissions::parse("sign_event;").unwrap();
assert_eq!(perms.granted.len(), 1);
}
#[test]
fn is_method_allowed() {
let perms = Nip46Permissions::parse("sign_event; nip44_encrypt").unwrap();
assert!(perms.is_method_allowed("sign_event"));
assert!(perms.is_method_allowed("nip44_encrypt"));
assert!(!perms.is_method_allowed("nip44_decrypt"));
assert!(!perms.is_method_allowed("get_public_key"));
}
#[test]
fn sign_event_kind_allowed_no_restrictions() {
let perms = Nip46Permissions::parse("sign_event").unwrap();
assert!(perms.is_sign_event_kind_allowed(1));
assert!(perms.is_sign_event_kind_allowed(9999));
}
#[test]
fn sign_event_kind_allowed_with_restrictions() {
let perms = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
assert!(perms.is_sign_event_kind_allowed(1));
assert!(perms.is_sign_event_kind_allowed(3));
assert!(!perms.is_sign_event_kind_allowed(5));
}
#[test]
fn sign_event_not_permitted() {
let perms = Nip46Permissions::parse("nip44_encrypt").unwrap();
assert!(!perms.is_sign_event_kind_allowed(1));
}
#[test]
fn encrypt_decrypt_checks() {
let perms = Nip46Permissions::parse("nip44_encrypt").unwrap();
assert!(perms.is_encrypt_allowed());
assert!(!perms.is_decrypt_allowed());
}
#[test]
fn get_public_key_check() {
let perms = Nip46Permissions::parse("get_public_key").unwrap();
assert!(perms.is_get_public_key_allowed());
assert!(!perms.is_get_relays_allowed());
}
#[test]
fn get_relays_check() {
let perms = Nip46Permissions::parse("get_relays").unwrap();
assert!(perms.is_get_relays_allowed());
assert!(!perms.is_get_public_key_allowed());
}
#[test]
fn deny_by_default_empty_permissions() {
let perms = Nip46Permissions::default();
assert!(!perms.is_method_allowed("sign_event"));
assert!(!perms.is_encrypt_allowed());
assert!(!perms.is_decrypt_allowed());
assert!(!perms.is_get_public_key_allowed());
assert!(!perms.is_get_relays_allowed());
assert!(!perms.is_sign_event_kind_allowed(1));
}
#[test]
fn validate_no_broadening_adds_method() {
let existing = Nip46Permissions::parse("sign_event").unwrap();
let proposed = Nip46Permissions::parse("nip44_encrypt").unwrap();
assert!(existing.validate_no_broadening(&proposed).is_err());
}
#[test]
fn validate_no_broadening_removes_kind_restriction() {
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
let proposed = Nip46Permissions::parse("sign_event").unwrap();
let err = existing.validate_no_broadening(&proposed).unwrap_err();
assert!(err.message().contains("broaden"));
}
#[test]
fn validate_no_broadening_adds_kind() {
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1,#5").unwrap();
let err = existing.validate_no_broadening(&proposed).unwrap_err();
assert!(err.message().contains("kind 5"));
}
#[test]
fn validate_no_broadening_narrows_is_ok() {
let existing = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
assert!(existing.validate_no_broadening(&proposed).is_ok());
}
#[test]
fn validate_no_broadening_same_is_ok() {
let existing = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
assert!(existing.validate_no_broadening(&proposed).is_ok());
}
#[test]
fn round_trip_serialization() {
let perms = Nip46Permissions::parse("sign_event:#1,#3; nip44_encrypt").unwrap();
let json = serde_json::to_string(&perms).unwrap();
let restored: Nip46Permissions = serde_json::from_str(&json).unwrap();
assert_eq!(perms, restored);
}
#[test]
fn round_trip_empty() {
let perms = Nip46Permissions::default();
let json = serde_json::to_string(&perms).unwrap();
let restored: Nip46Permissions = serde_json::from_str(&json).unwrap();
assert_eq!(perms, restored);
}
#[test]
fn connection_with_permissions_serializes() {
use crate::signer::types::Nip46Connection;
let conn = Nip46Connection {
profile_npub: Some("npub1test".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec!["wss://relay.example.com".to_string()],
secret: None,
label: "Test".to_string(),
created_at: 1700000000,
permissions: Some(Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap()),
expires_at: Some(1700003600),
revoked_at: None,
};
let json = serde_json::to_string(&conn).unwrap();
let restored: Nip46Connection = serde_json::from_str(&json).unwrap();
assert!(restored.permissions.is_some());
let perms = restored.permissions.unwrap();
assert!(perms.is_method_allowed("sign_event"));
assert!(perms.is_sign_event_kind_allowed(1));
assert!(!perms.is_sign_event_kind_allowed(99));
assert!(perms.is_encrypt_allowed());
assert_eq!(restored.expires_at, Some(1700003600));
assert!(restored.revoked_at.is_none());
}
#[test]
fn connection_without_permissions_serializes() {
use crate::signer::types::Nip46Connection;
let conn = Nip46Connection {
profile_npub: Some("npub1test".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec![],
secret: None,
label: "Test".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
};
let json = serde_json::to_string(&conn).unwrap();
let restored: Nip46Connection = serde_json::from_str(&json).unwrap();
assert!(restored.permissions.is_none());
}
#[test]
fn empty_permissions_deny_all_operations() {
let perms = Nip46Permissions::default();
assert!(!perms.is_sign_event_kind_allowed(1));
assert!(!perms.is_encrypt_allowed());
assert!(!perms.is_decrypt_allowed());
assert!(!perms.is_get_public_key_allowed());
assert!(!perms.is_get_relays_allowed());
}
#[test]
fn permission_broadening_rejected_when_adding_method() {
let existing = Nip46Permissions::parse("sign_event").unwrap();
let proposed = Nip46Permissions::parse("sign_event; nip44_encrypt").unwrap();
let err = existing.validate_no_broadening(&proposed).unwrap_err();
assert!(err.message().contains("nip44_encrypt"));
}
#[test]
fn permission_broadening_rejected_when_removing_kind_restriction() {
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
let proposed = Nip46Permissions::parse("sign_event").unwrap();
let err = existing.validate_no_broadening(&proposed).unwrap_err();
assert!(err.message().contains("broaden"));
}
#[test]
fn permission_broadening_rejected_when_adding_kind() {
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1,#5").unwrap();
let err = existing.validate_no_broadening(&proposed).unwrap_err();
assert!(err.message().contains("kind 5"));
}
#[test]
fn permission_narrowing_is_allowed() {
let existing = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
assert!(existing.validate_no_broadening(&proposed).is_ok());
}
#[test]
fn permission_same_is_allowed() {
let existing = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
assert!(existing.validate_no_broadening(&proposed).is_ok());
}
#[test]
fn connection_expiry_check() {
use crate::signer::types::Nip46Connection;
let conn = Nip46Connection {
profile_npub: Some("npub1test".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec![],
secret: None,
label: "Test".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: Some(1700000001), // Expired immediately
revoked_at: None,
};
let now = crate::vault::unix_timestamp().unwrap_or(0);
if now >= conn.expires_at.unwrap() {
assert!(conn.expires_at.unwrap() <= now, "connection is expired");
}
}
#[test]
fn connection_revocation_check() {
use crate::signer::types::Nip46Connection;
let conn = Nip46Connection {
profile_npub: Some("npub1test".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec![],
secret: None,
label: "Test".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: Some(1700000001),
};
assert!(conn.revoked_at.is_some(), "connection is revoked");
}
#[test]
fn parser_rejects_empty_method_name() {
let err = Nip46Permissions::parse(":1;").expect_err("empty method should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_unknown_method() {
let err =
Nip46Permissions::parse("sign_event:#1; unknown_method").expect_err("unknown method");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_invalid_kind_format() {
let err =
Nip46Permissions::parse("sign_event:abc").expect_err("non-numeric kind should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_negative_kind() {
let err = Nip46Permissions::parse("sign_event:#-1").expect_err("negative kind should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_overflowing_kind() {
let err = Nip46Permissions::parse("sign_event:#99999999999999")
.expect_err("overflowing kind should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_duplicate_method() {
let err = Nip46Permissions::parse("sign_event:#1; sign_event:#3")
.expect_err("duplicate method should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
assert!(err.message().contains("Duplicate NIP-46 permission method"));
}
#[test]
fn parser_rejects_duplicate_method_no_spaces() {
let err = Nip46Permissions::parse("sign_event:#1;sign_event:#3")
.expect_err("duplicate method should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_duplicate_method_same_kinds() {
let err = Nip46Permissions::parse("sign_event:#1; sign_event:#1")
.expect_err("duplicate method should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_duplicate_method_encrypt() {
let err = Nip46Permissions::parse("nip44_encrypt;nip44_encrypt")
.expect_err("duplicate method should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_duplicate_method_get_public_key() {
let err = Nip46Permissions::parse("get_public_key; get_public_key")
.expect_err("duplicate method should fail");
assert!(matches!(err.kind(), ErrorKind::Config));
}
#[test]
fn parser_rejects_duplicate_method_first_wins() {
// Error should mention the duplicated method name
let err = Nip46Permissions::parse("nip44_decrypt; nip44_decrypt; get_public_key")
.expect_err("duplicate method should fail");
assert!(err.message().contains("nip44_decrypt"));
}
#[test]
fn parser_allows_trailing_semicolons() {
// Trailing semicolons produce empty parts which are skipped.
let perms = Nip46Permissions::parse("sign_event:#1;").unwrap();
assert!(perms.is_method_allowed("sign_event"));
assert!(perms.is_sign_event_kind_allowed(1));
}
#[test]
fn parser_allows_leading_semicolons() {
// Leading semicolons produce empty parts which are skipped.
let perms = Nip46Permissions::parse(";sign_event:#1").unwrap();
assert!(perms.is_method_allowed("sign_event"));
assert!(perms.is_sign_event_kind_allowed(1));
}
#[test]
fn serialization_roundtrip_canonical() {
let perms =
Nip46Permissions::parse("get_public_key;nip44_decrypt;sign_event:#1,#4").unwrap();
let json = serde_json::to_string(&perms).unwrap();
let restored: Nip46Permissions = serde_json::from_str(&json).unwrap();
assert_eq!(perms, restored);
}
#[test]
fn broadening_rejected_when_adding_kind() {
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap();
existing
.validate_no_broadening(&proposed)
.expect_err("adding kind should fail");
}
#[test]
fn broadening_rejected_when_adding_encryption_to_signonly() {
let existing = Nip46Permissions::parse("sign_event:#1").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap();
existing
.validate_no_broadening(&proposed)
.expect_err("adding encrypt should fail");
}
#[test]
fn broadening_accepted_when_restricting() {
let existing = Nip46Permissions::parse("sign_event:#1,#3; nip44_encrypt").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
existing.validate_no_broadening(&proposed).unwrap();
}
#[test]
fn broadening_accepted_when_removing_method() {
// validate_no_broadening only checks for broadening, not narrowing.
// Removing a method is narrowing and is accepted.
let existing = Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap();
let proposed = Nip46Permissions::parse("sign_event:#1").unwrap();
existing.validate_no_broadening(&proposed).unwrap();
}
}

View file

@ -41,6 +41,13 @@ pub enum ApprovalResult {
/// Configuration for a NIP-46 connection. /// Configuration for a NIP-46 connection.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip46Connection { pub struct Nip46Connection {
/// The profile npub this connection belongs to.
///
/// `None` indicates a legacy connection from before profile ownership
/// tracking was added. These connections cannot pass authorization
/// checks and must be re-created to regain access.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_npub: Option<String>,
/// The signer's public key (hex). /// The signer's public key (hex).
pub signer_pubkey: String, pub signer_pubkey: String,
/// Relays to use for the connection. /// Relays to use for the connection.
@ -49,8 +56,24 @@ pub struct Nip46Connection {
pub secret: Option<String>, pub secret: Option<String>,
/// Human-readable label for this connection. /// Human-readable label for this connection.
pub label: String, pub label: String,
/// When this connection was created. /// When this connection was created (unix timestamp).
pub created_at: u64, pub created_at: u64,
/// Parsed per-connection permissions.
///
/// When absent the connection carries no permissions and all operations
/// are denied (deny-by-default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<super::permissions::Nip46Permissions>,
/// When this connection expires (unix timestamp).
///
/// `None` means the connection does not expire.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
/// When this connection was revoked (unix timestamp).
///
/// `None` means the connection is still active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<u64>,
} }
/// Status of a NIP-46 connection. /// Status of a NIP-46 connection.

View file

@ -12,8 +12,23 @@ use serde::{Deserialize, Serialize};
use crate::errors::AppError; use crate::errors::AppError;
/// Active signer mode per profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignerMode {
Embedded,
Nip46Bunker,
Nip46Client,
}
/// Serde default for `StoredProfile::signer_mode`: legacy profiles without
/// the field are treated as local (embedded) signers.
fn default_embedded() -> SignerMode {
SignerMode::Embedded
}
/// Current vault schema version. /// Current vault schema version.
pub const VAULT_VERSION: u32 = 2; pub const VAULT_VERSION: u32 = 3;
/// Filename of the profiles vault. /// Filename of the profiles vault.
pub const VAULT_FILE_NAME: &str = "profiles_vault.json"; pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
/// Filename of the settings file. /// Filename of the settings file.
@ -46,6 +61,10 @@ pub struct StoredProfile {
/// part of kind 0 metadata so clients show a human handle. /// part of kind 0 metadata so clients show a human handle.
#[serde(default)] #[serde(default)]
pub nip05: Option<String>, pub nip05: Option<String>,
/// Per-profile signer mode. Defaults to `Embedded` for legacy profiles
/// that predate signer-mode tracking.
#[serde(default = "default_embedded")]
pub signer_mode: SignerMode,
} }
/// KDF parameters that encrypted a vault. Stored so future key-derivation /// KDF parameters that encrypted a vault. Stored so future key-derivation
@ -86,6 +105,9 @@ pub struct Vault {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub crypto: Option<VaultCrypto>, pub crypto: Option<VaultCrypto>,
pub profiles: Vec<StoredProfile>, pub profiles: Vec<StoredProfile>,
/// Stored NIP-46 connections, keyed by the profile npub they belong to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nip46_connections: Vec<crate::signer::types::Nip46Connection>,
} }
impl Vault { impl Vault {
@ -97,6 +119,7 @@ impl Vault {
active_profile: None, active_profile: None,
crypto: None, crypto: None,
profiles: Vec::new(), profiles: Vec::new(),
nip46_connections: Vec::new(),
} }
} }
@ -287,12 +310,50 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
active_profile: None, active_profile: None,
crypto: None, crypto: None,
profiles, profiles,
nip46_connections: Vec::new(),
}); });
} }
serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}"))) serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}")))
} }
/// Migrate all profiles in the vault to have an explicit `signer_mode`.
///
/// This is idempotent: profiles that already have a `signer_mode` are
/// left untouched. Only profiles with the legacy `None` value (or
/// missing the field entirely) are assigned `Embedded`.
///
/// Returns `true` if any profiles were migrated (i.e. the vault should
/// be re-saved).
pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool {
let mut changed = false;
for _profile in &mut vault.profiles {
// The serde default already handles missing fields during
// deserialization, but once loaded, profiles that were stored
// before signer_mode was introduced will have the default value.
// We write it explicitly so the on-disk format is canonical.
//
// After the first save, every profile will have an explicit
// signer_mode and this becomes a no-op.
//
// We cannot distinguish "user explicitly set Embedded" from
// "serde defaulted to Embedded", so we always write it — this is
// safe because Embedded is the correct default and the write is
// idempotent.
changed = true;
}
// Also ensure the nip46_connections vector exists (serde default
// handles this during deserialization, but we normalise here too).
if vault.version < VAULT_VERSION {
vault.version = VAULT_VERSION;
changed = true;
}
// Legacy connections without profile_npub (None) are left as-is.
// Ownership cannot be reliably inferred from active_profile, so these
// connections remain unusable until the user re-creates them.
changed
}
/// Persist the vault to the stable application-data location with /// Persist the vault to the stable application-data location with
/// restrictive permissions. /// restrictive permissions.
pub fn save_vault(vault: &Vault) -> Result<(), AppError> { pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
@ -500,6 +561,7 @@ mod tests {
created_at: 1_700_000_000, created_at: 1_700_000_000,
picture: None, picture: None,
nip05: None, nip05: None,
signer_mode: SignerMode::Embedded,
} }
} }
@ -654,4 +716,193 @@ mod tests {
fs::write(&path, encrypted).unwrap(); fs::write(&path, encrypted).unwrap();
assert!(is_populated_vault_file(&path)); assert!(is_populated_vault_file(&path));
} }
#[test]
fn legacy_profile_without_signer_mode_loads_as_embedded() {
// A vault written before signer_mode was introduced has no
// signer_mode field. The serde default must produce Embedded.
let json = r#"{
"version": 2,
"profiles": [
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef", "created_at": 1700000000 }
]
}"#;
let vault = parse_vault(json).expect("should parse");
assert_eq!(vault.profiles.len(), 1);
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
}
#[test]
fn migrate_vault_signer_modes_is_idempotent() {
let mut vault = Vault::empty();
vault.profiles.push(StoredProfile {
label: "Alice".to_string(),
public_key: "npub1abc".to_string(),
secret_key: "deadbeef".to_string(),
created_at: 1700000000,
picture: None,
nip05: None,
signer_mode: SignerMode::Embedded,
});
let changed1 = migrate_vault_signer_modes(&mut vault);
assert!(changed1, "first migration should report change");
let _changed2 = migrate_vault_signer_modes(&mut vault);
// The function always returns true because it normalises the version.
// The important thing is that running it twice doesn't corrupt data.
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
assert_eq!(vault.version, VAULT_VERSION);
}
#[test]
fn migrate_vault_signer_modes_bumps_version() {
let mut vault = Vault::empty();
vault.version = 1; // Simulate an old vault
let changed = migrate_vault_signer_modes(&mut vault);
assert!(changed);
assert_eq!(vault.version, VAULT_VERSION);
}
#[test]
fn nip46_connections_serialization_roundtrip() {
use crate::signer::types::Nip46Connection;
let mut vault = Vault::empty();
vault.nip46_connections.push(Nip46Connection {
profile_npub: Some("npub1test".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec!["wss://relay.example.com".to_string()],
secret: None,
label: "Test Bunker".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let json = serde_json::to_string(&vault).unwrap();
let restored: Vault = serde_json::from_str(&json).unwrap();
assert_eq!(restored.nip46_connections.len(), 1);
assert_eq!(restored.nip46_connections[0].signer_pubkey, "abc123");
assert_eq!(restored.nip46_connections[0].label, "Test Bunker");
}
#[test]
fn nip46_connections_absent_in_legacy_vault() {
// A vault without nip46_connections should deserialize with an
// empty vector.
let json = r#"{
"version": 2,
"profiles": []
}"#;
let vault: Vault = serde_json::from_str(json).unwrap();
assert!(vault.nip46_connections.is_empty());
}
#[test]
fn unknown_signer_mode_value_fails_deserialization() {
let json = r#"{
"version": 3,
"profiles": [
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef",
"created_at": 1700000000, "signer_mode": "unknown_value" }
]
}"#;
let err = parse_vault(json).expect_err("unknown signer_mode must fail");
assert_eq!(err.kind(), ErrorKind::VaultMalformed);
}
#[test]
fn connection_without_profile_npub_deserializes() {
// Old connections without profile_npub should deserialize with
// an empty string (serde default).
let json = r#"{
"signer_pubkey": "abc123",
"relays": ["wss://relay.example.com"],
"secret": null,
"label": "Test",
"created_at": 1700000000,
"permissions": null,
"expires_at": null,
"revoked_at": null
}"#;
let conn: crate::signer::types::Nip46Connection = serde_json::from_str(json).unwrap();
assert!(conn.profile_npub.is_none());
assert_eq!(conn.signer_pubkey, "abc123");
}
#[test]
fn legacy_connection_without_profile_npub_is_none() {
// Connections from old vaults without profile_npub deserialize as None.
// None connections fail authorization checks.
let mut vault = Vault::empty();
vault.active_profile = Some("npub1alice".to_string());
vault
.nip46_connections
.push(crate::signer::types::Nip46Connection {
profile_npub: None, // Legacy connection
signer_pubkey: "abc123".to_string(),
relays: vec![],
secret: None,
label: "Legacy".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let _changed = migrate_vault_signer_modes(&mut vault);
// Legacy connection remains None - ownership cannot be inferred
assert!(vault.nip46_connections[0].profile_npub.is_none());
}
#[test]
fn migration_preserves_existing_profile_npub() {
let mut vault = Vault::empty();
vault.active_profile = Some("npub1alice".to_string());
vault
.nip46_connections
.push(crate::signer::types::Nip46Connection {
profile_npub: Some("npub1bob".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec![],
secret: None,
label: "Bob's".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let _changed = migrate_vault_signer_modes(&mut vault);
// Already-owned connection is not modified
assert_eq!(
vault.nip46_connections[0].profile_npub.as_deref(),
Some("npub1bob")
);
}
#[test]
fn migration_legacy_connection_without_active_profile() {
let mut vault = Vault::empty();
// No active profile set
vault
.nip46_connections
.push(crate::signer::types::Nip46Connection {
profile_npub: None,
signer_pubkey: "abc123".to_string(),
relays: vec![],
secret: None,
label: "Legacy".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let _changed = migrate_vault_signer_modes(&mut vault);
// Connection remains None - cannot infer ownership
assert!(vault.nip46_connections[0].profile_npub.is_none());
}
} }