fix(nip46): spec-correct connect params, full-handshake [NIP46] trace, visible handshake errors

This commit is contained in:
Avi 2026-09-23 13:02:19 -05:00
commit 8f055f71bf
5 changed files with 672 additions and 51 deletions

View file

@ -592,6 +592,11 @@ export function SignerModeScreen() {
Waiting for the signer to scan… the connection appears automatically once Waiting for the signer to scan… the connection appears automatically once
approved. The code expires after a few minutes. approved. The code expires after a few minutes.
</p> </p>
{nip46StatusState?.error && (
<Alert tone="error" title="Pairing failed">
{nip46StatusState.error}
</Alert>
)}
{pairError && <ErrorText>{pairError}</ErrorText>} {pairError && <ErrorText>{pairError}</ErrorText>}
<div className="settings-inline" style={{ justifyContent: 'center' }}> <div className="settings-inline" style={{ justifyContent: 'center' }}>
<Button variant="ghost" onClick={() => void handlePairCancel()}> <Button variant="ghost" onClick={() => void handlePairCancel()}>
@ -636,6 +641,21 @@ export function SignerModeScreen() {
</div> </div>
{error && <ErrorText>{error}</ErrorText>} {error && <ErrorText>{error}</ErrorText>}
{pairError && <ErrorText>{pairError}</ErrorText>} {pairError && <ErrorText>{pairError}</ErrorText>}
{/* A failed handshake must never look like an idle form:
surface the backend's error so a timeout is visible. */}
{nip46StatusState?.error && (
<Alert tone="error" title="Connection failed">
{nip46StatusState.error}
</Alert>
)}
{/* A sent-but-unapproved connection request is in flight:
say so instead of showing a blank form. */}
{!nip46StatusState?.error && (nip46StatusState?.relays?.length ?? 0) > 0 && (
<p className="hint">
Connection request sent — approve it in Amber. This updates automatically; it
can take up to a couple of minutes on a slow network.
</p>
)}
<div className="settings-inline"> <div className="settings-inline">
{mode === 'nip46_client' && ( {mode === 'nip46_client' && (
<Button <Button

View file

@ -0,0 +1,72 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, vi } from 'vitest';
import { SignerModeScreen } from '../screens/SignerModeScreen';
import { renderWithApp } from './render';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { makeState } from './apiMock';
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
}));
function installNip46Backend() {
const backend = createFakeBackend(makeState({ signer_mode: 'nip46_client' }));
installFakeBackend(backend);
return backend;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('SignerModeScreen handshake states', () => {
it('shows the QR waiting hint while a pairing is in flight', async () => {
const backend = installNip46Backend();
const user = userEvent.setup();
renderWithApp(<SignerModeScreen />);
await screen.findByRole('button', { name: /Show QR/i });
await user.click(screen.getByRole('button', { name: /Show QR/i }));
expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument();
expect(backend.requests.some((r) => r.method === 'nip46_pair_start')).toBe(true);
});
it('shows a connecting hint after a paste-URI connect is sent but unapproved', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: false,
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText(/Connection request sent/i)).toBeInTheDocument();
});
it('surfaces a failed handshake as a visible error, not a silent idle form', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: false,
relays: ['wss://relay.test'],
connected_relays: [],
error:
'The signer would not reveal its public key (timeout). Keep Amber open in the foreground with network access and try again.',
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText('Connection failed')).toBeInTheDocument();
expect(
await screen.findByText(/The signer would not reveal its public key/i),
).toBeInTheDocument();
// The failure must poll through the same status channel the screen reads.
await waitFor(() =>
expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true),
);
});
});

View file

