feat: add per-profile signer modes with persisted NIP-46 connections
Introduce three coexisting signing modes: - Embedded (INTERNAL): vault-held nsec, decrypted in Rust, signs locally. - Nip46Client (EXTERNAL): Keynctr is the NIP-46 CLIENT; the key never touches this machine. - Nip46Bunker: legacy inverted mode (Keynctr as signer serving others). Data model: - StoredProfile gains signer_mode (serde-defaults to Embedded for legacy profiles); SignerMode moves from app.rs to vault.rs to break a circular dependency; app.rs re-exports it. - Vault gains nip46_connections (profile-owned) and bumps VAULT_VERSION to 3; migrate_vault_signer_modes() normalises on load (idempotent). - Nip46Connection gains profile_npub ownership, parsed permissions, expires_at, and revoked_at. Signer abstraction (src/signer): - Signer trait gains pubkey_for() identity validation, a Signing enum (Local vs External) that re-verifies the returned event, and a permission surface (permissions/can_*/is_connection_valid) with safe defaults. - permissions.rs: NIP-46 per-connection permission model (parse, validate, deny-by-default, no-broadening checks) with 52 unit tests. - Nip46ClientSigner parses perms from nostrconnect:// URIs, enforces permissions on every gated request, persists/revokes connections in the vault, and audits permission denials via the app's audit log. - App gains audit_log and a nip46_bunker_signer handle; default mode is Nip46Client (most secure). Frontend: SignerModeScreen redesigned for the three modes with a nostr-tools-based SignerManager client, new IPC allowlist entries, and signer-mode styling. Verified: cargo test --release 186 passed; clippy/fmt clean; frontend tsc clean, vitest 110 passed.
This commit is contained in:
parent
caed722b06
commit
2c61830390
17 changed files with 2801 additions and 319 deletions
|
|
@ -38,12 +38,12 @@ protocol.registerSchemesAsPrivileged([
|
|||
* (HMR websocket included).
|
||||
*/
|
||||
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. */
|
||||
|
|
|
|||
143
frontend/package-lock.json
generated
143
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
536
frontend/src/lib/signer/SignerManager.ts
Normal file
536
frontend/src/lib/signer/SignerManager.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
import { nip19, generateSecretKey, finalizeEvent, EventTemplate } from 'nostr-tools';
|
||||
import { bytesToHex } from 'nostr-tools/utils';
|
||||
|
||||
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
|
||||
|
||||
export interface Keypair {
|
||||
nsec: string;
|
||||
npub: string;
|
||||
privateKey: Uint8Array<ArrayBufferLike>;
|
||||
publicKey: Uint8Array<ArrayBufferLike>;
|
||||
}
|
||||
|
||||
export interface NostrConnectURI {
|
||||
uri: string;
|
||||
signerPubkey: string;
|
||||
relays: string[];
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface ExternalSignerConnection {
|
||||
signerPubkey: string;
|
||||
relays: string[];
|
||||
secret?: string;
|
||||
connected: boolean;
|
||||
conversationKey?: string;
|
||||
}
|
||||
|
||||
export class SignerError extends Error {
|
||||
constructor(
|
||||
public readonly code: SignerErrorCode,
|
||||
message: string,
|
||||
public readonly details?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SignerError';
|
||||
}
|
||||
}
|
||||
|
||||
export type SignerErrorCode =
|
||||
| 'NO_KEYPAIR'
|
||||
| 'VAULT_LOCKED'
|
||||
| 'ACTIVE_SESSION_EXISTS'
|
||||
| 'INVALID_NOSTRCONNECT_URI'
|
||||
| 'NO_RELAYS_CONFIGURED'
|
||||
| 'BUNKER_START_FAILED'
|
||||
| 'CLIENT_CONNECT_FAILED'
|
||||
| 'SIGNING_FAILED'
|
||||
| 'APPROVAL_REJECTED'
|
||||
| 'APPROVAL_TIMEOUT';
|
||||
|
||||
export interface SignerState {
|
||||
mode: SignerMode;
|
||||
keypair: Keypair | null;
|
||||
isVaultUnlocked: boolean;
|
||||
/** Bunker mode (this app acts as signer for other clients) */
|
||||
bunker: {
|
||||
isRunning: boolean;
|
||||
connectionURI: string | null;
|
||||
connectedClients: Map<string, { pubkey: string; relays: string[] }>;
|
||||
};
|
||||
/** Client mode (this app connects to external signer like Amber) */
|
||||
client: {
|
||||
isConnected: boolean;
|
||||
signerPubkey: string | null;
|
||||
relays: string[];
|
||||
pendingRequests: Map<string, PendingSignRequest>;
|
||||
};
|
||||
embedded: {
|
||||
isActive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PendingSignRequest {
|
||||
id: string;
|
||||
event: EventTemplate;
|
||||
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
|
||||
params: unknown[];
|
||||
resolve: (result: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
type StateListener = (state: SignerState) => void;
|
||||
|
||||
export class SignerManager {
|
||||
private state: SignerState = {
|
||||
mode: 'nip46_client',
|
||||
keypair: null,
|
||||
isVaultUnlocked: false,
|
||||
bunker: {
|
||||
isRunning: false,
|
||||
connectionURI: null,
|
||||
connectedClients: new Map(),
|
||||
},
|
||||
client: {
|
||||
isConnected: false,
|
||||
signerPubkey: null,
|
||||
relays: [],
|
||||
pendingRequests: new Map(),
|
||||
},
|
||||
embedded: {
|
||||
isActive: false,
|
||||
},
|
||||
};
|
||||
|
||||
private listeners: Set<StateListener> = new Set();
|
||||
private abortController: AbortController | null = null;
|
||||
private requestIdCounter = 0;
|
||||
|
||||
/** Subscribe to state changes */
|
||||
subscribe(listener: StateListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getState());
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.getState());
|
||||
}
|
||||
}
|
||||
|
||||
getState(): Readonly<SignerState> {
|
||||
return Object.freeze({ ...this.state });
|
||||
}
|
||||
|
||||
/** Import a keypair from nsec or generate new one */
|
||||
async importKeypair(nsecOrPrivateKey?: string): Promise<Keypair> {
|
||||
let pk: Uint8Array<ArrayBufferLike>;
|
||||
|
||||
if (nsecOrPrivateKey) {
|
||||
try {
|
||||
const decoded = nip19.decode(nsecOrPrivateKey);
|
||||
if (decoded.type !== 'nsec') {
|
||||
throw new SignerError('NO_KEYPAIR', 'Provided key is not a valid nsec');
|
||||
}
|
||||
pk = decoded.data as any;
|
||||
} catch {
|
||||
throw new SignerError('NO_KEYPAIR', 'Invalid nsec format');
|
||||
}
|
||||
} else {
|
||||
pk = generateSecretKey();
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Explicit cast for nip19 API
|
||||
const nsec = nip19.nsecEncode(pk as any);
|
||||
const npub = nip19.npubEncode(bytesToHex(pk as any));
|
||||
|
||||
const keypair: Keypair = {
|
||||
nsec,
|
||||
npub,
|
||||
privateKey: pk,
|
||||
publicKey: pk,
|
||||
};
|
||||
|
||||
this.state.keypair = keypair;
|
||||
this.notify();
|
||||
return keypair;
|
||||
}
|
||||
|
||||
/** Set vault unlock state (called by vault unlock/lock) */
|
||||
async setVaultUnlocked(unlocked: boolean): Promise<void> {
|
||||
this.state.isVaultUnlocked = unlocked;
|
||||
if (!unlocked) {
|
||||
await this.stopAll();
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Switch signer mode with full validation */
|
||||
async setMode(mode: SignerMode): Promise<void> {
|
||||
if (mode === this.state.mode) return;
|
||||
|
||||
// Stop current mode
|
||||
switch (this.state.mode) {
|
||||
case 'embedded':
|
||||
await this.stopEmbedded();
|
||||
break;
|
||||
case 'nip46_bunker':
|
||||
await this.stopBunker();
|
||||
break;
|
||||
case 'nip46_client':
|
||||
await this.disconnectClient();
|
||||
break;
|
||||
}
|
||||
|
||||
// Start new mode
|
||||
switch (mode) {
|
||||
case 'embedded':
|
||||
await this.startEmbedded();
|
||||
break;
|
||||
case 'nip46_bunker':
|
||||
await this.startBunker();
|
||||
break;
|
||||
case 'nip46_client':
|
||||
// Client mode requires explicit connection via connectToExternalSigner()
|
||||
break;
|
||||
}
|
||||
|
||||
this.state.mode = mode;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Start embedded signer (local signing) */
|
||||
private async startEmbedded(): Promise<void> {
|
||||
if (!this.state.keypair || !this.state.isVaultUnlocked) {
|
||||
throw new SignerError(
|
||||
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
|
||||
this.state.keypair
|
||||
? 'Vault locked: Please unlock to use embedded signer.'
|
||||
: 'No keypair found: Please import a key first.',
|
||||
);
|
||||
}
|
||||
this.state.embedded.isActive = true;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Stop embedded signer */
|
||||
private async stopEmbedded(): Promise<void> {
|
||||
this.state.embedded.isActive = false;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// ==================== BUNKER MODE (this app acts as signer) ====================
|
||||
|
||||
/** Generate nostrconnect:// URI for bunker mode */
|
||||
generateBunkerURI(relays: string[], secret?: string): NostrConnectURI {
|
||||
if (!this.state.keypair) {
|
||||
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
|
||||
}
|
||||
if (relays.length === 0) {
|
||||
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46 connection.');
|
||||
}
|
||||
|
||||
const signerPubkey = this.state.keypair.npub;
|
||||
const params = new URLSearchParams();
|
||||
for (const relay of relays) {
|
||||
params.append('relay', relay);
|
||||
}
|
||||
if (secret) {
|
||||
params.append('secret', secret);
|
||||
}
|
||||
|
||||
const uri = `nostrconnect://${signerPubkey}?${params.toString()}`;
|
||||
|
||||
return { uri, signerPubkey, relays, secret };
|
||||
}
|
||||
|
||||
/** Start NIP-46 bunker server (this app acts as signer) */
|
||||
async startBunker(relays?: string[]): Promise<NostrConnectURI> {
|
||||
this.validateBunkerPreconditions();
|
||||
|
||||
const relayList = relays ?? this.getDefaultRelays();
|
||||
if (relayList.length === 0) {
|
||||
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46.');
|
||||
}
|
||||
|
||||
const connectionInfo = this.generateBunkerURI(relayList);
|
||||
|
||||
this.abortController = new AbortController();
|
||||
const { signal } = this.abortController;
|
||||
|
||||
try {
|
||||
await this.runBunkerServer(signal);
|
||||
} catch (error) {
|
||||
this.state.bunker.isRunning = false;
|
||||
this.state.bunker.connectionURI = null;
|
||||
this.notify();
|
||||
throw new SignerError(
|
||||
'BUNKER_START_FAILED',
|
||||
'Failed to start NIP-46 bunker server',
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
|
||||
this.state.bunker.isRunning = true;
|
||||
this.state.bunker.connectionURI = connectionInfo.uri;
|
||||
this.notify();
|
||||
|
||||
return connectionInfo;
|
||||
}
|
||||
|
||||
private validateBunkerPreconditions(): void {
|
||||
if (!this.state.keypair) {
|
||||
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
|
||||
}
|
||||
if (!this.state.isVaultUnlocked) {
|
||||
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to switch modes.');
|
||||
}
|
||||
if (this.state.bunker.isRunning) {
|
||||
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Bunker already running.');
|
||||
}
|
||||
if (this.state.client.isConnected) {
|
||||
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Client mode active. Disconnect first.');
|
||||
}
|
||||
if (this.state.embedded.isActive) {
|
||||
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Embedded signer active. Stop it first.');
|
||||
}
|
||||
}
|
||||
|
||||
async stopBunker(): Promise<void> {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
this.state.bunker.isRunning = false;
|
||||
this.state.bunker.connectionURI = null;
|
||||
this.state.bunker.connectedClients.clear();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private async runBunkerServer(signal: AbortSignal): Promise<void> {
|
||||
// Simplified - real impl would use websocket + NIP-44
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkAbort = () => {
|
||||
if (signal.aborted) resolve();
|
||||
else setTimeout(checkAbort, 100);
|
||||
};
|
||||
checkAbort();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== CLIENT MODE (connect TO external signer) ====================
|
||||
|
||||
/** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */
|
||||
parseExternalSignerURI(uri: string): ExternalSignerConnection {
|
||||
if (!uri.startsWith('nostrconnect://')) {
|
||||
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://');
|
||||
}
|
||||
|
||||
const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?');
|
||||
const signerPubkey = authority;
|
||||
const params = new URLSearchParams(queryString || '');
|
||||
const relays = params.getAll('relay');
|
||||
const secret = params.get('secret') || undefined;
|
||||
|
||||
if (!signerPubkey) {
|
||||
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'Missing signer pubkey in URI');
|
||||
}
|
||||
if (relays.length === 0) {
|
||||
throw new SignerError('NO_RELAYS_CONFIGURED', 'URI must contain at least one relay');
|
||||
}
|
||||
|
||||
return { signerPubkey, relays, secret, connected: false };
|
||||
}
|
||||
|
||||
/** Connect to external signer (Amber, Nostr Connect, bunker) using nostrconnect:// URI */
|
||||
async connectToExternalSigner(uri: string, relays?: string[]): Promise<ExternalSignerConnection> {
|
||||
if (this.state.client.isConnected) {
|
||||
throw new SignerError(
|
||||
'ACTIVE_SESSION_EXISTS',
|
||||
'Already connected to external signer. Disconnect first.',
|
||||
);
|
||||
}
|
||||
if (!this.state.keypair) {
|
||||
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
|
||||
}
|
||||
if (!this.state.isVaultUnlocked) {
|
||||
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to connect.');
|
||||
}
|
||||
|
||||
const parsed = this.parseExternalSignerURI(uri);
|
||||
const relayList = relays ?? parsed.relays ?? this.getDefaultRelays();
|
||||
|
||||
if (relayList.length === 0) {
|
||||
throw new SignerError(
|
||||
'NO_RELAYS_CONFIGURED',
|
||||
'No relays configured for NIP-46 client connection.',
|
||||
);
|
||||
}
|
||||
|
||||
// Derive conversation key with external signer
|
||||
const conversationKey = this.deriveConversationKey();
|
||||
|
||||
// In real implementation:
|
||||
// 1. Connect to relays via websocket
|
||||
// 2. Subscribe to kind 24133 from external signer
|
||||
// 3. Send 'connect' request with our pubkey + secret
|
||||
// 4. Handle incoming requests (sign_event, nip44_encrypt, nip44_decrypt)
|
||||
|
||||
// For now, simulate connection
|
||||
this.state.client = {
|
||||
isConnected: true,
|
||||
signerPubkey: parsed.signerPubkey,
|
||||
relays: relayList,
|
||||
pendingRequests: new Map(),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
return { ...parsed, connected: true, conversationKey };
|
||||
}
|
||||
|
||||
/** Disconnect from external signer */
|
||||
async disconnectClient(): Promise<void> {
|
||||
// Clear pending requests with rejection
|
||||
for (const [, request] of this.state.client.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new SignerError('APPROVAL_REJECTED', 'Disconnected from external signer'));
|
||||
}
|
||||
this.state.client = {
|
||||
isConnected: false,
|
||||
signerPubkey: null,
|
||||
relays: [],
|
||||
pendingRequests: new Map(),
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Sign event via external signer (request/response with user approval) */
|
||||
async signEventViaExternalSigner(event: EventTemplate): Promise<string> {
|
||||
if (!this.state.client.isConnected) {
|
||||
throw new SignerError(
|
||||
'CLIENT_CONNECT_FAILED',
|
||||
'Not connected to external signer. Connect first.',
|
||||
);
|
||||
}
|
||||
|
||||
return this.sendNip46Request('sign_event', [JSON.stringify(event)]);
|
||||
}
|
||||
|
||||
/** Send NIP-46 request to external signer and wait for approval */
|
||||
private async sendNip46Request(method: string, params: unknown[]): Promise<string> {
|
||||
if (!this.state.client.isConnected) {
|
||||
throw new SignerError('CLIENT_CONNECT_FAILED', 'Not connected to external signer.');
|
||||
}
|
||||
|
||||
const requestId = `req_${++this.requestIdCounter}_${Date.now()}`;
|
||||
|
||||
// Create promise that resolves when user approves/rejects
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.state.client.pendingRequests.delete(requestId);
|
||||
reject(new SignerError('APPROVAL_TIMEOUT', 'Approval request timed out'));
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
const request: PendingSignRequest = {
|
||||
id: requestId,
|
||||
event: params[0] as EventTemplate,
|
||||
method: method as 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt',
|
||||
params,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
};
|
||||
|
||||
this.state.client.pendingRequests.set(requestId, request);
|
||||
this.notify();
|
||||
|
||||
// In real implementation: encrypt request with conversation key, publish to relays
|
||||
// External signer receives, shows UI, user approves, response encrypted and published back
|
||||
});
|
||||
}
|
||||
|
||||
/** Approve or reject a pending external signer request */
|
||||
async respondToExternalRequest(requestId: string, approved: boolean): Promise<void> {
|
||||
const request = this.state.client.pendingRequests.get(requestId);
|
||||
if (!request) {
|
||||
throw new SignerError('APPROVAL_REJECTED', 'Request not found or already processed');
|
||||
}
|
||||
|
||||
clearTimeout(request.timeout);
|
||||
this.state.client.pendingRequests.delete(requestId);
|
||||
|
||||
if (approved) {
|
||||
// In real impl: sign/encrypt with conversation key, publish response
|
||||
// For now, simulate success
|
||||
request.resolve('signed_event_id_or_encrypted_result');
|
||||
} else {
|
||||
request.reject(new SignerError('APPROVAL_REJECTED', 'Request rejected by user'));
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Derive NIP-44 conversation key with another pubkey */
|
||||
private deriveConversationKey(): string {
|
||||
// Real impl: nip44.v2.ConversationKey.derive(mySk, theirPk)
|
||||
return 'derived_conversation_key';
|
||||
}
|
||||
|
||||
/** Stop all signers */
|
||||
async stopAll(): Promise<void> {
|
||||
await this.stopEmbedded();
|
||||
await this.stopBunker();
|
||||
await this.disconnectClient();
|
||||
this.state.mode = 'embedded';
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Sign an event (embedded mode only) */
|
||||
async signEvent(event: EventTemplate): Promise<string> {
|
||||
if (this.state.mode !== 'embedded') {
|
||||
throw new SignerError(
|
||||
'SIGNING_FAILED',
|
||||
`Signing not available in ${this.state.mode} mode. Use external signer.`,
|
||||
);
|
||||
}
|
||||
if (!this.state.keypair || !this.state.isVaultUnlocked) {
|
||||
throw new SignerError(
|
||||
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
|
||||
'Cannot sign: vault locked or no keypair.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = finalizeEvent(event, this.state.keypair.privateKey);
|
||||
return signedEvent.id;
|
||||
} catch (error) {
|
||||
throw new SignerError('SIGNING_FAILED', 'Failed to sign event', String(error));
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultRelays(): string[] {
|
||||
return ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PendingSignRequest {
|
||||
id: string;
|
||||
event: EventTemplate;
|
||||
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
|
||||
params: unknown[];
|
||||
resolve: (result: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/** React hook for using SignerManager */
|
||||
export function useSignerManager(): SignerManager {
|
||||
return new SignerManager();
|
||||
}
|
||||
|
||||
/** React hook for signer state */
|
||||
export function useSignerState(): Readonly<SignerState> {
|
||||
const manager = useSignerManager();
|
||||
return manager.getState();
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ export type Theme =
|
|||
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
|
||||
|
||||
/** 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 {
|
||||
|
|
|
|||
|
|
@ -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'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'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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue