One live NIP-46 session, many saved ones (Option A):
- start_pairing/connect while a session is live PARKS it instead of
refusing: row, pairing secret, and persisted client key stay intact,
so the parked account is restorable with no fresh scan.
- SelectProfile follows the switch: target has a restorable connection ->
park current + re-dial target's row (expected_identity guard applies);
target is local-key or unpaired -> live session untouched.
- New nip46_cancel_pairing IPC: aborts ONLY an in-flight pairing and
re-dials the parked session, so cancel-after-park is transparent.
The QR cancel paths (Add-profile modal, Signer Mode screen) use it —
plain disconnect would revoke the parked connection.
- e2e: two fake Ambers on one relay; A pairs, B's pairing parks A
(revoked_at none, client key resolvable), switch back re-dials A and
signs; no-op switch; local profile leaves session alone; B restorable.
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.
The suite failed intermittently (1 in ~4 runs) with 3-5 simultaneous
failures. Two stacked causes:
1. REAL flake: nip46_bunker_connect_params_match_spec_against_strict_amber
published its kind-0 through the DEFAULT relay set — the real internet
(wss://relay.damus.io + the known-hanging relay.nostr.band) — so
'at least one relay must accept the signed kind-0' was a network
lottery against the 6s send timeout. The QR test already pins settings
to the in-process relay; the strict test shipped without it. Now pinned
too: zero network dependency, deterministic.
2. CASCADE: a panic while holding VAULT_ENV_LOCK poisoned the mutex, so
every later test died on PoisonError and one flake reported as many.
The lock only serializes process-global XDG_DATA_HOME, so all four
sites now unwrap_or_else into_inner — a real failure reports as ONE.
10/10 consecutive green e2e runs (was ~1 in 4 failing); suite time now
uniform ~21.5s (was bimodal — the long tail was network waiting).
cargo test 216 unit + 5 e2e green; clippy 0 warnings; fmt clean.
Amber remembers our client pubkey for the life of a connection, so the
client secret key minted at pairing is now persisted in the vault
(encrypted like connection secrets, keyed by the same VaultRef, re-keyed
to the identity ref when the handshake resolves it). A restart re-dials
the saved session with the exact keypair, re-sends connect, and enforces
expected_identity in adopt_identity: a signer answering as a different
account is refused, never adopted. Restore hooks run at serve() for
unencrypted vaults and after UnlockVault; failures never block the GUI.
Legacy rows without a stored client key are skipped (one fresh scan
makes them restorable).
After each e2e App::load(), assert the loaded vault is empty. An
isolated run that loads any profile can only mean load_vault() fell
through to legacy migration (real repo vault with user keys). The bug
fixed in the previous commit passed green for weeks precisely because
nothing checked this; the guard makes it fail loudly and immediately.
Both e2e vault setups wrote the isolation vault to $XDG_DATA_HOME/
profiles_vault.json, but vault::data_dir() is $XDG_DATA_HOME/keynectr —
so the app never found the seeded vault and load_vault() fell through to
try_migrate_legacy_vault(), which picked up the legacy repo vault
(CARGO_MANIFEST_DIR/profiles_vault.json — the user's real profile with a
plaintext secret key) and migrated it into the test process.
Forensics: two legacy-vault backups appeared Sep 19 20:43:54 + 20:44:30,
exactly the cargo-test runs around the edd4e56 commit, proving every e2e
run was migrating the user's real legacy vault. The pairing-trace
"identity adopted" lines from that window were e2e sessions, not a live
Amber scan (no "pairing started" line, no new capture events — session-
level traces are not gated by live_capture).
Fix: seed the vault at tmp/keynectr/profiles_vault.json. Verified green
after the change: repo legacy vault byte-identical, backup count
unchanged, 3/3 e2e pass.
For a client-initiated nostrconnect:// scan, NIP-46 says the signer
sends a connect RESPONSE event — {"id",…,"result":"<secret>"} — not a
connect request: the secret echo IS the handshake, nothing to answer.
run_pairing_task only recognized an inbound {"method":"connect"}
*request*; a bare {"result":…} fell through as 'not a NIP-46 request'
(method empty) or 'pre-handshake ignored', so Amber's approval was
silently dropped and no profile row was ever created.
Now the pairing loop verifies the echoed secret (or 'ack') directly and
proceeds to identity adoption; a signer-sent error fails fast with the
signer's message. Wrong-secret responses are ignored as spoofing, same
as before. The legacy request shape keeps working.
e2e: run_fake_scanner takes a connect_shape; new
nip46_qr_pairing_connect_response_shape pins the Amber shape end-to-end
(fails on the old parser, green on the new one).
Amber picks whichever relay it likes from the nostrconnect:// URI and
some relays drop ephemeral kind-24133 traffic, so pairing could appear
successful on the signer while nothing reached us. The pairing set is
now the user's relays UNION a curated fallback of well-known relays;
loopback-only sets (e2e harness) skip widening to keep tests isolated.
Keynctr is the NIP-46 client; Amber is the scanner. Amber hands out no
link — it scans one — so the signer screen now mints a pairing token:
- start_pairing(): ephemeral key + secret, nostrconnect:// token via
NostrConnectUri::client_with_secret, status().pairing_uri for the GUI
- run_pairing_task(): listens for the signer's connect request, echoes
the secret (anti-spoofing), persists the connection row + secret,
then adopts identity via get_public_key and hands to the demux loop
- pairing subscription is closed at handoff so the demux loop owns the
conversation (relay could otherwise deliver signer replies under the
stale pairing sub id where nobody routes them)
- IPC: nip46_pair_start; status carries pairing_uri
- SignerModeScreen: 'Show QR' button, QR render (qrcode) of the token,
copy-link fallback, cancel; paste-link flow unchanged
- e2e: fake QR scanner consumes the real pairing token end-to-end
(scan -> secret echo -> identity -> sign -> vault persistence)
- 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