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

2
Cargo.lock generated
View file

@ -1169,6 +1169,7 @@ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
"base64", "base64",
"futures-util",
"getrandom 0.2.17", "getrandom 0.2.17",
"hex", "hex",
"keyring", "keyring",
@ -1179,6 +1180,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"tokio", "tokio",
"tokio-tungstenite",
"uuid", "uuid",
"zeroize", "zeroize",
] ]

View file

@ -20,3 +20,10 @@ rpassword = "7"
sha2 = "0.10" sha2 = "0.10"
async-trait = "0.1" async-trait = "0.1"
keyring = "4.2" keyring = "4.2"
[dev-dependencies]
base64 = "0.22"
futures-util = "0.3"
getrandom = "0.2"
nostr = "0.45"
tokio-tungstenite = "0.28"

View file

@ -89,9 +89,13 @@ export function ExportSecretKeyModal({ open, onClose, profile }: ExportSecretKey
} else if (code === 'profile_not_found') { } else if (code === 'profile_not_found') {
setFatal({ message: 'That profile is not stored on this computer.' }); setFatal({ message: 'That profile is not stored on this computer.' });
setPhase('error'); 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({ 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'); setPhase('error');
} else { } else {

View file

@ -322,14 +322,21 @@ export class SignerManager {
// ==================== CLIENT MODE (connect TO external signer) ==================== // ==================== 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 { parseExternalSignerURI(uri: string): ExternalSignerConnection {
if (!uri.startsWith('nostrconnect://')) { const isBunker = uri.startsWith('bunker://');
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://'); 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 withoutScheme = uri.slice(isBunker ? 'bunker://'.length : 'nostrconnect://'.length);
const signerPubkey = authority; 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 params = new URLSearchParams(queryString || '');
const relays = params.getAll('relay'); const relays = params.getAll('relay');
const secret = params.get('secret') || undefined; 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) // Single source of truth: backend state (defaults to most secure)
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode; 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 isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available;
const refreshStatus = useCallback(async () => { const refreshStatus = useCallback(async () => {
try { try {
// Mode comes from AppProvider state, just refresh signer statuses // Mode comes from AppProvider state, just refresh signer statuses
@ -53,14 +52,24 @@ export function SignerModeScreen() {
const status = await embeddedSignerStatus(); const status = await embeddedSignerStatus();
setEmbeddedStatus(status); setEmbeddedStatus(status);
} catch { } catch {
setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any); setEmbeddedStatus({
type: 'embedded',
available: false,
pending_count: 0,
pending: [],
} as any);
} }
} else { } else {
try { try {
const status = await nip46Status(); const status = await nip46Status();
setNip46StatusState(status); setNip46StatusState(status);
} catch { } catch {
setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any); setNip46StatusState({
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
} as any);
} }
} }
} catch (err) { } catch (err) {
@ -96,12 +105,18 @@ export function SignerModeScreen() {
await refresh(); await refresh();
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('No keypair') || msg.includes('No active profile') || msg.includes('no active profile')) { if (
msg.includes('No keypair') ||
msg.includes('No active profile') ||
msg.includes('no active profile')
) {
setError('No keypair found: Please import a key first.'); setError('No keypair found: Please import a key first.');
} else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) { } else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) {
setError('Vault locked: Please unlock to switch modes.'); setError('Vault locked: Please unlock to switch modes.');
} else if (msg.includes('ACTIVE_SESSION') || msg.toLowerCase().includes('active session')) { } 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 { } else {
setError(msg || 'That operation is not permitted.'); setError(msg || 'That operation is not permitted.');
} }
@ -112,8 +127,12 @@ export function SignerModeScreen() {
const handleNip46Connect = useCallback(async () => { const handleNip46Connect = useCallback(async () => {
const trimmed = uri.trim(); const trimmed = uri.trim();
if (!trimmed.startsWith('nostrconnect://')) { // Amber and self-hosted bunkers show a bunker:// link; Nostr Connect
setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.'); // 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; return;
} }
setError(null); setError(null);
@ -183,12 +202,16 @@ export function SignerModeScreen() {
return isEmbeddedActive ? ( return isEmbeddedActive ? (
<Badge tone="success">Embedded (Least Secure)</Badge> <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 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; if (nsec === null) return;
setError(null); setError(null);
try { try {
@ -237,11 +260,15 @@ export function SignerModeScreen() {
<div className="status-grid"> <div className="status-grid">
<div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}> <div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}>
<span className="status-label">Keypair</span> <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>
<div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}> <div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}>
<span className="status-label">Vault</span> <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>
<div className="status-item"> <div className="status-item">
<span className="status-label">Current Mode</span> <span className="status-label">Current Mode</span>
@ -249,12 +276,20 @@ export function SignerModeScreen() {
</div> </div>
</div> </div>
{!hasProfile && ( {!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 <Icon name="key" size={16} /> Import / Generate Key
</Button> </Button>
)} )}
{hasProfile && vaultLocked && ( {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 <Icon name="shield" size={16} /> Unlock Vault
</Button> </Button>
)} )}
@ -269,7 +304,9 @@ export function SignerModeScreen() {
<div className="card-body"> <div className="card-body">
<div className="mode-options"> <div className="mode-options">
{/* 1. Most Secure */} {/* 1. Most Secure */}
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}> <label
className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}
>
<input <input
type="radio" type="radio"
name="signer-mode" name="signer-mode"
@ -282,9 +319,9 @@ export function SignerModeScreen() {
<div className="mode-option-content"> <div className="mode-option-content">
<h3>NIP-46 Client (Connect to External Signer)</h3> <h3>NIP-46 Client (Connect to External Signer)</h3>
<p className="security-desc"> <p className="security-desc">
<strong>Most Secure:</strong> Private key never touches this device. Connects to an <strong>Most Secure:</strong> Private key never touches this device. Connects to
external signer (Amber, Nostr Connect, hardware wallet). Every signing request is an external signer (Amber, Nostr Connect, hardware wallet). Every signing
approved on the external device. request is approved on the external device.
</p> </p>
<ul className="mode-features"> <ul className="mode-features">
<li>✓ Private key NEVER on this device</li> <li>✓ Private key NEVER on this device</li>
@ -292,12 +329,16 @@ export function SignerModeScreen() {
<li>✓ Every request approved externally</li> <li>✓ Every request approved externally</li>
<li>✓ Key cannot be extracted if this app is compromised</li> <li>✓ Key cannot be extracted if this app is compromised</li>
</ul> </ul>
{mode === 'nip46_client' && isNip46Active && <span className="mode-badge active">Connected</span>} {mode === 'nip46_client' && isNip46Active && (
<span className="mode-badge active">Connected</span>
)}
</div> </div>
</label> </label>
{/* 2. Moderately Secure */} {/* 2. Moderately Secure */}
<label className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}> <label
className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}
>
<input <input
type="radio" type="radio"
name="signer-mode" name="signer-mode"
@ -310,8 +351,8 @@ export function SignerModeScreen() {
<div className="mode-option-content"> <div className="mode-option-content">
<h3>NIP-46 Bunker (This App Signs)</h3> <h3>NIP-46 Bunker (This App Signs)</h3>
<p className="security-desc"> <p className="security-desc">
<strong>Moderately Secure:</strong> This app acts as a signer for other clients. Key <strong>Moderately Secure:</strong> This app acts as a signer for other clients.
stays in this app&apos;s encrypted vault; other clients connect via{' '} Key stays in this app&apos;s encrypted vault; other clients connect via{' '}
<code>nostrconnect://</code>. <code>nostrconnect://</code>.
</p> </p>
<ul className="mode-features"> <ul className="mode-features">
@ -320,12 +361,16 @@ export function SignerModeScreen() {
<li>✓ Works with Amber, Nostr Connect, etc.</li> <li>✓ Works with Amber, Nostr Connect, etc.</li>
<li>⚠ Key in memory when vault unlocked</li> <li>⚠ Key in memory when vault unlocked</li>
</ul> </ul>
{mode === 'nip46_bunker' && isNip46Active && <span className="mode-badge active">Running</span>} {mode === 'nip46_bunker' && isNip46Active && (
<span className="mode-badge active">Running</span>
)}
</div> </div>
</label> </label>
{/* 3. Least Secure */} {/* 3. Least Secure */}
<label className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}> <label
className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}
>
<input <input
type="radio" type="radio"
name="signer-mode" name="signer-mode"
@ -338,8 +383,8 @@ export function SignerModeScreen() {
<div className="mode-option-content"> <div className="mode-option-content">
<h3>Embedded Signer (Local Keys)</h3> <h3>Embedded Signer (Local Keys)</h3>
<p className="security-desc"> <p className="security-desc">
<strong>Least Secure:</strong> Keys stored locally, signing on this device. Convenient <strong>Least Secure:</strong> Keys stored locally, signing on this device.
but key exists in memory when vault unlocked. Convenient but key exists in memory when vault unlocked.
</p> </p>
<ul className="mode-features"> <ul className="mode-features">
<li>✓ Keys never leave this device</li> <li>✓ Keys never leave this device</li>
@ -347,14 +392,18 @@ export function SignerModeScreen() {
<li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li> <li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li>
<li>⚠ Vulnerable to device compromise</li> <li>⚠ Vulnerable to device compromise</li>
</ul> </ul>
{mode === 'embedded' && isEmbeddedActive && <span className="mode-badge active">Active</span>} {mode === 'embedded' && isEmbeddedActive && (
<span className="mode-badge active">Active</span>
)}
</div> </div>
</label> </label>
</div> </div>
{mode === 'nip46_bunker' && !canSwitchToBunker && ( {mode === 'nip46_bunker' && !canSwitchToBunker && (
<Alert tone="warning" title="Cannot enable bunker"> <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> </Alert>
)} )}
{mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && ( {mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
@ -364,7 +413,8 @@ export function SignerModeScreen() {
)} )}
{!hasProfile && mode !== 'nip46_client' && ( {!hasProfile && mode !== 'nip46_client' && (
<Alert tone="warning" title="No keypair"> <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> </Alert>
)} )}
{error && <ErrorText>{error}</ErrorText>} {error && <ErrorText>{error}</ErrorText>}
@ -379,12 +429,14 @@ export function SignerModeScreen() {
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge> <Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
</header> </header>
<div className="card-body"> <div className="card-body">
{(embeddedStatus!.pending).map((req, idx) => ( {embeddedStatus!.pending.map((req, idx) => (
<div key={req.id} className="signer-pending-item"> <div key={req.id} className="signer-pending-item">
<div className="signer-pending-info"> <div className="signer-pending-info">
<code className="mono">{req.method}</code> <code className="mono">{req.method}</code>
<p>{req.summary}</p> <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>
<div className="settings-inline"> <div className="settings-inline">
<Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}> <Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
@ -404,16 +456,24 @@ export function SignerModeScreen() {
{(mode === 'nip46_client' || mode === 'nip46_bunker') && ( {(mode === 'nip46_client' || mode === 'nip46_bunker') && (
<section className="card"> <section className="card">
<header className="card-header"> <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> </header>
<div className="card-body"> <div className="card-body">
{isNip46Active && nip46StatusState ? ( {isNip46Active && nip46StatusState ? (
<div className="signer-actions"> <div className="signer-actions">
<p className="hint"> <p className="hint">
Connected to <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code> via{' '} Connected to{' '}
{(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code>{' '}
via {nip46StatusState.connected_relays?.length ?? 0} of{' '}
{nip46StatusState.relays?.length ?? 0} relays
</p> </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()}> <Button variant="danger" onClick={() => void handleNip46Disconnect()}>
<Icon name="trash" size={16} /> Disconnect <Icon name="trash" size={16} /> Disconnect
</Button> </Button>
@ -427,10 +487,16 @@ export function SignerModeScreen() {
<p>{r.summary}</p> <p>{r.summary}</p>
</div> </div>
<div className="settings-inline"> <div className="settings-inline">
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}> <Button
variant="primary"
onClick={() => void handleNip46Approve(r.id, true)}
>
Approve Approve
</Button> </Button>
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}> <Button
variant="danger"
onClick={() => void handleNip46Approve(r.id, false)}
>
Reject Reject
</Button> </Button>
</div> </div>
@ -444,7 +510,11 @@ export function SignerModeScreen() {
<div className="field"> <div className="field">
<input <input
type="text" 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} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
autoComplete="off" autoComplete="off"
@ -452,17 +522,26 @@ export function SignerModeScreen() {
/> />
<p className="hint"> <p className="hint">
{mode === 'nip46_client' {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.'} : 'Share this with client apps that want to connect to this bunker.'}
</p> </p>
</div> </div>
<div className="field"> <div className="field">
<label>Label</label> <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> </div>
{error && <ErrorText>{error}</ErrorText>} {error && <ErrorText>{error}</ErrorText>}
<div className="settings-inline"> <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 <Icon name="key" size={16} /> Connect
</Button> </Button>
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}> <Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
@ -482,15 +561,16 @@ export function SignerModeScreen() {
<div className="card-body"> <div className="card-body">
<ul className="security-notes"> <ul className="security-notes">
<li> <li>
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External <strong>NIP-46 Client (Most Secure):</strong> Private key never on this device.
signer (hardware wallet / Amber) holds key. External signer (hardware wallet / Amber) holds key.
</li> </li>
<li> <li>
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve each <strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve
remote request. each remote request.
</li> </li>
<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> </li>
</ul> </ul>
</div> </div>

View file

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

View file

@ -146,7 +146,9 @@ describe('exporting a secret key', () => {
// Reopen — fields should be empty // Reopen — fields should be empty
const dialog2 = await openExport(user); const dialog2 = await openExport(user);
expect((within(dialog2).getByLabelText('Vault password') as HTMLInputElement).value).toBe(''); 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 () => { 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 user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => { await waitFor(() => {
expect( expect(within(dialog).getByText(/not stored on this computer/)).toBeInTheDocument();
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 user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => { await waitFor(() => {
expect( expect(within(dialog).getByText(/external signer/i)).toBeInTheDocument();
within(dialog).getByText(/external signer/i),
).toBeInTheDocument();
}); });
}); });

View file

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

View file

@ -79,7 +79,7 @@ pub trait Signer: Send + Sync {
/// ///
/// Returns an owned value because the NIP-46 client must lock an async /// Returns an owned value because the NIP-46 client must lock an async
/// mutex internally. /// mutex internally.
fn permissions(&self) -> Option<permissions::Nip46Permissions> { async fn permissions(&self) -> Option<permissions::Nip46Permissions> {
None None
} }
@ -88,40 +88,40 @@ pub trait Signer: Send + Sync {
/// The default implementation returns `true` when there are no /// The default implementation returns `true` when there are no
/// permissions (local signers) and `false` when permissions exist but /// permissions (local signers) and `false` when permissions exist but
/// do not allow the operation. /// do not allow the operation.
fn can_sign_event(&self, kind: u16) -> bool { async fn can_sign_event(&self, kind: u16) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_sign_event_kind_allowed(kind), Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
None => true, None => true,
} }
} }
/// Whether `nip44_encrypt` is permitted. /// Whether `nip44_encrypt` is permitted.
fn can_encrypt(&self) -> bool { async fn can_encrypt(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_encrypt_allowed(), Some(ref perms) => perms.is_encrypt_allowed(),
None => true, None => true,
} }
} }
/// Whether `nip44_decrypt` is permitted. /// Whether `nip44_decrypt` is permitted.
fn can_decrypt(&self) -> bool { async fn can_decrypt(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_decrypt_allowed(), Some(ref perms) => perms.is_decrypt_allowed(),
None => true, None => true,
} }
} }
/// Whether `get_public_key` is permitted. /// Whether `get_public_key` is permitted.
fn can_get_public_key(&self) -> bool { async fn can_get_public_key(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_get_public_key_allowed(), Some(ref perms) => perms.is_get_public_key_allowed(),
None => true, None => true,
} }
} }
/// Whether `get_relays` is permitted. /// Whether `get_relays` is permitted.
fn can_get_relays(&self) -> bool { async fn can_get_relays(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_get_relays_allowed(), Some(ref perms) => perms.is_get_relays_allowed(),
None => true, None => true,
} }
@ -130,7 +130,7 @@ pub trait Signer: Send + Sync {
/// Whether the connection is currently valid (not expired, not revoked). /// Whether the connection is currently valid (not expired, not revoked).
/// ///
/// Local signers always return `true`. /// Local signers always return `true`.
fn is_connection_valid(&self) -> bool { async fn is_connection_valid(&self) -> bool {
true true
} }
} }

View file

@ -762,7 +762,7 @@ impl Nip46ClientSigner {
async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> { async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
// Check connection validity // Check connection validity
if !self.is_connection_valid() { if !self.is_connection_valid().await {
return Some(response_err( return Some(response_err(
&request.id, &request.id,
"Connection is expired or revoked".to_string(), "Connection is expired or revoked".to_string(),
@ -778,10 +778,10 @@ impl Nip46ClientSigner {
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok()) .and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64())) .and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
.unwrap_or(0) as u16; .unwrap_or(0) as u16;
self.can_sign_event(kind) self.can_sign_event(kind).await
} }
"nip44_encrypt" => self.can_encrypt(), "nip44_encrypt" => self.can_encrypt().await,
"nip44_decrypt" => self.can_decrypt(), "nip44_decrypt" => self.can_decrypt().await,
_ => false, _ => false,
}; };
@ -812,7 +812,7 @@ impl Nip46ClientSigner {
// The phase is updated in gated_response for key-using methods // The phase is updated in gated_response for key-using methods
// Check connection validity before processing // Check connection validity before processing
if !self.is_connection_valid() { if !self.is_connection_valid().await {
return Some(response_err( return Some(response_err(
&request.id, &request.id,
"Connection is expired or revoked".to_string(), "Connection is expired or revoked".to_string(),
@ -832,7 +832,7 @@ impl Nip46ClientSigner {
Some(response_ok(&request.id, "ack".to_string())) Some(response_ok(&request.id, "ack".to_string()))
} }
"get_public_key" => { "get_public_key" => {
if !self.can_get_public_key() { if !self.can_get_public_key().await {
self.audit_permission_denied("get_public_key").await; self.audit_permission_denied("get_public_key").await;
return Some(response_err( return Some(response_err(
&request.id, &request.id,
@ -842,7 +842,7 @@ impl Nip46ClientSigner {
Some(response_ok(&request.id, keys.public_key().to_hex())) Some(response_ok(&request.id, keys.public_key().to_hex()))
} }
"get_relays" => { "get_relays" => {
if !self.can_get_relays() { if !self.can_get_relays().await {
self.audit_permission_denied("get_relays").await; self.audit_permission_denied("get_relays").await;
return Some(response_err( return Some(response_err(
&request.id, &request.id,
@ -1166,7 +1166,7 @@ impl Signer for Nip46ClientSigner {
// encrypted response, and verify the returned event is exactly what we // encrypted response, and verify the returned event is exactly what we
// asked for (identity + id + signature) before handing it back. A // asked for (identity + id + signature) before handing it back. A
// misbehaving or MITM'd signer cannot swap content or keys. // misbehaving or MITM'd signer cannot swap content or keys.
if !self.can_sign_event(event.kind.as_u16()) { if !self.can_sign_event(event.kind.as_u16()).await {
self.audit_permission_denied("sign_event").await; self.audit_permission_denied("sign_event").await;
return Err(SigningError::PermissionDenied { return Err(SigningError::PermissionDenied {
method: "sign_event".to_string(), method: "sign_event".to_string(),
@ -1240,11 +1240,8 @@ impl Signer for Nip46ClientSigner {
// ── Permission checks ─────────────────────────────────────────────── // ── Permission checks ───────────────────────────────────────────────
fn permissions(&self) -> Option<Nip46Permissions> { async fn permissions(&self) -> Option<Nip46Permissions> {
// We need to block on the async lock here; this is safe because let inner = self.inner.lock().await;
// `permissions()` is only called from synchronous contexts that do
// not hold the inner lock.
let inner = self.inner.blocking_lock();
inner inner
.connection .connection
.as_ref() .as_ref()
@ -1258,45 +1255,45 @@ impl Signer for Nip46ClientSigner {
// refusing locally would make bunker:// connections (which carry no // refusing locally would make bunker:// connections (which carry no
// perms) unusable. When perms WERE declared, the local list is enforced // perms) unusable. When perms WERE declared, the local list is enforced
// as an additional guard. // as an additional guard.
fn can_sign_event(&self, kind: u16) -> bool { async fn can_sign_event(&self, kind: u16) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_sign_event_kind_allowed(kind), Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
None => true, None => true,
} }
} }
fn can_encrypt(&self) -> bool { async fn can_encrypt(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_encrypt_allowed(), Some(ref perms) => perms.is_encrypt_allowed(),
None => true, None => true,
} }
} }
fn can_decrypt(&self) -> bool { async fn can_decrypt(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_decrypt_allowed(), Some(ref perms) => perms.is_decrypt_allowed(),
None => true, None => true,
} }
} }
fn can_get_public_key(&self) -> bool { async fn can_get_public_key(&self) -> bool {
// `get_public_key` is part of the connect handshake for every // `get_public_key` is part of the connect handshake for every
// signer; with no declared perms the signer still answers it. // signer; with no declared perms the signer still answers it.
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_get_public_key_allowed(), Some(ref perms) => perms.is_get_public_key_allowed(),
None => true, None => true,
} }
} }
fn can_get_relays(&self) -> bool { async fn can_get_relays(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_get_relays_allowed(), Some(ref perms) => perms.is_get_relays_allowed(),
None => false, None => false,
} }
} }
fn is_connection_valid(&self) -> bool { async fn is_connection_valid(&self) -> bool {
let inner = self.inner.blocking_lock(); let inner = self.inner.lock().await;
match &inner.connection { match &inner.connection {
None => false, None => false,
Some(conn) => { Some(conn) => {

450
tests/nip46_e2e.rs Normal file
View file

@ -0,0 +1,450 @@
//! End-to-end NIP-46 client test: the real `Nip46ClientSigner` connects
//! against a local relay and a fake Amber that speaks the bunker:// flow —
//! per-connection communication key, delayed human-approval ack, and a real
//! identity revealed only via `get_public_key`.
//!
//! Exercises in one process: relay I/O, NIP-44 encryption, the
//! deferred-identity handshake, `sign_event` with full verification, and
//! vault persistence of the remote profile. No network, no phone.
use std::collections::HashMap;
use std::net::TcpListener as StdTcpListener;
use std::time::Duration;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use keynectr::app::App;
use keynectr::signer::nip46_client::Nip46ClientSigner;
use keynectr::signer::Signer as SignerTrait;
use keynectr::vault::Vault;
use nostr::nips::nip19::ToBech32;
use nostr::nips::nip44::v2;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::*;
use serde_json::{json, Value};
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::Message;
// ---------------------------------------------------------------------------
// Minimal in-process nostr relay
// ---------------------------------------------------------------------------
struct Session {
tx: mpsc::UnboundedSender<String>,
subs: HashMap<String, Vec<Value>>,
}
struct RelayState {
sessions: Vec<Session>,
events: Vec<Event>,
}
/// Match a stored/incoming event against a REQ filter (subset of the nostr
/// relay spec sufficient for this test: kinds + authors).
fn matches(filter: &Value, ev: &Event) -> bool {
if let Some(kinds) = filter.get("kinds").and_then(|k| k.as_array()) {
if !kinds
.iter()
.any(|k| k.as_u64() == Some(u64::from(u16::from(ev.kind))))
{
return false;
}
}
if let Some(authors) = filter.get("authors").and_then(|a| a.as_array()) {
if !authors.is_empty()
&& !authors
.iter()
.any(|a| a.as_str() == Some(ev.pubkey.to_hex().as_str()))
{
return false;
}
}
true
}
async fn start_relay() -> String {
let std_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind relay");
std_listener.set_nonblocking(true).expect("nonblocking");
let listener = tokio::net::TcpListener::from_std(std_listener).expect("tokio listener");
let port = listener.local_addr().unwrap().port();
let url = format!("ws://127.0.0.1:{port}");
let state: std::sync::Arc<Mutex<RelayState>> = std::sync::Arc::new(Mutex::new(RelayState {
sessions: Vec::new(),
events: Vec::new(),
}));
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
continue;
};
let Ok(socket) = tokio_tungstenite::accept_async(stream).await else {
continue;
};
let state = state.clone();
tokio::spawn(async move {
let (mut write, mut read) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let session_idx = {
let mut s = state.lock().await;
s.sessions.push(Session {
tx,
subs: HashMap::new(),
});
s.sessions.len() - 1
};
// Writer half.
let writer = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
if write.send(Message::Text(line.into())).await.is_err() {
break;
}
}
});
while let Some(Ok(msg)) = read.next().await {
let Message::Text(text) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
continue;
};
match arr.first().and_then(|v| v.as_str()).unwrap_or("") {
"REQ" => {
let Some(sub_id) = arr.get(1).and_then(|v| v.as_str()) else {
continue;
};
let filters: Vec<Value> = arr[2..].to_vec();
let mut s = state.lock().await;
if let Some(sess) = s.sessions.get_mut(session_idx) {
sess.subs.insert(sub_id.to_string(), filters.clone());
}
// Replay matching stored events so late joiners
// never miss messages they raced past.
for ev in &s.events {
for f in &filters {
if matches(f, ev) {
let out = json!([
"EVENT",
sub_id,
serde_json::from_str::<Value>(&ev.as_json())
.unwrap_or_default()
])
.to_string();
if let Some(sess) = s.sessions.get(session_idx) {
let _ = sess.tx.send(out);
}
break;
}
}
}
let eose = json!(["EOSE", sub_id]).to_string();
if let Some(sess) = s.sessions.get(session_idx) {
let _ = sess.tx.send(eose);
}
}
"CLOSE" => {
if let Some(sub_id) = arr.get(1).and_then(|v| v.as_str()) {
let mut s = state.lock().await;
if let Some(sess) = s.sessions.get_mut(session_idx) {
sess.subs.remove(sub_id);
}
}
}
"EVENT" => {
let Some(ev) = arr.get(1).and_then(|v| v.as_object()).and_then(|o| {
Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok()
}) else {
continue;
};
let mut s = state.lock().await;
s.events.push(ev.clone());
// OK notice: nostr-sdk's send_event waits for the
// relay to accept the event before resolving.
let ok = json!(["OK", ev.id.to_hex(), true, ""]).to_string();
if let Some(sess) = s.sessions.get(session_idx) {
let _ = sess.tx.send(ok);
}
let ev_json =
serde_json::from_str::<Value>(&ev.as_json()).unwrap_or_default();
// Broadcast to every OTHER session with a
// matching subscription (relay spec: no echo to
// origin).
for (idx, sess) in s.sessions.iter().enumerate() {
if idx == session_idx {
continue;
}
for (sub_id, filters) in &sess.subs {
if filters.iter().any(|f| matches(f, &ev)) {
let out = json!(["EVENT", sub_id, ev_json]).to_string();
let _ = sess.tx.send(out.clone());
break;
}
}
}
}
_ => {}
}
}
writer.abort();
let mut s = state.lock().await;
if let Some(sess) = s.sessions.get_mut(session_idx) {
// Leave a dead session slot; harmless for a test relay.
sess.subs.clear();
}
});
}
});
url
}
// ---------------------------------------------------------------------------
// Fake Amber: signer role with a per-connection comms key + real identity
// ---------------------------------------------------------------------------
fn nip44_enc(conversation: &ConversationKey, plaintext: &str) -> String {
let mut nonce = [0u8; 32];
getrandom::getrandom(&mut nonce).unwrap();
let bytes = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce).unwrap();
B64.encode(bytes)
}
fn nip44_dec(conversation: &ConversationKey, content: &str) -> Option<String> {
let bytes = B64.decode(content).ok()?;
let plain = v2::decrypt_to_bytes(conversation, &bytes).ok()?;
String::from_utf8(plain).ok()
}
/// Connect to the relay as Amber: subscribe to kind 24133, answer the
/// bunker:// handshake (simulated human approval delay), reveal the real
/// identity key, and sign events with it.
async fn run_fake_amber(relay_url: String, comms: Keys, identity: Keys, approval_delay: Duration) {
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
.await
.expect("amber connect");
ws.send(Message::Text(
json!(["REQ", "amber", {"kinds": [24133]}])
.to_string()
.into(),
))
.await
.unwrap();
let comms_pub = comms.public_key();
while let Some(Ok(msg)) = ws.next().await {
let Message::Text(text) = msg else { continue };
let Ok(arr) = serde_json::from_str::<Vec<Value>>(&text) else {
continue;
};
if arr.first().and_then(|v| v.as_str()) != Some("EVENT") {
continue;
}
let Some(ev) = arr
.get(2)
.and_then(|v| v.as_object())
.and_then(|o| Event::from_json(serde_json::to_string(o).ok()?.as_bytes()).ok())
else {
continue;
};
// Never answer our own messages.
if ev.pubkey == comms_pub {
continue;
}
// Try to decrypt with a conversation keyed to this sender. A failure
// means the message was not addressed to us.
let Ok(conversation) = ConversationKey::derive(comms.secret_key(), &ev.pubkey) else {
continue;
};
let Some(plain) = nip44_dec(&conversation, &ev.content) else {
continue;
};
let Ok(req) = serde_json::from_str::<Value>(&plain) else {
continue;
};
let Some(method) = req.get("method").and_then(|m| m.as_str()) else {
continue;
};
let id = req
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let response: Value = match method {
"connect" => {
// Simulate a human tapping "approve" in Amber.
tokio::time::sleep(approval_delay).await;
json!({"id": id, "result": "ack"})
}
"get_public_key" => json!({"id": id, "result": identity.public_key().to_hex()}),
"sign_event" => {
let unsigned_json = req["params"].get(0).and_then(|v| v.as_str());
match unsigned_json.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
Some(mut v) => {
if v.get("pubkey").is_none() {
v["pubkey"] = json!(identity.public_key().to_hex());
}
match serde_json::from_value::<UnsignedEvent>(v)
.ok()
.and_then(|u| identity.sign_event(u).ok())
{
Some(signed) => {
json!({"id": id, "result": signed.as_json()})
}
None => json!({"id": id, "error": "sign failed"}),
}
}
None => json!({"id": id, "error": "bad params"}),
}
}
other => json!({"id": id, "error": format!("unsupported: {other}")}),
};
let content = nip44_enc(&conversation, &response.to_string());
let out = EventBuilder::new(Kind::NostrConnect, content)
.tags([Tag::parse(["p", ev.pubkey.to_hex().as_str()]).unwrap()])
.finalize(&comms)
.unwrap();
ws.send(Message::Text(
json!([
"EVENT",
serde_json::from_str::<Value>(&out.as_json()).unwrap()
])
.to_string()
.into(),
))
.await
.unwrap();
}
}
// ---------------------------------------------------------------------------
// The test
// ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn nip46_client_handshake_and_sign_against_fake_amber() {
// Isolated vault so the test never touches the real user vault.
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("profiles_vault.json"),
serde_json::to_string(&Vault::empty()).unwrap(),
)
.unwrap();
std::env::set_var("XDG_DATA_HOME", &tmp);
let app = std::sync::Arc::new(Mutex::new(App::load().expect("load app")));
// Amber's keys: `comms` is the per-connection key in the bunker:// URI;
// `identity` is the REAL signing identity, never in the URI.
let comms = Keys::generate();
let identity = Keys::generate();
let relay_url = start_relay().await;
tokio::spawn(run_fake_amber(
relay_url.clone(),
comms.clone(),
identity.clone(),
Duration::from_millis(400),
));
let signer = Nip46ClientSigner::new(app.clone());
// Fail closed: signing before connect must error, never fall back.
let unsigned = UnsignedEvent::new(
identity.public_key(),
Timestamp::now(),
Kind::TextNote,
vec![],
"hello via amber".to_string(),
);
assert!(
SignerTrait::sign_event(&signer, unsigned.clone())
.await
.is_err(),
"signing before connect must fail closed"
);
// Amber shows exactly this URI: authority = comms key, no identity.
let uri = format!(
"bunker://{}?relay={}",
comms.public_key().to_hex(),
relay_url
);
let status = signer
.connect(&uri, "fake amber".to_string())
.await
.expect("connect");
// Session starts Connecting, not Connected: identity is not yet proven.
assert!(!status.connected, "must not be connected before handshake");
// Wait for the handshake (approval delay + get_public_key) to complete.
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
loop {
let status = signer.status().await;
if let Some(err) = &status.error {
panic!("signer failed: {err}");
}
if status.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"handshake never completed; last status: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Identity must be the REAL key, not the URI comms key.
let resolved = SignerTrait::get_public_key(&signer)
.await
.expect("identity resolved");
assert_eq!(
resolved,
identity.public_key(),
"identity must come from get_public_key"
);
assert_ne!(
resolved,
comms.public_key(),
"URI key must never become identity"
);
// Sign a note through the external signer and verify the client checks
// identity, id, and signature on the returned event.
let signed = SignerTrait::sign_event(&signer, unsigned.clone())
.await
.expect("remote sign_event");
assert_eq!(signed.pubkey, identity.public_key());
assert_eq!(signed.content, "hello via amber");
assert_eq!(signed.id, unsigned.compute_id());
assert!(signed.verify_signature());
// Vault persistence: the handshake stored a remote profile under the
// REAL identity, in external-signer mode.
let identity_npub = identity.public_key().to_bech32().unwrap();
let app_guard = app.lock().await;
let profile = app_guard
.vault
.profiles
.iter()
.find(|p| p.public_key == identity_npub)
.expect("remote profile row created");
assert_eq!(
profile.signer_mode,
keynectr::vault::SignerMode::Nip46Client,
"remote profile must be in external-signer mode"
);
assert!(
profile.secret_key.trim().is_empty(),
"no secret material for remote profiles"
);
drop(app_guard);
// Clean teardown so a failed run cannot leave a stuck task.
signer.disconnect().await.ok();
let _ = PublicKey::from_hex; // keep import used across cfg variations
}