feat(signer): accept bunker:// URIs, async signer permissions, NIP-46 e2e test

- SignerManager/SignerModeScreen parse both nostrconnect:// and bunker://
  (Amber presents bunker://; signer pubkey extracted before '@')
- Signer permission surface made async (permissions, can_*, is_connection_valid)
- tests/nip46_e2e.rs: full client handshake against fake Amber over a local
  relay — NIP-44 round-trip, get_public_key identity, signed-event verification,
  vault persistence asserting no secret material for remote profiles
- prettier formatting of touched frontend files
This commit is contained in:
Avi 2026-09-12 02:40:40 -05:00
commit 85756df081
11 changed files with 654 additions and 116 deletions

View file

@ -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 {

View file

@ -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://<key>@<primary-relay>?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;

View file

@ -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 ? (
<Badge tone="success">Embedded (Least Secure)</Badge>
) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>Embedded {vaultLocked ? '(Vault Locked)' : ''}</Badge>
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>
Embedded {vaultLocked ? '(Vault Locked)' : ''}
</Badge>
);
};
const handleImportKey = useCallback(async () => {
const nsec = prompt('Enter your nsec (npub will be derived) or leave blank to generate a new key:');
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() {
<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>
<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>
<span className="status-value">
{vaultLocked ? 'Locked' : 'Unlocked / No password'}
</span>
</div>
<div className="status-item">
<span className="status-label">Current Mode</span>
@ -249,12 +276,20 @@ export function SignerModeScreen() {
</div>
</div>
{!hasProfile && (
<Button variant="primary" onClick={() => void handleImportKey()} style={{ marginTop: 12 }}>
<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 }}>
<Button
variant="secondary"
onClick={() => void handleUnlockVault()}
style={{ marginTop: 12 }}
>
<Icon name="shield" size={16} /> Unlock Vault
</Button>
)}
@ -269,7 +304,9 @@ export function SignerModeScreen() {
<div className="card-body">
<div className="mode-options">
{/* 1. Most Secure */}
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}>
<label
className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}
>
<input
type="radio"
name="signer-mode"
@ -282,9 +319,9 @@ export function SignerModeScreen() {
<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.
<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>
@ -292,12 +329,16 @@ export function SignerModeScreen() {
<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>}
{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`}>
<label
className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}
>
<input
type="radio"
name="signer-mode"
@ -310,8 +351,8 @@ export function SignerModeScreen() {
<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{' '}
<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">
@ -320,12 +361,16 @@ export function SignerModeScreen() {
<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>}
{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`}>
<label
className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}
>
<input
type="radio"
name="signer-mode"
@ -338,8 +383,8 @@ export function SignerModeScreen() {
<div className="mode-option-content">
<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.
<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>
@ -347,14 +392,18 @@ export function SignerModeScreen() {
<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>}
{mode === 'embedded' && isEmbeddedActive && (
<span className="mode-badge active">Active</span>
)}
</div>
</label>
</div>
{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.'}
{hasProfile
? 'Unlock vault to enable bunker mode.'
: 'No keypair found: Please import a key first.'}
</Alert>
)}
{mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
@ -364,7 +413,8 @@ export function SignerModeScreen() {
)}
{!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.)
No keypair found: Please import a key first. (NIP-46 Client can be selected without
a local key.)
</Alert>
)}
{error && <ErrorText>{error}</ErrorText>}
@ -379,12 +429,14 @@ export function SignerModeScreen() {
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
</header>
<div className="card-body">
{(embeddedStatus!.pending).map((req, idx) => (
{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>}
{req.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive</span>
)}
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
@ -404,16 +456,24 @@ export function SignerModeScreen() {
{(mode === 'nip46_client' || mode === 'nip46_bunker') && (
<section className="card">
<header className="card-header">
<h2>{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker 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 ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} 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>
{nip46StatusState.error && <Alert tone="error" title="Connection error">{nip46StatusState.error}</Alert>}
{nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}
</Alert>
)}
<Button variant="danger" onClick={() => void handleNip46Disconnect()}>
<Icon name="trash" size={16} /> Disconnect
</Button>
@ -427,10 +487,16 @@ export function SignerModeScreen() {
<p>{r.summary}</p>
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}>
<Button
variant="primary"
onClick={() => void handleNip46Approve(r.id, true)}
>
Approve
</Button>
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}>
<Button
variant="danger"
onClick={() => void handleNip46Approve(r.id, false)}
>
Reject
</Button>
</div>
@ -444,7 +510,11 @@ export function SignerModeScreen() {
<div className="field">
<input
type="text"
placeholder={mode === 'nip46_client' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'}
placeholder={
mode === 'nip46_client'
? 'bunker://… or nostrconnect://… (from Amber / Nostr Connect)'
: 'nostrconnect://…'
}
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
@ -452,17 +522,26 @@ export function SignerModeScreen() {
/>
<p className="hint">
{mode === 'nip46_client'
? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.'
? 'In Amber, open Connect and copy the bunker:// link. In other Nostr Connect apps, copy the nostrconnect:// link. Then paste it here.'
: 'Share this with client apps that want to connect to this bunker.'}
</p>
</div>
<div className="field">
<label>Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Remote Signer" />
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Remote Signer"
/>
</div>
{error && <ErrorText>{error}</ErrorText>}
<div className="settings-inline">
<Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}>
<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}>
@ -482,15 +561,16 @@ export function SignerModeScreen() {
<div className="card-body">
<ul className="security-notes">
<li>
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External
signer (hardware wallet / Amber) holds key.
<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 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve each
remote request.
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve
each remote request.
</li>
<li>
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when unlocked.
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when
unlocked.
</li>
</ul>
</div>

View file

@ -284,8 +284,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
[applyState],
);
const exportSecretKey = useCallback(
(npub: string, password: string, reason: string) =>
api.exportSecretKey(npub, password, reason),
(npub: string, password: string, reason: string) => api.exportSecretKey(npub, password, reason),
[],
);
const pickImages = useCallback(() => api.pickImages(), []);

View file

@ -146,7 +146,9 @@ describe('exporting a secret key', () => {
// Reopen — fields should be empty
const dialog2 = await openExport(user);
expect((within(dialog2).getByLabelText('Vault password') as HTMLInputElement).value).toBe('');
expect((within(dialog2).getByLabelText('Reason for export') as HTMLInputElement).value).toBe('');
expect((within(dialog2).getByLabelText('Reason for export') as HTMLInputElement).value).toBe(
'',
);
});
it('shows an error for an incorrect password', async () => {
@ -185,9 +187,7 @@ describe('exporting a secret key', () => {
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(
within(dialog).getByText(/not stored on this computer/),
).toBeInTheDocument();
expect(within(dialog).getByText(/not stored on this computer/)).toBeInTheDocument();
});
});
@ -226,9 +226,7 @@ describe('exporting a secret key', () => {
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(
within(dialog).getByText(/external signer/i),
).toBeInTheDocument();
expect(within(dialog).getByText(/external signer/i)).toBeInTheDocument();
});
});

View file

@ -445,10 +445,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
// Check profile exists first
const profile = state.profiles.find((p) => p.npub === npub);
if (!profile) {
throw Object.assign(
new Error('That profile is not stored on this computer.'),
{ code: 'profile_not_found' },
);
throw Object.assign(new Error('That profile is not stored on this computer.'), {
code: 'profile_not_found',
});
}
// External signer profiles cannot export secret keys
@ -461,24 +460,19 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
if (state.encrypted_storage) {
if (!password) {
throw Object.assign(
new Error('Password required to export secret key.'),
{ code: 'wrong_password' },
);
throw Object.assign(new Error('Password required to export secret key.'), {
code: 'wrong_password',
});
}
// Fake password check: accept "test" or "password"
if (password !== 'test' && password !== 'password') {
throw Object.assign(
new Error('Wrong password.'),
{ code: 'wrong_password' },
);
throw Object.assign(new Error('Wrong password.'), { code: 'wrong_password' });
}
}
if (!reason) {
throw Object.assign(
new Error('A reason is required for key export.'),
{ code: 'config' },
);
throw Object.assign(new Error('A reason is required for key export.'), {
code: 'config',
});
}
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
return { hex, nsec: `nsec1${npub.slice(5)}` };