@ -2,6 +2,7 @@ import type {
AppState, AppState,
BackendResponse, BackendResponse,
FeedItem, FeedItem,
Nip46SignerStatus,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
@ -42,6 +43,9 @@ export interface FakeBackend {
/** Current NIP-46 signer status. */ /** Current NIP-46 signer status. */
signer: SignerStatus; signer: SignerStatus;
setSigner: (next: SignerStatus) => void; setSigner: (next: SignerStatus) => void;
/** NIP-46 client handshake status backing the nip46_* methods. */
nip46: Nip46SignerStatus;
setNip46: (next: Nip46SignerStatus) => void;
/** Standing "always allow" grants returned by signer_grants_list. */ /** Standing "always allow" grants returned by signer_grants_list. */
signerGrants: SignerGrant[]; signerGrants: SignerGrant[];
/** Notes returned by `feed_get`. */ /** Notes returned by `feed_get`. */
@ -109,6 +113,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
setSigner(next) { setSigner(next) {
backend.signer = next; backend.signer = next;
}, },
nip46: {
type: 'nip46',
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
},
setNip46(next) {
backend.nip46 = next;
},
signerGrants: [], signerGrants: [],
feedItems: [ feedItems: [
{ {
@ -181,6 +195,38 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'get_state': case 'get_state':
return state; return state;
// NIP-46 client handshake surface used by SignerModeScreen. The fake
// keeps a Nip46SignerStatus-shaped object so handshake-state tests
// (pairing URI, connecting relays, failure errors) run without relays.
case 'nip46_status':
return backend.nip46;
case 'nip46_pair_start': {
const next = {
...backend.nip46,
pairing_uri: `nostrconnect://deadbeef?relay=${encodeURIComponent('wss://relay.test')}&secret=fake`,
};
backend.setNip46(next);
return next;
}
case 'nip46_connect': {
const next = { ...backend.nip46, relays: ['wss://relay.test'] };
backend.setNip46(next);
return next;
}
case 'nip46_disconnect': {
const next: Nip46SignerStatus = {
type: 'nip46',
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
};
backend.setNip46(next);
return next;
}
case 'nip46_approve':
return backend.nip46;
case 'create_profile': { case 'create_profile': {
const label = String(params.label ?? ''); const label = String(params.label ?? '');
const profile: ProfileSummary = { const profile: ProfileSummary = {

View file

@ -267,32 +267,40 @@ impl Nip46ClientSigner {
/// Connect to a NIP-46 signer using a nostrconnect:// URI. /// Connect to a NIP-46 signer using a nostrconnect:// URI.
pub async fn connect(&self, uri: &str, label: String) -> Result<Nip46Status, AppError> { pub async fn connect(&self, uri: &str, label: String) -> Result<Nip46Status, AppError> {
let parsed = Self::parse_connect_uri(uri)?; let parsed = Self::parse_connect_uri(uri)?;
eprintln!(
"[NIP46] parsed connect URI: peer={} relays={:?} secret={} perms={}",
parsed.peer.to_hex(),
parsed
.relays
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>(),
if parsed.secret.is_some() {
"present"
} else {
"absent"
},
if parsed.permissions.is_some() {
"present"
} else {
"absent"
},
);
// For NIP-46 Client the identity lives on the external signer, so local // Per NIP-46 ("client-keypair ... largely disposable ... delete it on
// profile is optional. Use ephemeral keys for the session, preferring // logout") the client keypair is ALWAYS ephemeral: it identifies this
// the local vault profile if available and unlocked. // session on the wire and must never be confused with the user's
let (keys, active_npub) = { // actual identity (learned later via `get_public_key`) or with a
// local vault key. Reusing a vault key here would also link the
// throwaway session to the long-term identity.
let keys = Keys::generate();
eprintln!(
"[NIP46] generated client keypair: client pubkey={}",
keys.public_key().to_hex()
);
let active_npub = {
let app = self.app.lock().await; let app = self.app.lock().await;
if let Some(npub) = app.vault.active_profile.clone() { app.vault.active_profile.clone()
if !app.is_locked() {
let vault_key = app.vault_key().copied();
if let Ok(secret_hex) =
profiles::resolve_secret_key(&app.vault, &npub, vault_key.as_ref())
{
if let Ok(secret_key) = profiles::parse_secret_key(&secret_hex) {
(Keys::new(secret_key), Some(npub))
} else {
(Keys::generate(), Some(npub))
}
} else {
(Keys::generate(), Some(npub))
}
} else {
(Keys::generate(), Some(npub))
}
} else {
(Keys::generate(), None)
}
}; };
// Check for permission broadening against stored connections for // Check for permission broadening against stored connections for
@ -523,6 +531,21 @@ impl Nip46ClientSigner {
secret.clone(), secret.clone(),
) )
.to_string(); .to_string();
// NOTE: the URI carries the connection secret — log the shape only,
// never the secret itself.
eprintln!(
"[NIP46] generated client keypair: client pubkey={}",
keys.public_key().to_hex()
);
eprintln!(
"[NIP46] connection secret: present ({} hex chars)",
secret.len()
);
eprintln!(
"[NIP46] nostrconnect URI generated ({} chars, {} relays); waiting for Amber response",
uri.len(),
relays.len()
);
{ {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
@ -579,6 +602,7 @@ impl Nip46ClientSigner {
.authenticator(SignerAuthenticator::new(keys.clone())) .authenticator(SignerAuthenticator::new(keys.clone()))
.build(); .build();
for url in &relays { for url in &relays {
eprintln!("[NIP46] relay connecting: {url}");
client client
.add_relay(url.to_string()) .add_relay(url.to_string())
.await .await
@ -599,6 +623,9 @@ impl Nip46ClientSigner {
} }
tokio::time::sleep(Duration::from_millis(250)).await tokio::time::sleep(Duration::from_millis(250)).await
}; };
for url in &connected {
eprintln!("[NIP46] relay connected: {url}");
}
if connected.is_empty() { if connected.is_empty() {
return Err("None of the relays answered".to_string()); return Err("None of the relays answered".to_string());
} }
@ -607,6 +634,10 @@ impl Nip46ClientSigner {
// signer replies to after scanning the QR. The author is unknown // signer replies to after scanning the QR. The author is unknown
// until the first event lands. // until the first event lands.
let our_pk = keys.public_key(); let our_pk = keys.public_key();
eprintln!(
"[NIP46] subscription created: kind=24133 #p={} (pairing listen)",
our_pk.to_hex()
);
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::NostrConnect) .kind(Kind::NostrConnect)
.tag(Tag::public_key(our_pk)); .tag(Tag::public_key(our_pk));
@ -642,6 +673,11 @@ impl Nip46ClientSigner {
&event.pubkey.to_hex()[..16], &event.pubkey.to_hex()[..16],
event.content.len() event.content.len()
); );
eprintln!(
"[NIP46] received kind 24133: author={} tags={:?}",
event.pubkey.to_hex(),
event.tags
);
// Local-only debug capture (never leaves this machine): keep the // Local-only debug capture (never leaves this machine): keep the
// full event so a failed handshake can be dissected offline. // full event so a failed handshake can be dissected offline.
// Loopback (e2e harness) pairings are excluded so test traffic // Loopback (e2e harness) pairings are excluded so test traffic
@ -677,7 +713,14 @@ impl Nip46ClientSigner {
continue; continue;
}; };
let plain = match nip44_decrypt(&conv, &event.content) { let plain = match nip44_decrypt(&conv, &event.content) {
Ok(p) => p, Ok(p) => {
eprintln!(
"[NIP46] decrypting response from {}: OK ({} plaintext bytes)",
event.pubkey.to_hex(),
p.len()
);
p
}
Err(e) => { Err(e) => {
// Surface the REAL failure (HMAC vs wrong key vs invalid // Surface the REAL failure (HMAC vs wrong key vs invalid
// padding): a malformed NIP-44 frame looks identical to a // padding): a malformed NIP-44 frame looks identical to a
@ -715,7 +758,9 @@ impl Nip46ClientSigner {
return Err(format!("The signer rejected the connection: {msg}")); return Err(format!("The signer rejected the connection: {msg}"));
} }
let result = shaped["result"].as_str().unwrap_or(""); let result = shaped["result"].as_str().unwrap_or("");
// Never log the secret values themselves — only the verdict.
if result != secret && result != "ack" { if result != secret && result != "ack" {
eprintln!("[NIP46] secret validation: FAIL (echo did not match)");
eprintln!( eprintln!(
"[nip46 pairing] connect response echoed the wrong secret — ignoring" "[nip46 pairing] connect response echoed the wrong secret — ignoring"
); );
@ -725,6 +770,12 @@ impl Nip46ClientSigner {
continue; continue;
} }
eprintln!("[nip46 pairing] connect response accepted (secret echo verified)"); eprintln!("[nip46 pairing] connect response accepted (secret echo verified)");
eprintln!("[NIP46] secret validation: PASS");
eprintln!(
"[NIP46] remote signer pubkey (response author): {}",
event.pubkey.to_hex()
);
eprintln!("[NIP46] connection established (pairing leg)");
if live_capture { if live_capture {
pairing_trace("connect response accepted (secret echo verified)"); pairing_trace("connect response accepted (secret echo verified)");
} }
@ -744,6 +795,10 @@ impl Nip46ClientSigner {
}; };
if request.method != "connect" { if request.method != "connect" {
// Anything before the handshake is premature — ignore. // Anything before the handshake is premature — ignore.
eprintln!(
"[NIP46] decrypted method: {} (pre-handshake — ignored)",
request.method
);
eprintln!("[nip46 pairing] pre-handshake '{}' ignored", request.method); eprintln!("[nip46 pairing] pre-handshake '{}' ignored", request.method);
if live_capture { if live_capture {
pairing_trace(&format!("pre-handshake '{}' ignored", request.method)); pairing_trace(&format!("pre-handshake '{}' ignored", request.method));
@ -765,6 +820,11 @@ impl Nip46ClientSigner {
return Err(format!("Could not answer the connect request: {e}")); return Err(format!("Could not answer the connect request: {e}"));
} }
eprintln!("[nip46 pairing] connect answered; awaiting identity handshake"); eprintln!("[nip46 pairing] connect answered; awaiting identity handshake");
eprintln!(
"[NIP46] decrypted method: connect (legacy request shape) from {}",
event.pubkey.to_hex()
);
eprintln!("[NIP46] connection established (pairing leg)");
if live_capture { if live_capture {
pairing_trace("connect answered; awaiting identity handshake"); pairing_trace("connect answered; awaiting identity handshake");
} }
@ -906,11 +966,19 @@ impl Nip46ClientSigner {
String, String,
> { > {
let filter = Filter::new().kind(Kind::NostrConnect).author(peer); let filter = Filter::new().kind(Kind::NostrConnect).author(peer);
eprintln!(
"[NIP46] subscription created: kind=24133 author={} (signer replies)",
peer.to_hex()
);
let notifications = client.notifications(); let notifications = client.notifications();
let subscription = client let subscription = client
.subscribe(filter) .subscribe(filter)
.await .await
.map_err(|e| format!("Could not subscribe: {e}"))?; .map_err(|e| format!("Could not subscribe: {e}"))?;
eprintln!(
"[NIP46] subscription registered: id={} — listening before any publish",
subscription.id()
);
Ok((notifications, subscription)) Ok((notifications, subscription))
} }
@ -1021,9 +1089,17 @@ impl Nip46ClientSigner {
fn fail(&self, message: impl Into<String>) { fn fail(&self, message: impl Into<String>) {
let message = message.into(); let message = message.into();
eprintln!("[nip46] session failed: {message}"); eprintln!("[nip46] session failed: {message}");
eprintln!("[NIP46] session failed: {message}");
pairing_trace(&format!("session failed: {message}")); pairing_trace(&format!("session failed: {message}"));
if let Ok(mut inner) = self.inner.try_lock() { if let Ok(mut inner) = self.inner.try_lock() {
// First failure wins: the demux loop exits with a generic
// "Connect handshake failed" AFTER the handshake task already
// recorded the specific cause — overwriting it would hide the
// useful error ("signer would not reveal its public key") behind
// a generic one in the UI.
if !matches!(inner.phase, Nip46Phase::Error(_)) {
inner.phase = Nip46Phase::Error(message); inner.phase = Nip46Phase::Error(message);
}
inner.task = None; inner.task = None;
inner.client = None; inner.client = None;
inner.conversation_key = None; inner.conversation_key = None;
@ -1166,14 +1242,46 @@ impl Nip46ClientSigner {
return Err(SigningError::Network { detail }); return Err(SigningError::Network { detail });
} }
eprintln!(
"[NIP46] requesting {}: id={} timeout={}s",
method,
&id[..8.min(id.len())],
timeout.as_secs()
);
match tokio::time::timeout(timeout, receiver).await { match tokio::time::timeout(timeout, receiver).await {
Ok(Ok(result)) => result.map_err(|detail| SigningError::Internal { detail }), Ok(Ok(result)) => {
match &result {
Ok(_) => eprintln!(
"[NIP46] received {} response: id={}",
method,
&id[..8.min(id.len())]
),
Err(detail) => eprintln!(
"[NIP46] received {} error response: id={} detail={}",
method,
&id[..8.min(id.len())],
detail
),
}
result.map_err(|detail| SigningError::Internal { detail })
}
Ok(Err(_)) => { Ok(Err(_)) => {
// The waiter was dropped (disconnect/fail) while awaiting. // The waiter was dropped (disconnect/fail) while awaiting.
eprintln!(
"[NIP46] {} id={}: waiter dropped (disconnect/fail)",
method,
&id[..8.min(id.len())]
);
Err(SigningError::NotConnected) Err(SigningError::NotConnected)
} }
Err(_) => { Err(_) => {
// Abandon the waiter so a late response finds nothing. // Abandon the waiter so a late response finds nothing.
eprintln!(
"[NIP46] {} id={}: TIMED OUT after {}s with no response",
method,
&id[..8.min(id.len())],
timeout.as_secs()
);
self.inner.lock().await.remote_pending.remove(&id); self.inner.lock().await.remote_pending.remove(&id);
Err(SigningError::Timeout) Err(SigningError::Timeout)
} }
@ -1194,11 +1302,17 @@ impl Nip46ClientSigner {
}; };
// Connect to relays // Connect to relays
eprintln!(
"[NIP46] generated client keypair already in session; peer={} relays={:?}",
uri.peer.to_hex(),
uri.relays.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
);
let client = Client::builder() let client = Client::builder()
.authenticator(SignerAuthenticator::new(keys.clone())) .authenticator(SignerAuthenticator::new(keys.clone()))
.build(); .build();
for url in &uri.relays { for url in &uri.relays {
eprintln!("[NIP46] relay connecting: {url}");
client client
.add_relay(url.to_string()) .add_relay(url.to_string())
.await .await
@ -1220,6 +1334,9 @@ impl Nip46ClientSigner {
} }
tokio::time::sleep(Duration::from_millis(250)).await tokio::time::sleep(Duration::from_millis(250)).await
}; };
for url in &connected {
eprintln!("[NIP46] relay connected: {url}");
}
if connected.is_empty() { if connected.is_empty() {
return Err("None of the relays answered".to_string()); return Err("None of the relays answered".to_string());
@ -1297,13 +1414,35 @@ impl Nip46ClientSigner {
}; };
let event = *incoming; let event = *incoming;
if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { // Log EVERY inbound event on this subscription: a silent `continue`
// here once hid live handshake traffic, so each drop names itself.
if event.kind != Kind::NostrConnect {
eprintln!(
"[NIP46] demux: ignoring kind {} from {} (want kind 24133)",
u16::from(event.kind),
event.pubkey.to_hex()
);
continue;
}
if event.pubkey != uri.peer {
eprintln!(
"[NIP46] demux: ignoring 24133 from {} (want author {})",
event.pubkey.to_hex(),
uri.peer.to_hex()
);
continue; continue;
} }
let plaintext = match nip44_decrypt(&conversation, &event.content) { let plaintext = match nip44_decrypt(&conversation, &event.content) {
Ok(p) => p, Ok(p) => p,
Err(_) => continue, Err(e) => {
eprintln!(
"[NIP46] demux: payload from {} did not decrypt: {}",
event.pubkey.to_hex(),
e.message()
);
continue;
}
}; };
// A payload carrying `result`/`error` is a response to a request // A payload carrying `result`/`error` is a response to a request
@ -1315,7 +1454,13 @@ impl Nip46ClientSigner {
serde_json::from_str(&plaintext).unwrap_or(serde_json::Value::Null); serde_json::from_str(&plaintext).unwrap_or(serde_json::Value::Null);
if shaped.get("result").is_some() || shaped.get("error").is_some() { if shaped.get("result").is_some() || shaped.get("error").is_some() {
let id = shaped.get("id").and_then(|v| v.as_str()).unwrap_or(""); let id = shaped.get("id").and_then(|v| v.as_str()).unwrap_or("");
let outcome = if shaped.get("error").is_some() && !shaped["error"].is_null() { let is_error = shaped.get("error").is_some() && !shaped["error"].is_null();
eprintln!(
"[NIP46] received response: id={} {} — routing to waiter",
id,
if is_error { "error" } else { "result" }
);
let outcome = if is_error {
Err(shaped["error"] Err(shaped["error"]
.as_str() .as_str()
.unwrap_or("The signer reported an error.") .unwrap_or("The signer reported an error.")
@ -1326,9 +1471,14 @@ impl Nip46ClientSigner {
let waiter = self.inner.lock().await.remote_pending.remove(id); let waiter = self.inner.lock().await.remote_pending.remove(id);
match waiter { match waiter {
Some(pending) => { Some(pending) => {
eprintln!("[NIP46] waiter found for id={} — delivering", id);
let _ = pending.sender.send(outcome); let _ = pending.sender.send(outcome);
} }
None => { None => {
eprintln!(
"[NIP46] stale/duplicate response: no waiter for id={} — dropped",
id
);
eprintln!("Ignoring NIP-46 response with no waiting request: {id}"); eprintln!("Ignoring NIP-46 response with no waiting request: {id}");
} }
} }
@ -1337,8 +1487,16 @@ impl Nip46ClientSigner {
let request: RawRequest = match serde_json::from_str(&plaintext) { let request: RawRequest = match serde_json::from_str(&plaintext) {
Ok(r) => r, Ok(r) => r,
Err(_) => continue, Err(e) => {
// Unreachable with the lenient parser, but named if hit.
eprintln!("[NIP46] demux: request parse failed: {e}");
continue;
}
}; };
eprintln!(
"[NIP46] demux: incoming request method={} id={}",
request.method, request.id
);
let response = if self.requires_approval(&request.method) { let response = if self.requires_approval(&request.method) {
self.gated_response(&keys, &request).await self.gated_response(&keys, &request).await
@ -1604,6 +1762,19 @@ impl Nip46ClientSigner {
} }
} }
/// Build the `connect` RPC params per NIP-46: the first param is the
/// REMOTE signer's pubkey (the URI authority), followed by the optional
/// connection secret. The client's own pubkey is already the event
/// author — sending it as params[0] is a spec violation that strict
/// signers (Amber) reject with silence, stalling the handshake.
fn connect_params(peer: PublicKey, secret: Option<&str>) -> Vec<String> {
let mut params = vec![peer.to_hex()];
if let Some(secret) = secret {
params.push(secret.to_string());
}
params
}
/// The connect handshake, run as its own task alongside the demux loop. /// The connect handshake, run as its own task alongside the demux loop.
/// ///
/// 1. Send `connect` (our client pubkey + the URI secret, resolved /// 1. Send `connect` (our client pubkey + the URI secret, resolved
@ -1654,24 +1825,27 @@ impl Nip46ClientSigner {
} }
}; };
let mut params = vec![{ let params = Self::connect_params(peer, secret.as_deref());
let inner = self.inner.lock().await; eprintln!(
inner "[NIP46] requesting connect: peer={} params=[remote_pubkey, {}] — awaiting signer ack",
.keys peer.to_hex(),
.as_ref() if secret.is_some() {
.ok_or("No session keys")? "secret:present"
.public_key() } else {
.to_hex() "secret:absent"
}]; },
if let Some(secret) = &secret { );
params.push(secret.clone());
}
// Await the ack (may wait on a human approving in Amber). // Await the ack (may wait on a human approving in Amber).
let ack = self let ack = self
.send_rpc("connect", params, HANDSHAKE_TIMEOUT, false) .send_rpc("connect", params, HANDSHAKE_TIMEOUT, false)
.await .await
.map_err(|e| format!("The signer refused the connection: {e}"))?; .map_err(|e| {
format!(
"The signer did not approve the connection ({e}). Keep Amber open in the foreground and try again."
)
})?;
eprintln!("[NIP46] connect ack received");
// Anti-spoofing: per NIP-46, a signer answering a nostrconnect:// // Anti-spoofing: per NIP-46, a signer answering a nostrconnect://
// (secret-carrying) handshake must echo the secret back. A mismatch // (secret-carrying) handshake must echo the secret back. A mismatch
@ -1679,11 +1853,13 @@ impl Nip46ClientSigner {
if let Some(expected) = &expected_secret { if let Some(expected) = &expected_secret {
let trimmed = ack.trim(); let trimmed = ack.trim();
if trimmed != expected.as_str() { if trimmed != expected.as_str() {
eprintln!("[NIP46] secret validation: FAIL (connect ack echo did not match)");
return Err( return Err(
"The signer did not echo the connection secret; refusing the connection." "The signer did not echo the connection secret; refusing the connection."
.to_string(), .to_string(),
); );
} }
eprintln!("[NIP46] secret validation: PASS (connect ack echo matched)");
} }
self.adopt_identity(peer).await self.adopt_identity(peer).await
@ -1724,17 +1900,49 @@ impl Nip46ClientSigner {
// the answer never comes. A retry lands while the signer is // the answer never comes. A retry lands while the signer is
// listening. Non-timeout failures (refusal, bad payload) still fail // listening. Non-timeout failures (refusal, bad payload) still fail
// fast. // fast.
eprintln!("[NIP46] requesting get_public_key (identity resolution)");
let mut attempt = 0u32; let mut attempt = 0u32;
let identity = loop { let identity = loop {
attempt += 1; attempt += 1;
// Fail fast with a USEFUL error when no relay is even connected:
// waiting out a 30s timeout here would hide a dead transport.
let connected_relays: Vec<String> = {
let inner = self.inner.lock().await;
match inner.client.as_ref() {
Some(client) => client
.relays()
.all()
.await
.into_iter()
.filter(|(_, relay)| relay.status().is_connected())
.map(|(url, _)| url.to_string())
.collect(),
None => Vec::new(),
}
};
if connected_relays.is_empty() {
return Err(
"Lost the relay connection while waiting for the signer. Reconnect and try again."
.to_string(),
);
}
eprintln!(
"[NIP46] get_public_key attempt {attempt}: {} relay(s) connected",
connected_relays.len()
);
match self match self
.send_rpc("get_public_key", vec![], REQUEST_TIMEOUT, false) .send_rpc("get_public_key", vec![], REQUEST_TIMEOUT, false)
.await .await
{ {
Ok(identity) => break identity, Ok(identity) => {
eprintln!("[NIP46] received get_public_key response");
break identity;
}
Err(e) => { Err(e) => {
if !matches!(e, SigningError::Timeout) || attempt >= 4 { if !matches!(e, SigningError::Timeout) || attempt >= 4 {
return Err(format!("The signer would not reveal its public key: {e}")); return Err(format!(
"The signer would not reveal its public key ({e}). Keep Amber open in the foreground with network access and try again."
));
} }
if live_relays(&connection.relays) { if live_relays(&connection.relays) {
pairing_trace(&format!( pairing_trace(&format!(
@ -1755,6 +1963,8 @@ impl Nip46ClientSigner {
let identity_npub = identity let identity_npub = identity
.to_bech32() .to_bech32()
.map_err(|e| format!("Could not encode identity npub: {e}"))?; .map_err(|e| format!("Could not encode identity npub: {e}"))?;
eprintln!("[NIP46] user pubkey: {identity_npub}");
eprintln!("[NIP46] updating account state: storing remote profile");
// NOTE: kind-0 metadata (display name / picture / nip05) is fetched // NOTE: kind-0 metadata (display name / picture / nip05) is fetched
// AFTER the session flips to Connected, as a background enrichment // AFTER the session flips to Connected, as a background enrichment
@ -1826,6 +2036,7 @@ impl Nip46ClientSigner {
)); ));
} }
drop(inner); drop(inner);
eprintln!("[NIP46] UI connection state updated: Connected ({identity_npub})");
// Background enrichment (post-Connected by design): look up the // Background enrichment (post-Connected by design): look up the
// identity's kind-0 metadata so the profile row carries the real // identity's kind-0 metadata so the profile row carries the real
@ -2219,6 +2430,35 @@ fn percent_decode(raw: &str) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod raw_request_tests { mod raw_request_tests {
use super::RawRequest; use super::RawRequest;
use nostr_sdk::prelude::Keys;
#[test]
fn connect_params_carry_remote_pubkey_first_per_spec() {
// NIP-46 ("connect | [<remote-signer-pubkey>, <optional_secret>, …]")
// names the REMOTE signer in params[0]. The client key is already the
// event author; sending it as params[0] stalled pasted connections
// against strict signers with zero feedback.
let peer = Keys::generate().public_key();
let params = super::Nip46ClientSigner::connect_params(peer, Some("s3cr3t"));
assert_eq!(params, vec![peer.to_hex(), "s3cr3t".to_string()]);
let params = super::Nip46ClientSigner::connect_params(peer, None);
assert_eq!(params, vec![peer.to_hex()]);
}
#[test]
fn connect_params_round_trip_through_library_codec() {
// Our outbound `connect` must parse under rust-nostr's own
// NostrConnectRequest::Connect codec — the same codec strict signers
// validate against.
use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest};
let peer = Keys::generate().public_key();
let params = super::Nip46ClientSigner::connect_params(peer, Some("s3cr3t"));
let req = NostrConnectRequest::from_message(NostrConnectMethod::Connect, params)
.expect("library must accept our connect params");
assert_eq!(req.method(), NostrConnectMethod::Connect);
assert_eq!(req.params()[0], peer.to_hex());
assert_eq!(req.params()[1], "s3cr3t");
}
#[test] #[test]
fn parses_amber_style_connect_with_object_params() { fn parses_amber_style_connect_with_object_params() {

View file

@ -335,12 +335,18 @@ async fn run_fake_amber(relay_url: String, comms: Keys, identity: Keys, approval
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_client_handshake_and_sign_against_fake_amber() { async fn nip46_client_handshake_and_sign_against_fake_amber() {
// Isolated vault so the test never touches the real user vault. // Isolated vault so the test never touches the real user vault.
// XDG_DATA_HOME is process-global and both tests in this binary set it, // XDG_DATA_HOME is process-global and every test in this binary sets it,
// so vault setup + App::load are serialized. // so the lock is held for the WHOLE test: data_dir() re-reads the env on
// every save, and a setup-only guard lets parallel tests cross-write.
let _vault_guard = VAULT_ENV_LOCK.lock().unwrap();
let app = { let app = {
let _guard = VAULT_ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id())); let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id()));
// data_dir() is $XDG_DATA_HOME/keynectr — the isolation vault must // data_dir() is $XDG_DATA_HOME/keynectr — the isolation vault must
// live there. Writing it one level too shallow left the app finding // live there. Writing it one level too shallow left the app finding
@ -478,6 +484,230 @@ async fn nip46_client_handshake_and_sign_against_fake_amber() {
let _ = PublicKey::from_hex; // keep import used across cfg variations let _ = PublicKey::from_hex; // keep import used across cfg variations
} }
// ---------------------------------------------------------------------------
// Strict Amber for the bunker:// (paste-URI) flow: validates the `connect`
// request against NIP-46 instead of acking anything. params[0] MUST be the
// remote signer's pubkey (the URI authority) — the client's own pubkey there
// is a spec violation that real signers answer with silence, stalling the
// handshake with zero feedback. This test FAILS on the old param order and
// passes on the fixed one.
// ---------------------------------------------------------------------------
/// Run Amber in strict mode: enforce the NIP-46 `connect` shape, then behave
/// like the lenient fake (delayed approval ack, real identity, remote sign).
async fn run_strict_amber(
relay_url: String,
comms: Keys,
identity: Keys,
approval_delay: Duration,
) {
let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url)
.await
.expect("strict amber connect");
ws.send(Message::Text(
json!(["REQ", "strict-amber", {"kinds": [24133]}])
.to_string()
.into(),
))
.await
.unwrap();
let comms_pub = comms.public_key();
let comms_hex = comms_pub.to_hex();
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;
};
if ev.pubkey == comms_pub {
continue;
}
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" => {
let first_param = req
.get("params")
.and_then(|p| p.get(0))
.and_then(|v| v.as_str())
.unwrap_or("");
if first_param != comms_hex {
json!({"id": id, "error": "connect params must start with the remote signer pubkey"})
} else {
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();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_bunker_connect_params_match_spec_against_strict_amber() {
// Whole-body env lock: data_dir() re-reads XDG_DATA_HOME on every save.
let _vault_guard = VAULT_ENV_LOCK.lock().unwrap();
let app = {
let tmp = std::env::temp_dir().join(format!("keynectr-e2e-strict-{}", std::process::id()));
let app_dir = tmp.join("keynectr");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
app_dir.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")));
assert!(
app.try_lock().unwrap().vault.profiles.is_empty(),
"e2e vault isolation failed: a non-empty vault was loaded"
);
app
};
// `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_strict_amber(
relay_url.clone(),
comms.clone(),
identity.clone(),
Duration::from_millis(200),
));
let signer = Nip46ClientSigner::new(app.clone());
// No secret in the URI: the strict signer must still ack a well-formed
// connect whose params[0] is its own pubkey.
let uri = format!(
"bunker://{}?relay={}",
comms.public_key().to_hex(),
relay_url
);
let status = signer
.connect(&uri, "strict amber".to_string())
.await
.expect("connect");
assert!(!status.connected, "must not be connected before handshake");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
let status = signer.status().await;
if let Some(err) = &status.error {
panic!("strict handshake failed: {err}");
}
if status.connected {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"strict handshake never completed; last status: {:?}",
signer.status().await
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Identity must be the REAL key from get_public_key — the actual user
// pubkey the account manager stores — never the URI comms key and never
// the client's own ephemeral key.
let resolved = SignerTrait::get_public_key(&signer)
.await
.expect("identity resolved");
assert_eq!(resolved, identity.public_key());
assert_ne!(resolved, comms.public_key());
let identity_npub = identity.public_key().to_bech32().unwrap();
let app_guard = app.lock().await;
assert!(
app_guard
.vault
.profiles
.iter()
.any(|p| p.public_key == identity_npub && p.secret_key.trim().is_empty()),
"remote profile row for the real identity must be stored with no secret"
);
assert_eq!(
app_guard.vault.active_profile.as_deref(),
Some(identity_npub.as_str()),
"the connected account must become the active profile"
);
drop(app_guard);
signer.disconnect().await.ok();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// QR pairing (client-initiated nostrconnect://): the fake signer plays the // QR pairing (client-initiated nostrconnect://): the fake signer plays the
// scanner role — it reads the pairing token the GUI would render, sends the // scanner role — it reads the pairing token the GUI would render, sends the
@ -625,6 +855,11 @@ async fn run_fake_scanner(
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_qr_pairing_handshake_and_sign() { async fn nip46_qr_pairing_handshake_and_sign() {
run_qr_pairing("request").await; run_qr_pairing("request").await;
} }
@ -633,16 +868,24 @@ async fn nip46_qr_pairing_handshake_and_sign() {
/// what Amber sends — is a connect *response* (`{"id","result":"<secret>"}`), /// what Amber sends — is a connect *response* (`{"id","result":"<secret>"}`),
/// not a `connect` request. Pairing must complete on that shape too. /// not a `connect` request. Pairing must complete on that shape too.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
/// Serializes the e2e tests against each other (see the whole-body `_vault_guard`
/// below). `await_holding_lock` is allowed here deliberately: the guard is a
/// test-only serialization lock, never nested, never shared with production
/// code — holding it across awaits is the entire point.
#[allow(clippy::await_holding_lock)]
async fn nip46_qr_pairing_connect_response_shape() { async fn nip46_qr_pairing_connect_response_shape() {
run_qr_pairing("response").await; run_qr_pairing("response").await;
} }
/// Whole-body env lock like the test fns above (test-only, never nested).
#[allow(clippy::await_holding_lock)]
async fn run_qr_pairing(connect_shape: &'static str) { async fn run_qr_pairing(connect_shape: &'static str) {
let relay_url = start_relay().await; let relay_url = start_relay().await;
// Isolated vault + pairing relay, serialized with the other e2e test. // Isolated vault + pairing relay; the env lock is held for the whole
// helper body (see above) so parallel tests cannot cross-write vaults.
let _vault_guard = VAULT_ENV_LOCK.lock().unwrap();
let app = { let app = {
let _guard = VAULT_ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!( let tmp = std::env::temp_dir().join(format!(
"keynectr-e2e-pair-{}-{}", "keynectr-e2e-pair-{}-{}",
std::process::id(), std::process::id(),