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",
"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",
]

View file

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

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)}` };

View file

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

View file

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