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

View file

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

View file

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

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

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

View file

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