diff --git a/Cargo.lock b/Cargo.lock index 8dd71c3..e893dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,6 +1169,7 @@ dependencies = [ "argon2", "async-trait", "base64", + "futures-util", "getrandom 0.2.17", "hex", "keyring", @@ -1179,6 +1180,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tokio", + "tokio-tungstenite", "uuid", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 6212a43..cd5303f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,10 @@ rpassword = "7" sha2 = "0.10" async-trait = "0.1" keyring = "4.2" + +[dev-dependencies] +base64 = "0.22" +futures-util = "0.3" +getrandom = "0.2" +nostr = "0.45" +tokio-tungstenite = "0.28" diff --git a/frontend/src/components/ExportSecretKeyModal.tsx b/frontend/src/components/ExportSecretKeyModal.tsx index 6aba1c4..c4f9ec5 100644 --- a/frontend/src/components/ExportSecretKeyModal.tsx +++ b/frontend/src/components/ExportSecretKeyModal.tsx @@ -89,9 +89,13 @@ export function ExportSecretKeyModal({ open, onClose, profile }: ExportSecretKey } else if (code === 'profile_not_found') { setFatal({ message: 'That profile is not stored on this computer.' }); setPhase('error'); - } else if (code === 'external_signer_not_connected' || code === 'external_signer_identity_mismatch') { + } else if ( + code === 'external_signer_not_connected' || + code === 'external_signer_identity_mismatch' + ) { setFatal({ - message: 'This profile uses an external signer. Secret key export is not possible for externally managed accounts.', + message: + 'This profile uses an external signer. Secret key export is not possible for externally managed accounts.', }); setPhase('error'); } else { diff --git a/frontend/src/lib/signer/SignerManager.ts b/frontend/src/lib/signer/SignerManager.ts index 063e451..5f58e79 100644 --- a/frontend/src/lib/signer/SignerManager.ts +++ b/frontend/src/lib/signer/SignerManager.ts @@ -322,14 +322,21 @@ export class SignerManager { // ==================== CLIENT MODE (connect TO external signer) ==================== - /** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */ + /** Parse nostrconnect:// or bunker:// 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 isBunker = uri.startsWith('bunker://'); + if (!uri.startsWith('nostrconnect://') && !isBunker) { + throw new SignerError( + 'INVALID_NOSTRCONNECT_URI', + 'URI must start with nostrconnect:// or bunker://', + ); } - const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?'); - const signerPubkey = authority; + const withoutScheme = uri.slice(isBunker ? 'bunker://'.length : 'nostrconnect://'.length); + const [authorityRaw, queryString] = withoutScheme.split('?'); + // bunker://@?relay=… carries a display relay in the + // authority; the key is what precedes the '@'. + const signerPubkey = authorityRaw.split('@')[0]; const params = new URLSearchParams(queryString || ''); const relays = params.getAll('relay'); const secret = params.get('secret') || undefined; diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx index dcabe76..2eeb4dd 100644 --- a/frontend/src/screens/SignerModeScreen.tsx +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -33,11 +33,10 @@ export function SignerModeScreen() { // 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 isNip46Active = + (mode === 'nip46_client' || mode === 'nip46_bunker') && !!nip46StatusState?.connected; const isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available; - - const refreshStatus = useCallback(async () => { try { // Mode comes from AppProvider state, just refresh signer statuses @@ -53,14 +52,24 @@ export function SignerModeScreen() { const status = await embeddedSignerStatus(); setEmbeddedStatus(status); } catch { - setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any); + setEmbeddedStatus({ + type: 'embedded', + available: false, + pending_count: 0, + pending: [], + } as any); } } else { try { const status = await nip46Status(); setNip46StatusState(status); } catch { - setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any); + setNip46StatusState({ + connected: false, + relays: [], + connected_relays: [], + pending_approvals: [], + } as any); } } } catch (err) { @@ -96,12 +105,18 @@ export function SignerModeScreen() { 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')) { + 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.'); + setError( + 'Invalid mode transition: Cannot switch while active session exists. Disconnect first.', + ); } else { setError(msg || 'That operation is not permitted.'); } @@ -112,8 +127,12 @@ export function SignerModeScreen() { const handleNip46Connect = useCallback(async () => { const trimmed = uri.trim(); - if (!trimmed.startsWith('nostrconnect://')) { - setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.'); + // Amber and self-hosted bunkers show a bunker:// link; Nostr Connect + // apps use nostrconnect://. Both are accepted by the backend parser. + if (!trimmed.startsWith('nostrconnect://') && !trimmed.startsWith('bunker://')) { + setError( + 'Paste a bunker:// or nostrconnect:// link from Amber, Nostr Connect, or your bunker.', + ); return; } setError(null); @@ -183,12 +202,16 @@ export function SignerModeScreen() { return isEmbeddedActive ? ( Embedded (Least Secure) ) : ( - Embedded {vaultLocked ? '(Vault Locked)' : ''} + + Embedded {vaultLocked ? '(Vault Locked)' : ''} + ); }; const handleImportKey = useCallback(async () => { - const nsec = prompt('Enter your nsec (npub will be derived) or leave blank to generate a new key:'); + 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 { @@ -237,11 +260,15 @@ export function SignerModeScreen() {
Keypair - {hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'} + + {hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'} +
Vault - {vaultLocked ? 'Locked' : 'Unlocked / No password'} + + {vaultLocked ? 'Locked' : 'Unlocked / No password'} +
Current Mode @@ -249,12 +276,20 @@ export function SignerModeScreen() {
{!hasProfile && ( - )} {hasProfile && vaultLocked && ( - )} @@ -269,7 +304,9 @@ export function SignerModeScreen() {
{/* 1. Most Secure */} -
{/* 2. Moderately Secure */} -
{/* 3. Least Secure */} -