The pairing path minted its profile under the backend's 'Remote Signer'
default, so several test pairings left indistinguishable rows. The
'Sign in with a signer app (Amber)' button now shows a prefilled
('Amber') Connection name step first; the QR step starts only from
there and the typed name reaches nip46_pair_start as the profile label.
Enter submits directly (input focused, prefilled).
Also cleaned the live vault (not in git): dropped 2 stale 'Remote
Signer' connections and the secret-less 'Dev' remote stub tied to them,
plus 13 orphaned connection_secrets entries; kept the live Amber
pairing (npub1qn0w4a…, its connection + client key) and Testing123.
frontend: 125 tests passed (label step covered: prefill, typed label
reaches nip46_pair_start), typecheck/lint/format/electron:build/build
green. Rust untouched.
The Amber pairing QR existed but only behind the sidebar's Signer Mode,
so clicking 'Add profile' expectedly led to a local-key form and users
never found the signer flow. The modal now opens as a choice:
- 'Sign in with a signer app (Amber)' — mints the nostrconnect:// QR
inline, polls signer status every 2s (same channel as Signer Mode),
and flips to an 'Amber is now your signer!' confirmation once the
handshake lands; cancelling mid-pairing aborts it cleanly.
- 'Create a new key on this computer' — the previous local-key form,
unchanged, with a Back step.
frontend: 125 tests passed (CreateProfileModal suite rewritten to cover
choice, QR start, connected poll, cancel-abort; App.test updated for the
new dialog title), typecheck/lint/format:check/electron:build/build green.
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).
Live pairing (Sep 23) proved the signer round-trip works: connect,
get_public_key and the first sign_event all succeeded against real
Amber. Subsequent signs failed because the client half used the 30s
ordinary-RPC leash for sign_event, while every sign waits on a human
approving the prompt on the signer's device. The log shows a valid
signature arriving ~61s after publication, after the waiter had been
removed: 'stale/duplicate response: no waiter ... dropped' — the user
watched failures while Amber was signing correctly.
sign_event now uses a 120s SIGN_TIMEOUT (same leash as the connect
handshake); get_public_key keeps the 30s retry-cadence timeout.
The 09:44 live scan proved the failure with the new accepted_by trace:
our get_public_key was published and accepted, but Amber's own connect
echo was created one second LATER — Amber's subscription to our client
key opens milliseconds after we publish, so the first identity request is
already in the relays' past when its listener starts (relays never replay
history to a fresh subscription). The signer-side race is structural and
unfixable from our subscription ordering; adopt_identity now retries
get_public_key up to 4x with 3/8/15s backoff (non-timeout errors still
fail fast), so a later attempt lands while the signer is listening.
pairing_relays() narrowed to primal + nos.lol — the only two relays with
proven bidirectional ephemeral-24133 traffic in today's scans. damus.io
503'd reads and silently dropped events; snort persists nothing; user
settings switched to the two proven relays as well.
The Sep 23 08:59 live scan died at get_public_key with the subscribe race
already fixed, and a post-hoc relay sweep found neither our request nor
Amber's connect reply on any healthy pairing relay — the session lived on
damus.io (503-flapping) and nostr.band (handshake-hanger, still enabled in
user settings and therefore merged into the pairing set). publish_payload
now returns the accepting-relay list and send_rpc traces it for live
sessions (rpc published: method=… accepted_by=…, NONE when no relay took
the event), so the next scan names the dead relay instead of guessing.
The relay pushes events only to subscriptions that exist at delivery time.
Both connect flows spawned the handshake task before run_demux registered the
kind-24133 author subscription, so a fast signer reply (the live Sep 23 Amber
scan: clean connect + secret echo, then get_public_key) landed in the gap and
was silently dropped — the identity RPC timed out 30s later and the session
died with 'The signer would not reveal its public key' after the connection
was already stored, leaving the UI signed in with no profile.
Both run_sign_task (bunker flow) and run_paired (QR pairing) now subscribe
first via subscribe_to_signer and hand the stream + subscription to run_demux.
futures-util promoted from dev-dependency to runtime. cargo test 209+3e2e
green, clippy 0, fmt clean, release rebuilt.
- Home first-run now has three entry points: create a new profile, I already
have an account (opens ImportProfileModal), and Sign in with a signer
(navigates to Signer Mode where Amber/NIP-46 pairing lives).
- Copy states the per-mode truth: local-vault keys vs remote signer where the
private key never lives on this device.
- Tests updated to assert all three entry points; 116 frontend tests green.
Live write+readback canary (Sep 22): purplepag.es rejects kind 24133
outright ('blocked: kind 24133 is not allowed') and relay.nostr.band
hangs the WebSocket handshake. A signer (Amber) that picks a blocking
relay reports 'connected' while its connect event is silently discarded
— exactly the observed failure. The Sep 22 18:01 trace shows a pairing
window that opened, never saw an inbound 24133, and timed out; a
post-hoc relay sweep found zero 24133 events tagged to the ephemeral
key on any relay, consistent with Amber's write being rejected.
Replaced with nos.lol and relay.snort.social, both verified to accept
and serve ephemeral kind-24133. Added a regression test pinning the
blockers out of the set.
External NIP-46 signer mode is a stronger posture, not a caveat: the key
never arrives on this machine, so a compromised desktop cannot extract it.
The old claim only holds for embedded/bunker modes and undersold the
external-signer option. UI already ranked modes correctly; no code change.
The trace log's "identity adopted - CONNECTED" line fired in the e2e
harness too (adopt_identity had no relay gate), so every test run
appended fake CONNECTED entries to the forensics log. Three such lines
landed there during today's cron verification and had to be manually
distinguished from a real Amber scan. Gate the line on live_relays()
(same loopback test the capture already uses) and add a "paired:
connection stored" trace line at the QR-pairing handover so a stall
between connect-echo and get_public_key is visible in the trace.
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).
The Sep 16 forensics file pairing-capture.jsonl turned out to be
polluted: the e2e harness pairs over loopback relays but its fake
scanner events were appended to the same file (lines 6-7 from today's
test runs). Now loopback pairings skip both the capture file and the
new pairing-trace.log.
pairing-trace.log records the full pairing outcome at every decision
point (started, inbound, decrypt-fail, not-a-request, pre-handshake,
connect answered, identity adopted, session failed) with timestamps,
because backend stderr only reaches the Electron console and /tmp logs
get cleaned — a failed live handshake previously left no durable trace.
Release binary rebuilt at this commit.
RawRequest still dropped real Amber connect requests after aedde8f:
tonight's re-scan (20:11) hit the lenient-params build and the payload
parsed no better. The strict derive rejected non-string ids, missing
ids, object-shaped params, and double-encoded request strings. Replace
the derived Deserialize with a coercion-based one: any valid JSON
deserializes, every scalar becomes text, a JSON-string-wrapped object
is unwrapped, and only truly non-JSON reaches the log path — now
dumping the exact payload plus the real decrypt error (HMAC vs padding
vs wrong key) instead of a generic 'wrong conversation key'.
RawRequest used a strict Vec<String>, so Amber's connect request —
whose params[1] is the requested_perms grant object — failed to parse
and was silently dropped. The signer saw a live session; we saw
nothing. Params now coerce non-string values to their JSON text, with
regression tests for both shapes.
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.