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:
parent
84f11e615d
commit
85756df081
11 changed files with 654 additions and 116 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'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'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's vault, you approve each
|
||||
remote request.
|
||||
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app'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>
|
||||
|
|
|
|||
|
|
@ -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(), []);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}` };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
450
tests/nip46_e2e.rs
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue