fix(nip46): restored sessions no longer demand a connect secret re-echo

Live Amber sign-in (Sep 25) paired fine, but every restart died with
'the signer did not echo the connection secret': the restore re-sends
connect with the ORIGINAL pairing secret, and an already-approved
signer legitimately answers 'true' without re-echoing — NIP-46 reserves
the echo for proving possession during the INITIAL pairing, and Amber
proved it once. Requiring it on re-dial made session restore fail
100% against real Amber (the e2e missed it because its fake answered a
plain ack with no secret in play).

ConnectUri gains a 'restore' flag (set only by reactivate_saved_sessions).
Fresh handshakes still fail closed on a wrong echo. The restore path's
anti-spoofing is expected_identity in adopt_identity — a peer answering
as any other account is refused, and only the real key holder can
decrypt traffic on the stored conversation key. Log now says
'secret validation: SKIPPED (restored session)' and proceeds.

e2e: run_fake_amber now mirrors Amber's ack shapes (plain ack on first
pairing; 'true' when the client re-presents a secret) and the restore
test seeds a pairing secret so it exercises exactly the live failure —
it fails without the fix and passes with it.

cargo test 216 unit + 5 e2e green; clippy --all-targets 0 warnings;
fmt clean; release rebuilt.
This commit is contained in:
Avi 2026-09-25 16:19:19 -05:00
commit 01ce5de417
2 changed files with 63 additions and 5 deletions

View file

@ -284,9 +284,18 @@ async fn run_fake_amber(relay_url: String, comms: Keys, identity: Keys, approval
let response: Value = match method {
"connect" => {
// Simulate a human tapping "approve" in Amber.
// Simulate a human tapping "approve" in Amber. Amber's
// ack SHAPE depends on the connection state: a first-time
// pairing (no secret in params) gets a plain ack; an
// ALREADY-APPROVED connection re-dialing with the stored
// secret gets `true` WITHOUT a secret echo (live Amber,
// Sep 25 — the client must not demand an echo there).
tokio::time::sleep(approval_delay).await;
json!({"id": id, "result": "ack"})
if req["params"].as_array().is_some_and(|p| p.len() > 1) {
json!({"id": id, "result": true})
} else {
json!({"id": id, "result": "ack"})
}
}
"get_public_key" => json!({"id": id, "result": identity.public_key().to_hex()}),
"sign_event" => {
@ -1189,6 +1198,18 @@ async fn nip46_session_restore_redials_and_refuses_wrong_identity() {
// (The old instance's listener task is left running on purpose — the
// live process exiting is modeled by the new instance, not by
// `disconnect()`, which revokes and wipes the stored key.)
//
// Seed a pairing secret at the identity ref first: a QR pairing stores
// one, and the restore re-sends it — real Amber then answers `true`
// WITHOUT echoing (already-approved connection), which the fake models.
// Without the restore-mode skip this handshake fails "did not echo the
// connection secret" (the exact live Sep 25 failure).
{
let mut g = app.lock().await;
keynectr::vault::store_connection_secret(&mut g.vault, None, &ref_id, "restore-secret-123")
.unwrap();
g.save_vault().unwrap();
}
let signer2 = Nip46ClientSigner::new(app.clone());
let restored = signer2
.reactivate_saved_sessions()