diff --git a/.gitignore b/.gitignore index bb01fa6..f3a88e5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ profiles_vault.json profiles_vault.json.backup-* *.json.tmp +# Editor / tool artifacts +.directory +.opencode/ +.impeccable/ + # Frontend frontend/node_modules/ frontend/dist/ diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 352d9bf..f4cb723 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,3 +1,1611 @@ +# Checkpoint — auto-naming hardened (2026-09-25 eve) + +## What changed since the label-step checkpoint +- UI: pairing label step REMOVED (user friction: prefilled 'Amber' needed + letter-by-letter deletion). One click 'Sign in with a signer app (Amber)' + → QR. Seed label 'Amber'; real name comes from kind-0. (fc2fe93) +- Backend: auto-name enrichment now retries 4x/20s apart with 75s budget + and per-attempt pairing_trace lines (bc736ff), after live proof that + nos.lol — the ONLY relay holding this account's kind-0 ('web5osint') — + intermittently 502s EVERY client (any UA; nostr-sdk and raw websockets + alike; worked 16:29, dead 16:40+). Single-shot fetch + flaky relay = + permanently generic label; retries fix that. + +## Verified +- fetch_profile_metadata bisected per relay: only nos.lol has the kind-0; + all others return None (account metadata was only ever published there). +- 216 unit + 5 e2e green; clippy 0; fmt clean; release rebuilt bc736ff. +- frontend 125 tests green at fc2fe93. + +## Open +- Profile will auto-rename to 'web5osint' on the next successful restore + once nos.lol recovers (retry loop logs 'auto-name: kind-0 fetched'). +- Consider publishing the account's kind-0 to primal/damus too so naming + doesn't depend on one relay (needs one Amber signature; user action). +- Stray untracked files from another session left alone: COSMIC_THEME.md, + KeynectrAppIconPossibility02.jpeg, deferred/SignerConnectionPanel.tsx.wip/ + +# Checkpoint — restore secret-echo fix LIVE-VERIFIED (2026-09-25 late PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`01ce5de`** ("fix(nip46): restored sessions no + longer demand a connect secret re-echo"). Previous: `a809a67` (label + step), `5b13162` (Add-profile choice), `a6a4e6f` (flake kill). +- SESSION RESTORE LIVE-VERIFIED against real Amber at 01ce5de: log shows + restoring session -> secret validation SKIPPED (restore) -> + get_public_key -> identity check PASS -> Connected, no scan. (Amber + answered a plain 'ack' here, not 'true' — the restore path now accepts + any ack shape and trusts the re-proved identity.) +- Remaining live proof: one publish/note approved in Amber within the + 120s leash (fresh pairing at 15:37 already exercised sign? — the + last_publish.json entry from 10:57 was BEFORE the fresh pairing; the + fresh profile has not published yet). + +## What was completed +1. **Restore secret-echo fix (`01ce5de`)**: session restore failed 100% + against real Amber — the re-dial resent the pairing secret and the + client demanded an echo, but an already-approved signer legitimately + answers without re-echoing (echo = initial-pairing possession proof + only). ConnectUri gained a `restore` flag (only reactivate_saved_sessions + sets it): restore skips the echo and relies on expected_identity in + adopt_identity; fresh pairings still fail closed on a wrong echo. + e2f model updated: run_fake_amber now mirrors Amber ack shapes and + the restore test seeds a pairing secret (fails without the fix). + cargo test 216 unit + 5 e2e green; clippy 0; fmt clean; release + rebuilt at 01ce5de; backend restarted and LIVE restore verified. + +--- + +# Checkpoint — signer pairing label step + vault prune (2026-09-25 PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`a809a67`** ("feat(ui): name the signer + connection before the QR; prune stale vault rows"). Previous: + `5b13162` (Add-profile choice), `a6a4e6f` (e2e flake kill). +- LIVE PAIRING CONFIRMED (Sep 25): user signed in with real Amber + through the new Add-profile flow; active remote profile + npub1qn0w4a2hm9f…, client key persisted (restorable). Remaining + live proof: one publish/approval + one restart re-dial. +- Live vault pruned (not in git): 3 conns -> 1 (live Amber only), + stale 'Dev' remote stub + 2 stale 'Remote Signer' rows removed, + 14 connection_secrets -> 1, client keys kept for the live conn. + +# Checkpoint — Add-profile now offers Amber signer sign-in (2026-09-25) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`5b13162`** ("feat(ui): Add profile now offers + 'Sign in with a signer (Amber)' first"). Previous: `a6a4e6f` (e2e + flake kill), `0982dad` (session restore). +- Live settings: relay list expanded to 6 (primal, nos.lol, damus, + snort, mutinywallet, nostr.band) in ~/.local/share/keynectr/ + settings.json (backup: settings.json.backup-prerelays-20260924). + Code default relay set and the curated pairing set unchanged. +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Frontend `dist/` rebuilt at 5b13162 — reload the Electron window + (Ctrl+R) or relaunch to see the new Add-profile flow. Backend + release binary unchanged since a6a4e6f. + +## What was completed this session +1. **Add-profile choice (5b13162)**: clicking "Add profile" now opens a + choice modal: "Sign in with a signer app (Amber)" shows the + nostrconnect:// pairing QR inline, polls signer status every 2s, and + confirms "Amber is now your signer!" when the handshake lands + (cancel aborts mid-pairing). "Create a new key on this computer" + keeps the old local-key form with a Back step. Previously the Amber + QR was only reachable via the sidebar Signer Mode screen, so the + expected entry point hid the main flow. +- Verification: frontend 125 tests passed (CreateProfileModal suite + rewritten: choice step, QR start, connected-poll confirmation, + cancel-abort; App.test dialog title updated), `npm run typecheck`, + `npm run lint`, `npm run format:check`, `npm run electron:build`, + `npm run build` all green. Rust untouched. + +## Commits added (newest first) +- `5b13162` feat(ui): Add profile now offers 'Sign in with a signer (Amber)' first + +--- + +# Checkpoint — e2e flake killed (2026-09-24 evening) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`a6a4e6f`** ("test(nip46): kill the e2e flake — + local relay for strict kind-0, poison-tolerant vault lock"). Previous: + `0982dad` (session restore), `73bf17c` (prior checkpoint). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `a6a4e6f`** (test-only commit; same code + as `0982dad` for the app itself). Restart Electron before retesting. + +## What was completed this session +1. **Root-caused and killed the intermittent e2e failure** that showed + up as 3–5 simultaneous test failures roughly 1 run in 4: + - REAL flake: `nip46_bunker_connect_params_match_spec_against_strict_amber` + published its kind-0 through the DEFAULT relay set — the real + internet (damus + the known-hanging nostr.band) — so "at least one + relay must accept the signed kind-0" was a network lottery against + the 6s send timeout. The test now pins settings to the in-process + relay like the QR test always did. Suite has zero network + dependency now. + - CASCADE amplifier: a panic while holding the test-only + `VAULT_ENV_LOCK` poisoned the mutex, so every later test died on + `PoisonError`. All four lock sites are now poison-tolerant + (`unwrap_or_else(into_inner)`) — a real failure reports as ONE. +- Verification (all green at `a6a4e6f`): **10/10 consecutive green + e2e runs** (was ~1 in 4 failing), runtime now uniform ~21.5s (was + bimodal — long tail was network waiting). `cargo test` 216 unit + 5 + e2e passed, `cargo clippy --all-targets` 0 warnings, `cargo fmt + --check` clean, `cargo build --release` green. Frontend untouched. + +## Commits added (newest first) +- `a6a4e6f` test(nip46): kill the e2e flake — local relay for strict kind-0, poison-tolerant vault lock + +## How to resume / reproduce +- Restart Electron (new release binary). With the vault already + unlocked/unencrypted, the backend logs `[NIP46] restoring session: + peer=... as npub1...` then `identity check on restored session: + PASS` and the profile goes Connected without showing Amber a new + scan. CLI trace: `~/Tools/keynctr-debug/` logs. +- Flake regression check: `for i in 1..10; do cargo test --test + nip46_e2e; done` — all runs must pass in ~21–22s. +- Then do the still-pending live proof: Publish name / publish a note + and approve in Amber within 2 minutes (120s leash from `f53bc56`). + +## Outstanding / next steps +1. **Live sign/publish through real Amber** (covers the 120s leash and + the restored session in one shot). +2. Prune the 11 stale profileless `nip46_connections` rows (cosmetic; + restore already skips them). + +--- + +# Checkpoint — NIP-46 session restore on startup (2026-09-24) + +## Where things are +- Branch `master` @ `0982dad`; see git for hashes. Details below are + as written when `0982dad` was HEAD. + +## What was completed this session +1. **Session restore without a fresh scan (`0982dad`)**: the WIP from + the Sep-23 session (item 2 of the previous next-steps) is finished + and committed. Amber remembers our *client pubkey* as the identity + of an approved connection for its whole life, so the client keypair + minted at pairing is now persisted in the vault + (`Vault::connection_client_keys` — encrypted under the vault key + exactly like connection secrets, keyed by the same `VaultRef`, and + re-keyed to the identity ref when the handshake resolves it). +2. **Re-dial path**: `reactivate_saved_sessions()` picks the active + profile's live connection (or any other live one), rebuilds the + exact wire identity, re-derives the NIP-44 conversation key, and + re-sends `connect` — no QR/bunker scan. It is called at `serve()` + for unencrypted vaults and after `UnlockVault` for encrypted ones; + a restore failure can never block the GUI. +3. **Cross-account guard**: restored sessions pin `expected_identity` + before dialing; `adopt_identity` refuses (never adopts) a signer + that answers as a different account — different Amber account, + mistyped bunker, or relay spoof all fail closed. +4. Legacy connection rows without a stored client key are skipped + (they need one fresh scan, after which they become restorable too). +- Verification (all green at `0982dad`): `cargo test` **216 unit + 5 + e2e passed / 0 failed** — incl. the new + `nip46_session_restore_redials_and_refuses_wrong_identity` e2e + (fresh pairing → simulated restart → re-dial connects → a fake + Amber answering as a different key is refused) and 3 new vault unit + tests (plaintext roundtrip, encrypted fail-closed, legacy-vault + parsing). `cargo clippy --all-targets` 0 warnings, `cargo fmt + --check` clean, `cargo build --release` green. Frontend untouched. + +## Commits added (newest first) +- `0982dad` feat(nip46): restore saved signer sessions on startup/unlock — no fresh scan + +## How to resume / reproduce +- Restart Electron (new release binary). With the vault already + unlocked/unencrypted, the backend logs `[NIP46] restoring session: + peer=... as npub1...` then `identity check on restored session: + PASS` and the profile goes Connected without showing Amber a new + scan. CLI trace: `~/Tools/keynctr-debug/` logs. +- Then do the still-pending live proof: Publish name / publish a note + and approve in Amber within 2 minutes (120s leash from `f53bc56`). + +## Outstanding / next steps +1. **Live sign/publish through real Amber** (covers both the 120s + leash and the restored session in one shot). +2. Prune the 11 stale profileless `nip46_connections` rows (cosmetic; + restore already skips them). +3. Remote picture/nip05 edits, KDF upgrade (Step 5), rename pass + (Step 7) — unchanged. + +--- + +# Checkpoint — sign_event given the human-approval timeout leash (2026-09-23 evening) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`f53bc56`** ("fix(nip46): give sign_event the + human-approval timeout leash"). Previous: `a76d8df` (feed authors). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `f53bc56`**. No frontend changes — no + renderer rebuild needed. Restart Electron before retesting. + +## What was completed this session +1. **Confirmed live pairing WORKS**: today's trace log shows two full + successes against real Amber (identity adopted, CONNECTED at 15:17 + and 15:37). The "Dev" profile row exists in + `~/.local/share/keynectr/profiles_vault.json` in `nip46_client` + mode — the original complaint (no profile row, no persisted + connection) is resolved as of the `c789cb4` relay-set fix. +2. **Diagnosed + fixed the remaining sign failure (`f53bc56`)**: + logs show `sign_event` timing out at 30s while Amber's valid + signature for the last request arrived ~61s after publication + ("stale/duplicate response: no waiter ... dropped"). Every sign + waits on a human approving Amber's prompt, so it was using the + wrong leash. `sign_event` now uses `SIGN_TIMEOUT` = 120s (same as + the connect handshake); `get_public_key` keeps the 30s + retry-cadence timeout unchanged. +- Verification (all green at `f53bc56`): `cargo test` **213 unit + 4 + e2e passed / 0 failed** (incl. `nip46_qr_pairing_handshake_and_sign`, + which signs through the changed path), `cargo clippy --all-targets` + 0 warnings, `cargo fmt --check` clean, `cargo build --release` green. + Frontend untouched. + +## Commits added (newest first) +- `f53bc56` fix(nip46): give sign_event the human-approval timeout leash + +## How to resume / reproduce +- Restart Electron (new release binary), re-pair or restore, then + Publish name / publish a note: approve in Amber within 2 minutes and + the signature will be accepted even if the prompt took a while. +- If a sign still fails, check `~/Tools/keynctr-debug/` logs — a + "TIMED OUT after 120s" now means the phone truly never answered. + +## Outstanding / next steps +1. **Live sign/publish through real Amber with the 120s leash** (fix + is log-evidence-based; needs one live publish to confirm). +2. **NIP-46 session restore on startup** (re-scan needed after restarts). +3. Prune the 11 stale profileless `nip46_connections` rows (cosmetic). +4. Remote picture/nip05 edits, KDF upgrade (Step 5), rename pass + (Step 7) — unchanged. + +--- + +# Checkpoint — feed shows author names/pictures (2026-09-23 PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`a76d8df`** ("feat(feed): resolve author names and + pictures from kind-0 metadata"). Previous: `cd8b671` (checkpoint), + `6f4dbb2` (kind-0 via signer). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `a76d8df`**. Renderer rebuilt via + `npm run build`. Restart Electron before retesting. + +## What was completed this session +1. **Feed author resolution (`a76d8df`)**: the feed previously rendered + bare npubs + initial avatars — it never looked up author metadata + anywhere. After collecting notes, the backend now runs ONE batched + kind-0 lookup for all distinct authors (8s cap, best-effort; failures + keep the npub fallback) and attaches the newest `author_name` + (display_name preferred) + `author_picture` per note. FeedScreen renders + the name (full npub on hover) and the picture avatar. Unit test covers + newest-wins, display_name preference, and blank→fallback; new + FeedScreen test covers name+picture rendering. +2. Note: anyone WITHOUT published kind-0 (like the user's own new identity + until "Publish name" runs) still shows as npub — correct fallback, not + a bug. +- Verification (all green at `a76d8df`): `cargo test` **213 unit + 4 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green; frontend + `npm test` **121 passed**, `typecheck`, `lint`, `format:check`, + `electron:build`, `build` clean. + +## Commits added (newest first) +- `a76d8df` feat(feed): resolve author names and pictures from kind-0 + metadata + +## How to resume / reproduce +- Restart Electron → Feed: notes from authors WITH kind-0 metadata now + show names + pictures; others still show npubs. +- To see your own name on your posts: Profiles → Publish name (Amber + approval) → wait ~1 min → Feed refreshes with your name/picture. + +## Outstanding / next steps +1. **Live "Publish name" + live sign/publish through real Amber.** +2. **NIP-46 session restore on startup.** +3. Prune the 11 stale profileless `nip46_connections` rows (cosmetic). +4. Remote picture/nip05 edits, KDF upgrade (Step 5), rename pass + (Step 7) — unchanged. + +--- + +# Checkpoint — kind-0 publishes through Amber; name goes network-wide (2026-09-23 PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`6f4dbb2`** ("feat(nip46): publish kind-0 metadata + through the connected signer"). Previous: `327bf0f` (checkpoint), + `3d1c306` (silent poll). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `6f4dbb2`**. Renderer rebuilt via + `npm run build`. Restart Electron before retesting. + +## What was completed this session +1. **Closed the P2 kind-0 gap (`6f4dbb2`)**: the "Publish name" IPC path + now routes through the profile's `Signing` source (same pattern as + note publishing). Embedded profiles sign locally as before; a paired + profile's kind-0 (label/picture/nip05) is signed by the remote signer — + Amber shows an approval prompt — so the name becomes visible in every + Nostr client. Disconnected sessions fail closed, never with a local + fallback. CLI keeps the local-only path (no signer instances there). +2. **E2E proof**: the strict-Amber test now also publishes kind-0 through + `App::signing_for` + `publish_metadata_signed` and asserts relay + acceptance — the exact GUI path, with identity validation. Along the way + fixed the test's production wiring (handle registered on `App`, as + `ensure_nip46_signer` does) after a `None`-handle failure poisoned the + shared env lock and cascaded to all 4 e2e tests. +3. Rename modal copy now points remote profiles at "Publish name" (Amber + approval) instead of claiming the signer app publishes it. +- Verification (all green at `6f4dbb2`): `cargo test` **212 unit + 4 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green; frontend + `npm test` **120 passed**, `typecheck`, `lint`, `format:check`, + `electron:build`, `build` clean. + +## Commits added (newest first) +- `6f4dbb2` feat(nip46): publish kind-0 metadata through the connected + signer + +## How to resume / reproduce +- Restart Electron → Profiles → **Publish name** on the paired profile → + approve the `sign_event` (kind 0) prompt in Amber → per-relay report. + Check any Nostr client: the display name (your vault label) now appears + instead of the bare npub. +- E2E: `cargo test --test nip46_e2e` (4 tests incl. kind-0-via-signer). + +## Outstanding / next steps +1. **Live "Publish name" through real Amber** (e2e-proven, never live-run). +2. **NIP-46 session restore on startup** (re-scan needed after restarts). +3. Prune the 11 stale profileless `nip46_connections` rows (cosmetic). +4. Remote picture/nip05 edits, KDF upgrade (Step 5), rename pass + (Step 7) — unchanged. + +--- + +# Checkpoint — silent state poll + session-restore gap named (2026-09-23 PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`3d1c306`** ("fix(ui): silent background state poll"). + Previous: `5714349` (checkpoint), `6ebc364` (remote rename). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: current at `6ebc364` (Rust unchanged since). Renderer + rebuilt via `npm run build`. Restart Electron before retesting. + +## What was completed this session +1. **Diagnosed the Home flicker (my `a8d112b` polling regression)**: every + 5s poll delivered a fresh state object identity, so Home's publications + loader (`useProfilePublications`, keyed on `settings.relays` identity) + refired forever — "Loading publications…" cycling. Fixed in `3d1c306`: + `refresh()` keeps the previous state object when content-identical (JSON + compare), so idle polls are re-render silent. Poll timer uses global + `setInterval` (testable under fake timers). +2. **Regression test + harness fidelity**: new HomeScreen test proves + `feed_get` fires once across 12s of polling (verified RED without the + guard: 3 fetches). `fakeBackend` `get_state` now deep-copies like the + real IPC boundary — the shared-reference version masked the bug. +3. **Diagnosed the publish failure**: backend restarted 14:57 for the rename + build, which killed the in-memory NIP-46 session (client keys + subs are + memory-only by design). Vault keeps the connection row, but there is NO + session restore on startup — publishing fails closed with "external + signer selected but not connected" while Amber still shows connected. + Re-scanning the QR re-establishes it (proven path). Automatic restore + (fresh `connect` against the stored row) is the follow-up, not this + change. +- Verification (all green at `3d1c306`): frontend `npm test` **120 passed + (18 files)**, `typecheck`, `lint`, `format:check`, `electron:build`, + `build` clean. Rust untouched (last green at `6ebc364`: 212 unit + 4 e2e, + clippy/fmt clean). + +## Commits added (newest first) +- `3d1c306` fix(ui): silent background state poll - no refetch churn when + vault unchanged + +## How to resume / reproduce +- Restart Electron → Home no longer flickers; Rename works as in `6ebc364`. +- To publish: Signer Mode → Show QR → re-scan in Amber (session does not + survive restarts yet) → Compose → Publish (approval lands in Amber). + +## Outstanding / next steps +1. **NIP-46 session restore on startup** (fresh handshake against the stored + connection row) — publishing after any restart currently needs a re-scan. +2. **Live sign/publish through paired Amber** (never exercised live). +3. Prune the 11 stale profileless `nip46_connections` rows (cosmetic). +4. Remote picture/nip05 edits, kind-0 via `Signing` (P2), KDF upgrade + (Step 5), rename pass (Step 7) — unchanged. + +--- + +# Checkpoint — remote-profile rename; live pairing aftermath (2026-09-23 PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`6ebc364`** ("fix(profiles): label-only rename for + secretless remote-signer profiles"). Previous: `ca62111` (live-pairing + checkpoint), `a8d112b` (state polling). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `6ebc364`** (Rust changed). Renderer rebuilt + via `npm run build`. Restart Electron before retesting. + +## What was completed this session +1. **Explained the generic "Remote Signer" label**: the paired identity + (`npub1f3tura…`) has NO kind-0 metadata on damus/primal/nos.lol (direct + REQ check — all EOSE), so the background enrichment correctly found + nothing to upgrade the pairing label with. +2. **Label-only rename for remote profiles (`6ebc364`)**: `rename_profile` + used to resolve the local secret first, so renaming a paired profile + errored. Secretless `Nip46Client` rows now rename vault-side with an + empty publish report (kind-0 signing still awaits the P2 external-signer + reroute). Rename modal reports "saved on this device" for the empty + report instead of a misleading "published to 0 relays". New regression + test `rename_profile_updates_label_for_secretless_remote_profiles`. +- Verification (all green at `6ebc364`): `cargo test` **212 unit + 4 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green; frontend + `npm test` **119 passed**, `typecheck`, `lint`, `format:check`, + `electron:build`, `build` clean. + +## Commits added (newest first) +- `6ebc364` fix(profiles): label-only rename for secretless remote-signer + profiles + +## How to resume / reproduce +- Restart Electron, Profiles → rename `Remote Signer` to any display name + (e.g. your Nostr name) — saves instantly, no error. +- To show your real Nostr name everywhere instead: publish a kind-0 profile + (name/picture) from Amber or another client; a future enrichment pass can + pick it up (no re-fetch path yet). + +## Outstanding / next steps +1. **Live sign/publish through paired Amber** (never exercised live). +2. Prune the 11 stale profileless `nip46_connections` rows (cosmetic). +3. Remote picture/nip05 edits (same local-key limitation as rename had), + `publish_profile_metadata` kind-0 via `Signing` (P2), KDF upgrade + (Step 5), rename pass (Step 7) — unchanged. + +--- + +# Checkpoint — FIRST LIVE END-TO-END AMBER PAIRING SUCCEEDED (2026-09-23 PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`a8d112b`** ("fix(ui): poll vault state so background + Amber pairing appears without reload"). Previous: `fb14976` + (checkpoint), `e02417b` (damus pairing relays), `8f055f7` (NIP-46 spec + fix + trace). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: rebuilt at `e02417b`; `a8d112b` is frontend-only (no + rebuild needed — Electron loads the renderer from `frontend/dist`, rebuilt + via `npm run build`). Restart Electron to pick up the renderer change. + +## What was completed this session +1. **ROOT CAUSE OF THE WHOLE SAGA — Amber's OS notification gate**: Amber's + in-app log showed all four `get_public_key` RPCs arriving on all relays, + each followed by `notifications disabled`. Amber's + `EventNotificationConsumer.consume` returns early when Android + notifications are blocked for the app — every signer request dies + silently after connect. Fix was on the phone: Settings → Apps → Amber → + Notifications → Allow. (Battery "unrestricted" was already set and was + never the issue.) +2. **First live end-to-end pairing**: after enabling notifications, the next + scan completed in ~1s — `identity adopted: + npub=npub1f3tura29nrmhpp5v88z45knjejzdcx7ulvcz2jswau4raa7v25nsh6ltfw — + CONNECTED`. Vault holds the secretless `Nip46Client` profile as active; + connection re-keyed under the identity. Amber shows the app; desktop + Signer screens show Connected. +3. **UI staleness found by the success (`a8d112b`)**: Home/Profiles read the + launch-time state snapshot and never refetch (IPC has no push channel), + so they still showed first-run Welcome after the background pairing. + `AppProvider` now polls `getState()` every 5s (cheap local read, errors + swallowed — screens surface their own failures). +- Verification (all green at `a8d112b`): frontend `npm test` **119 passed + (17 files)**, `typecheck`, `lint`, `format:check`, `electron:build`, + `build` clean. Rust untouched (last green at `e02417b`: 211 unit + 4 e2e, + clippy/fmt clean, release green). + +## Commits added (newest first) +- `a8d112b` fix(ui): poll vault state so background Amber pairing appears + without reload + +## How to resume / reproduce +- Restart Electron (renderer changed), open Home/Profiles: the `Remote + Signer` profile (`npub1f3tura…`) is listed and active; state refreshes + within ~5s without reload. +- Next: publish a note / sign through the paired Amber (approval appears in + Amber; `sign_event` path was e2e-tested, never yet live-tested). + +## Outstanding / next steps +1. **Live sign/publish through paired Amber** (never exercised live). +2. Prune the 11 stale profileless `nip46_connections` rows left by failed + scans (cosmetic; vault-only cleanup). +3. `publish_profile_metadata` kind-0 via `Signing` (P2), KDF upgrade (Step + 5), rename pass (Step 7) — unchanged. + +--- + +# Checkpoint — Amber-side silence proven; damus rejoins pairing set (2026-09-23 PM) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`e02417b`** ("fix(pairing): offer relay.damus.io + first"). Previous: `5456835` (checkpoint), `8f055f7` (NIP-46 spec fix). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `e02417b`** — Electron spawns this one. + IMPORTANT: the running Electron/backend (started 10:06) predates every fix + — quit fully and relaunch before any live test. + +## What was completed this session +1. **Stale-binary trap found**: the user's live scans ran against a backend + started 10:06, before the 13:01 rebuild. Documented the full-quit + + relaunch procedure (`pkill`, port-5173 conflict fix). +2. **Live scan on the NEW binary (`8f055f7`) analyzed end to end**: the + `[NIP46]` trace is perfect through secret validation, but `get_public_key` + (accepted by both relays, 4 attempts) gets zero responses and the demux + subscription logs zero inbound — nothing is dropped, nothing arrives. +3. **Fetched our own request event off relay.primal.net**: author, `p`-tag, + kind all textbook-correct. Unauthenticated REQ works on all four relays + (no NIP-42 gate). Ephemeral retention is short (<~20 min), so both sides + must be live-subscribed — late joiners get nothing. +4. **Read Amber's own signer source** (`BunkerRequestUtils.kt`, master): + fresh localKey per approval, URI relays honored for nostrconnect://, + listen-REQ-before-response, auto-approve `get_public_key`. Our wire + behavior matches it — with current Amber the halves should meet. +5. **Amber 6.6.5 (current) forensics**: app entry correct (primal+nos.lol), + activity log shows ONLY the Connect ack — Amber never receives our RPC. + Battery already unrestricted. +6. **Relay experiment (`e02417b`)**: Amber's issue history documents NIP-46 + working over `relay.damus.io`; same-day write-canary from this network + shows damus connected + accepting ephemeral 24133. Pairing set is now + damus → primal → nos.lol (damus first). +- Verification (all green at `e02417b`): `cargo test` **211 unit + 4 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green. Frontend + untouched (suite last green at `8f055f7`: 119 tests, typecheck, lint, + format, electron:build, build). + +## Commits added (newest first) +- `e02417b` fix(pairing): offer relay.damus.io first + +## How to resume / reproduce +- **Quit Electron fully first** (`pkill -f "electron ."` → `pgrep` shows + nothing), relaunch vite + Electron (release binary is current at + `e02417b`), verify backend start time is NOW, then Signer Mode → Show QR + (QR now lists damus/primal/nos.lol) → scan in Amber → approve with Amber + foregrounded. Expect `[NIP46] ... UI connection state updated: Connected`. +- If Amber still shows only the Connect ack, its phone-side relay path is + the remaining suspect — report which relays Amber's entry lists and any + new activity lines. + +## Outstanding / next steps +1. **Live re-scan against `e02417b`** (decisive; needs app restart first). +2. If damus doesn't help, consider pruning to fewer relays (force both ends + onto one) or capturing Amber's in-app relay/connection log. +3. `publish_profile_metadata` kind-0 via `Signing` (P2), KDF upgrade (Step + 5), rename pass (Step 7) — unchanged. + +--- + +# Checkpoint — NIP-46 handshake debugged: connect-params spec fix + full [NIP46] trace + visible errors (2026-09-23) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`8f055f7`** ("fix(nip46): spec-correct connect params, + full-handshake [NIP46] trace, visible handshake errors"). Previous: + `2bc6321` (identity-RPC retry), `9cfc1cf` (relay-accept trace). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `8f055f7`** — Electron spawns this one. + +## What was completed this session +1. **Spec-verified the whole signer flow against NIP-46** (20-point checklist + in the session brief). URI generation, keypair handling, subscribe-before- + publish, relay URL encoding, kind-24133 listen, NIP-44 decrypt, secret + validation, identity-via-`get_public_key`, account-state update and UI + polling were all traced file-by-file (`nip46_client.rs`, `ipc.rs`, + `SignerModeScreen.tsx`, `relays.rs`, `nip46_e2e.rs`). +2. **Found + fixed the definitive paste-flow (`bunker://`) bug (`8f055f7`)**: + `run_handshake` sent `connect` params as `[client_pubkey, secret]`, but + NIP-46 (and rust-nostr's own `NostrConnectRequest::Connect` codec) require + `[, ]`. Strict signers answer a + malformed connect with silence, stalling the handshake with zero feedback. + New pure helper `connect_params(peer, secret)` + 2 unit tests (one + round-trips through the library codec). +3. **Client keypair is now always ephemeral** in `connect()` (was: reused the + vault's local secret key when unlocked). Per NIP-46 the client keypair is + disposable and must never be confused with the user's identity or a vault + key. QR flow already did this; both flows now agree. +4. **Full `[NIP46]` diagnostic trace** across both flows (console stderr, in + addition to the existing `pairing-trace.log` forensics): keypair gen, + client pubkey, secret presence (NEVER values), relay connecting/connected, + subscription created/registered (filter + id), URI generated, every inbound + 24133 (author, tags, decrypt OK/real-error, method, secret PASS/FAIL, + remote pubkey), every RPC publish/timeout/response-routing decision + (including stale/duplicate ids), relay-health pre-check before each + `get_public_key` retry (fails fast with a useful error when no relay is + connected), user pubkey, account-state update, UI-state update. +5. **Failures are visible now, not silent**: `fail()` keeps the FIRST + (specific) error instead of letting the demux exit overwrite it with + generic "Connect handshake failed"; `SignerModeScreen` renders + `status.error` as an alert in both the pairing and the idle/paste branches + and shows a "Connection request sent — approve it in Amber" hint while a + paste-URI connect is in flight. No fake Connected state anywhere: + `Connected` still requires `get_public_key` to resolve the identity. +6. **Tests**: new strict-Amber e2e + (`nip46_bunker_connect_params_match_spec_against_strict_amber`) — fake + signer rejects `connect` unless params[0] is its own pubkey, then serves + identity + sign like Amber; asserts the REAL user pubkey lands in the + vault as a secretless row AND becomes the active profile. Verified RED on + the old param order, GREEN on the fix. New `SignerModeScreen.test.tsx` + (3 tests: QR waiting hint, connecting hint, failed-handshake error + visible) + `nip46_*` dispatch in `fakeBackend.ts`. Also widened the e2e + `VAULT_ENV_LOCK` to whole-test bodies (data_dir() re-reads process-global + XDG_DATA_HOME on every save — one cross-write flake seen under full-suite + parallelism) with targeted `await_holding_lock` allows. +- Verification (all green at `8f055f7`): `cargo test` **211 unit + 4 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green; frontend + `npm test` **119 passed (17 files)**, `typecheck`, `lint`, `format:check`, + `electron:build`, `build` clean. + +## Commits added (newest first) +- `8f055f7` fix(nip46): spec-correct connect params, full-handshake [NIP46] + trace, visible handshake errors + +## How to resume / reproduce +- Dev loop: `cd frontend && npx vite --port 5173` then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` + (release binary already rebuilt at `8f055f7`). +- **Live Amber re-scan (still the decisive test)**: Signer Mode → Show QR → + scan in Amber → approve, **keep Amber open in the foreground**. Watch + terminal/backend console for the `[NIP46]` lines in order (see "Expected + logs" in the session report) and `~/Tools/keynctr-debug/pairing-trace.log`. +- E2E: `cargo test --test nip46_e2e` (4 tests, no network). +- Frontend: `cd frontend && npm test -- SignerModeScreen`. + +## Outstanding / next steps +1. **Live Amber re-scan against `8f055f7`** — the QR flow's `get_public_key` + silence (5 scans × 4 attempts, publishes accepted by primal+nos.lol, zero + responses) is downstream of our publish: either Amber never receives our + request (e.g. its subscription fails, NIP-42 AUTH on those relays from + mobile, or the app was backgrounded) or it receives and never answers. + The new demux logs distinguish these live: zero inbound lines during the + retries ⇒ Amber-side; inbound-but-dropped lines ⇒ our filter/decrypt. +2. If the live trace shows zero inbound during retries, next step is a relay + experiment (add a no-auth REQ relay to the pairing set) and/or confirming + Amber stays foregrounded with network. +3. `publish_profile_metadata` (kind 0) still signs locally — reroute through + `Signing` for external profiles (P2, pre-existing). +4. Step 5 (KDF upgrade), Step 7 (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — first live Amber pairing succeeded; subscription race fixed (2026-09-23) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`9cfc1cf`** ("chore(pairing): trace which relays + accepted each identity RPC"). Previous: `2e98e69` ("fix(pairing): subscribe + to signer replies BEFORE the handshake publishes"), `13a66f2` + ("feat(onboarding): first-run screen offers import-existing-account and + external-signer paths"), `963b740`, `c789cb4` (relay-set canary fix). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `2e98e69` (2026-09-23 ~08:45 CDT)** — Electron + spawns this one. Dev loop: vite :5173 + + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 + KEYNCTR_FORCE_WAYLAND=1 KEYNCTR_NO_RELAUNCH=1 npx electron .` (the + FORCE_WAYLAND/NO_RELAUNCH pair keeps the crash-fallback ladder from drifting + the window onto XWayland, where it fails to map on this Hyprland session). +- What was completed this session: + 1. **First genuine live pairing captured** (Sep 23 08:31 CDT): pairing + started → inbound 24133 → `connect` accepted with secret echo → + `paired: connection stored` — the c789cb4 relay fix is proven end to end. + The session then died at identity adoption: `get_public_key` timed out. + 2. **Root cause `2e98e69`**: both connect flows spawned the handshake task + BEFORE `run_demux` registered the kind-24133 author subscription, so the + relay delivered Amber's fast identity reply into a gap with no active + subscription and it was dropped; 30s later the RPC timed out, leaving the + vault with a stored connection (`profile_npub: None`) but no profile — + UI said "connected" while Home still showed first-run. Fix: new + `subscribe_to_signer` opens the notification stream + subscription before + any publish; `run_sign_task` and `run_paired` both subscribe first and + pass the stream to `run_demux`. `futures-util` promoted to runtime dep. + 3. **Onboarding `13a66f2`**: first-run Home now offers three paths — Create + a new profile / I already have an account (ImportProfileModal) / Sign in + with a signer (navigates to Signer Mode). Copy states per-mode key truth + (local vault vs remote signer key never on this device). +- Verification (all green): `cargo test` **209 unit + 3 e2e passed / 0 + failed**, `cargo clippy --all-targets` 0 warnings, `cargo fmt --check` + clean, `cargo build --release` green; frontend `npm test` **116 passed**, + `typecheck`, `lint`, `format:check` clean. +- Resume / reproduce: dev-loop command above; scan the pairing QR with Amber + from Signer Mode. Expected trace in `/home/avi/Tools/keynctr-debug/ + pairing-trace.log`: `pairing started → inbound 24133 → connect response + accepted → paired: connection stored → identity adopted … CONNECTED`, and a + profile row appearing in `~/.local/share/keynectr/profiles_vault.json`. +- Outstanding / next steps: + - **Live re-scan against `2e98e69`** — the only missing proof; the stale + half-paired connection row (`Remote Signer`, signer_pubkey 9597b46d…) is + replaced by the new pairing. + - Consider pruning stale `Remote Signer` connection rows on failed + adoption so the vault never keeps a profileless connection. + +--- + +# Previous checkpoint — pairing relay set canary-tested; 24133-blocking relays removed (2026-09-22) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`c789cb4`** ("fix(pairing): drop purplepag.es and + nostr.band from the pairing relay set"). Previous: `2e5938a`, `d4d87b8`, + `f6bf6a9`, `edd4e56` (pairing feature HEAD). +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `c789cb4` (2026-09-22 20:39)** — Electron + spawns this one. +- Verification (all green at `c789cb4`): `cargo test` **209 unit + 3 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green. No frontend + changes (npm suite last green at `edd4e56`). + +## What was completed since the last checkpoint +- **First real live pairing attempt was captured — and diagnosed + (`c789cb4`)**: the trace log now shows a genuine live session for the + first time: `pairing started` at 18:01:29 CDT (ephemeral + `7c695714…`, relays damus/nostr.band/primal/purplepag) followed by + `session failed: Pairing timed out` at 18:06:39 — the 5-min window ran + with ZERO inbound 24133 events (capture file untouched since Sep 18). + Post-hoc relay sweep (read-only REQ, results in + `/tmp/probe-results.json` + `/tmp/window-24133.json`) found **no + kind-24133 event tagged to the ephemeral key on any of the four + relays, and no kind-24133 at all in the whole 18:01–18:06 window**. + So Amber connected to a relay and published, but the event was + discarded before storage. +- **Smoking gun via anonymous canary** (throwaway random keys, zero user + data; `~/Tools/keynctr-debug/canary-24133.py`, results + `/tmp/canary-results.json`): writing a kind-24133 event to each pairing + relay → **purplepag.es: `OK false "blocked: kind 24133 is not + allowed"`** — it silently drops signer connect traffic; + **relay.nostr.band: WebSocket handshake hangs** (also pay-to-read); + relay.damus.io + relay.primal.net + nos.lol: accepted + readable; + relay.snort.social: accepted live ("ephemeral: will not be stored"), + fine for pairing push. If Amber picked purplepag.es (it is in the URI), + it would say "connected" while its connect event was rejected — + exactly the observed symptom, and consistent with the earlier + parse-failure theories being dead ends (the payload never arrived). +- **Fix (`c789cb4`)**: `pairing_relays()` is now damus.io, primal.net, + nos.lol, relay.snort.social — both blockers removed, both replacements + canary-verified for ephemeral 24133. Regression test + `pairing_relays_exclude_24133_blockers` pins the blockers out. +- **Debug-dir hygiene**: `inspect.py` removed (it shadowed the stdlib + `inspect` module for any script run from that directory and leaked + stale forensics output into terminal sessions); moved to + `inspect-capture.txt`. Canary + probe scripts kept under + `~/Tools/keynctr-debug/` (untracked tools dir). + +## Commits added (newest first) +- `c789cb4` fix(pairing): drop purplepag.es and nostr.band from the + pairing relay set + +## How to reproduce / exercise +- **LIVE AMBER RE-SCAN (the decisive test, cannot run unattended)**: + launch the app (release binary is current at `c789cb4`): + `cd frontend && npx vite --port 5173` then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` + Signer mode -> Show QR -> scan in Amber -> approve. The QR's relay list + no longer contains purplepag.es/nostr.band. Expected trace: + `pairing started` -> `inbound 24133 from …` -> `connect response + accepted (secret echo verified)` -> `paired: connection stored; + handing over to identity handshake` -> `identity adopted: npub=… — + CONNECTED`. Watch with `bash ~/Tools/keynctr-debug/watch-pairing.sh 240`. +- Canary: `python3 ~/Tools/keynctr-debug/canary-24133.py` (throwaway + keys; results to /tmp/canary-results.json). +- E2E: `cargo test --test nip46_e2e` (no network; trace file stays + untouched). + +## Outstanding / next steps +1. **Live Amber re-scan against `c789cb4`** — the relay-set fix is the + best-evidenced change yet but only a live scan proves it. +2. Consider whether the user's two enabled relays (settings.json: + damus.io + nostr.band) should swap nostr.band for nos.lol for + ordinary traffic too (it hangs the handshake today; also pay-to-read). +3. If trace still shows timeout with the new set, next hypothesis is + Amber not actually publishing (its "connected" = relay socket open); + compare against Amber's own relay debug screen. +4. `publish_profile_metadata` (kind 0) still signs locally — reroute + through `Signing` for external profiles (P2). +5. Step 5 (KDF upgrade), Step 6 (undo preserves ProfileSummary — done in + `d101b8e`), Step 7 (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — 'keys never leave' claim corrected per-mode (2026-09-22); prior: pairing forensics de-noised + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`d4d87b8`** ("docs: replace blanket 'keys never leave + the machine' claim with per-mode truth"). Previous: `f6bf6a9`, `edd4e56` + (pairing feature HEAD), `deeb4f9`, `d52fa58`. +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: still built at `f6bf6a9` — `d4d87b8` changed only + Markdown docs, no rebuild needed. +- Verification at `d4d87b8`: `cargo fmt --check` clean, `cargo test` **208 + unit + 3 e2e passed / 0 failed** (first run showed 1 e2e flake; both the + targeted `cargo test --test nip46_e2e` rerun and the full rerun were + green). No frontend changes (npm suite last green at `edd4e56`). + +## What was completed since the last checkpoint +- **Honest security copy (`d4d87b8`)**: the blanket "keys never leave the + machine" claim in README.md (tagline), PRODUCT.md (purpose, positioning, + principle 1), and DESIGN.md (North Star) was replaced with the per-mode + truth: in embedded/bunker modes keys stay in the local encrypted vault + and never reach the renderer; in external NIP-46 signer mode the key + never *arrives* on this machine — a strictly stronger posture against + desktop compromise. README security notes gained an explicit bullet + saying external signer mode can be *more* secure. App UI + (`SignerModeScreen.tsx`) already ranked external signer "Most Secure / + Private key NEVER on this device" — no code or test changes were needed. + +## Commits added (newest first) +- `d4d87b8` docs: replace blanket 'keys never leave the machine' claim with per-mode truth + +## How to reproduce / exercise +- **LIVE AMBER TEST (still the only missing step from `f6bf6a9`)**: launch + the app: `cd frontend && npx vite --port 5173` then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` + Signer mode -> Show QR -> scan in Amber -> approve. Expected trace: + `pairing started: ephemeral=…` -> `inbound 24133 from …` -> `connect + response accepted (secret echo verified)` -> `paired: connection stored; + handing over to identity handshake` -> `identity adopted: npub=… — + CONNECTED`. Watch with `bash ~/Tools/keynctr-debug/watch-pairing.sh 240`. +- Docs-only change: `git show d4d87b8`. + +## Outstanding / next steps +1. **Live Amber re-scan required** (cannot be done from an unattended run). +2. If trace shows `pre-handshake 'get_public_key' ignored`, relax the + connect-only gate. +3. `publish_profile_metadata` (kind 0) still signs locally — reroute + through `Signing` for external profiles (P2). +4. Step 5 (KDF upgrade), Step 6 (undo preserves ProfileSummary), Step 7 + (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — pairing forensics fully de-noised; Amber fix still awaiting live test (2026-09-21) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`f6bf6a9`** ("fix(pairing): keep e2e traffic out of + the live pairing trace + trace the handover"). Feature HEAD unchanged: + `edd4e56` (connect-response root-cause fix). Previous: `deeb4f9`, + `d52fa58`, `5f9c64f`, `21c522b`, `188b2eb`, `aedde8f`, `759b5dd`, + `5aa122d`, `f59c2b1`. +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary: **rebuilt at `f6bf6a9` (2026-09-21 ~20:25)** — the + current Sep 19 binary was superseded. Electron spawns this one. +- **STILL NO LIVE AMBER SCAN against the fixed binary.** Proof: trace log + has zero `pairing started` lines (every real pairing writes one) and the + capture file's newest event is Sep 16 21:03. NOTE: the `identity + adopted — CONNECTED` lines dated Sep 18–21 (20:59, 20:43, 20:39–20:44, + 20:13) are all **cargo-test / cron-verification e2e traffic**, not live + scans — that exact confusion was the reason for `f6bf6a9` below. +- Verification (all green at `f6bf6a9`): `cargo fmt --check` clean, + `cargo test` **208 unit + 3 e2e passed / 0 failed**, `cargo clippy + --all-targets` 0 warnings, `cargo build --release` green. No frontend + changes (npm suite last green at `edd4e56`). + +## What was completed since the last checkpoint +- **Forensics de-noising (`f6bf6a9`)**: `adopt_identity`'s + `identity adopted — CONNECTED` trace line had no loopback gate, so every + e2e run appended fake CONNECTED entries to + `~/Tools/keynctr-debug/pairing-trace.log` — three appeared during this + cron's own `cargo test` runs and had to be manually distinguished from a + real Amber scan. The line is now gated on `live_relays()` (same loopback + test the event capture already uses; verified live: re-running + `cargo test --test nip46_e2e` no longer touches the trace file's mtime). + Also added a `paired: connection stored; handing over to identity + handshake` trace line at the QR-pairing handover, so a stall between the + connect echo and `get_public_key` now names itself in the trace. +- **Post-fix audit of the pairing flow** (read-through, no code change): + the QR path (pairing loop -> connect-response echo -> connection+secret + persisted -> demux + `adopt_identity` -> profile row + vault save) is + coherent end to end; `send_rpc` waiters register before publish; demux + routes responses by id; failure paths call `fail()` which traces + `session failed: …`. Nothing further to fix without live data. + +## Commits added (newest first) +- `f6bf6a9` fix(pairing): keep e2e traffic out of the live pairing trace + trace the handover + +## How to reproduce / exercise +- **LIVE TEST (the only missing step)**: launch the app (release binary is + current): `cd frontend && npx vite --port 5173` then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` + (or run the packaged app). Signer mode -> Show QR -> scan in Amber -> + approve. Expected trace: `pairing started: ephemeral=…` -> + `inbound 24133 from …` -> `connect response accepted (secret echo + verified)` -> `paired: connection stored; handing over to identity + handshake` -> `identity adopted: npub=… — CONNECTED`; the profile row + + `nip46_connections` entry then land in + `~/.local/share/keynectr/profiles_vault.json` (its mtime is Sep 12 — + untouched since, further proof no live pairing has ever persisted). +- Watch during a live scan: `bash ~/Tools/keynctr-debug/watch-pairing.sh 240`. +- E2E: `cargo test --test nip46_e2e` (no network; 3 tests, both connect + shapes, empty-vault isolation guards; trace file stays untouched). + +## Outstanding / next steps +1. **Live Amber re-scan required** (cannot be done from an unattended + run). Trace log names the exact stop point if it stalls. +2. If trace shows `pre-handshake 'get_public_key' ignored`, relax the + connect-only gate. +3. `publish_profile_metadata` (kind 0) still signs locally — reroute + through `Signing` for external profiles (P2). +4. Step 5 (KDF upgrade), Step 6 (undo preserves ProfileSummary), Step 7 + (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — e2e vault-isolation bug found; Amber fix awaiting live test (2026-09-20) + +## Where things are (as of 2026-09-20) +- Branch: `master` @ `deeb4f9` + `d52fa58`. Feature HEAD: `edd4e56`. + +## What was completed since the last checkpoint +- **e2e vault-isolation bug found and fixed (`d52fa58` + `deeb4f9`)**: + both e2e tests seeded their isolation vault at + `$XDG_DATA_HOME/profiles_vault.json`, but `vault::data_dir()` is + `$XDG_DATA_HOME/keynectr` — so `App::load()` never saw the seed and + `try_migrate_legacy_vault()` silently migrated the **legacy repo vault + (real profile, plaintext secret key)** into the test process. Forensic + proof: legacy-vault backups `profiles_vault.json.backup-1789868634/670` + timestamped Sep 19 20:43:54 + 20:44:30 — exactly the cargo-test runs + around `edd4e56`. Consequence: the `identity adopted` trace lines from + Sep 19 20:43 were **e2e sessions, not a live Amber scan** (session-level + traces are not gated by `live_capture`). Fix: seed at + `tmp/keynectr/profiles_vault.json` + assert-empty guard after every e2e + `App::load()` so any future silent migration fails loudly. Verified after + the fix: repo legacy vault byte-identical, backup count unchanged (77). +- **Pairing status unchanged**: `edd4e56` (accept the NIP-46 connect + *response* shape) remains the root-cause fix; it has never yet faced a + live scan. + +## Commits added (newest first) +- `deeb4f9` test(e2e): assert vault isolation actually isolated +- `d52fa58` test(e2e): isolate the e2e vault at the real data_dir path + +## How to reproduce / exercise +- **LIVE TEST (the only missing step)**: launch the app (release binary is + current): `cd frontend && npx vite --port 5173` then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` + (or run the packaged app). Signer mode -> Show QR -> scan in Amber -> + approve. Expected: trace shows `connect response accepted (secret echo + verified)` then `identity adopted: npub=… — CONNECTED`, and the profile + row + `nip46_connections` entry land in the vault. +- Watch during a live scan: `bash ~/Tools/keynctr-debug/watch-pairing.sh 240`. +- E2E: `cargo test --test nip46_e2e` (no network; 3 tests, both connect + shapes, empty-vault isolation guards). + +## Outstanding / next steps +1. **Live Amber re-scan required** (cannot be done from an unattended + run). Trace log names the exact stop point if it stalls. +2. If trace shows `pre-handshake 'get_public_key' ignored`, relax the + connect-only gate. +3. Consider whether `identity adopted` / `session failed` trace lines + should also carry a live/e2e marker (the empty-vault guard keeps e2e out + of the real vault now, but the trace file can still mix both). +4. `publish_profile_metadata` (kind 0) still signs locally — reroute + through `Signing` for external profiles (P2). +5. Step 5 (KDF upgrade), Step 6 (undo preserves ProfileSummary), Step 7 + (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — Amber pairing: accept the NIP-46 connect *response* shape (2026-09-19) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`edd4e56`** ("fix(pairing): accept the NIP-46 connect + *response* shape Amber actually sends"). Previous feature HEADs: + `21c522b`, `188b2eb`, `aedde8f`, `759b5dd`, `5aa122d`, `f59c2b1`. +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary rebuilt at `edd4e56` (2026-09-19 ~20:50) — Electron spawns + this one. **No live Amber scan has run against this build yet.** +- Verification (all green at `edd4e56`): `cargo fmt --check` clean, + `cargo test` **208 unit + 3 e2e passed / 0 failed**, `cargo clippy + --all-targets` 0 warnings, `cargo build --release` green. Frontend: + `npm test` **116 passed**, `npm run typecheck` / `lint` / + `format:check` / `build` / `electron:build` all clean. + +## What was completed since the last checkpoint +- **The likely root cause of the whole Amber pairing failure (`edd4e56`)**: + for a client-initiated `nostrconnect://` scan, NIP-46 specifies the signer + sends a connect **response** — `{"id":…,"result":""}` — not a + connect *request* (spec: "the _remote-signer_ … then sends `connect` + *response* event to the `client-pubkey`"; result is `"ack"` OR the secret; + "Client discovers remote-signer-pubkey from connect response author"). + `run_pairing_task` only recognized an inbound `{"method":"connect"}` + request. A bare `{"result":…}` parsed into `RawRequest` with an *empty* + method (the lenient deserializer never fails, so the old "not a NIP-46 + request" log couldn't fire) and was silently swallowed as "pre-handshake + '' ignored" — Amber showed "connected", Keynctr sat in the pairing loop + until timeout, no profile row, nothing persisted. That exactly matches the + original symptom and the observed 89-byte plaintext + (`{"id":"…","result":"<16-byte-hex-secret>"}` fits 65–96 B). + The pairing loop now verifies the echoed secret (or `"ack"`) directly and + proceeds to identity adoption; a signer-sent `error` fails fast with the + signer's message; a wrong secret is still ignored as spoofing; the legacy + request shape keeps working. Trace lines added for each outcome. +- **e2e regression lock**: `run_fake_scanner` takes a `connect_shape` + param; new `nip46_qr_pairing_connect_response_shape` test simulates + Amber's spec shape end-to-end. Confirmed RED on the pre-fix parser + (pairing times out) and GREEN with the fix. + +## Commits added (newest first) +- `edd4e56` fix(pairing): accept the NIP-46 connect *response* shape Amber + actually sends + +## How to reproduce / exercise +- Dev loop (unchanged): `npx vite --port 5173` in `frontend/` FIRST, then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .`. +- GUI: Signer mode -> "Show QR" -> scan in Amber -> approve. Expected now: + trace log shows `connect response accepted (secret echo verified)` then + `identity adopted: npub=… — CONNECTED`, and a new profile row appears. +- Watch during a live scan: `bash ~/Tools/keynctr-debug/watch-pairing.sh 240` + (new helper — tails trace/capture, prints any new lines). +- E2E: `cargo test --test nip46_e2e` (no network; 3 tests incl. both + connect shapes). + +## Outstanding / next steps +1. **Live Amber re-scan required** to confirm end-to-end (cannot be done + from an unattended run). If it still stalls, `pairing-trace.log` names + the exact stop point. +2. If trace shows `pre-handshake 'get_public_key' ignored`, relax the + connect-only gate (the log line names it outright). +3. `publish_profile_metadata` (kind 0) still signs locally — reroute + through `Signing` for external profiles (P2). +4. Step 5 (KDF upgrade), Step 6 (undo preserves ProfileSummary), Step 7 + (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — durable pairing trace log + forensics-file hygiene (2026-09-18) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`21c522b`** ("chore(pairing): durable trace log + keep + e2e loopback traffic out of forensics files"). Previous feature HEADs: + `188b2eb`, `aedde8f`, `759b5dd`, `5aa122d`, `f59c2b1`. +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary rebuilt at `21c522b` (2026-09-18 ~21:00) — Electron spawns + this one. **No live Amber scan has run against `188b2eb` or `21c522b` + yet**: the capture file shows no real scan since Sep 16 21:03. +- Verification (all green at `21c522b`): `cargo fmt --check` clean, + `cargo test` **208 unit + 2 e2e passed / 0 failed**, `cargo clippy + --all-targets` 0 warnings, `cargo build --release` green. Frontend: + `npm test` **116 passed**, `npm run typecheck` / `lint` / + `format:check` / `build` / `electron:build` all clean. + +## What was completed since the last checkpoint +- **Forensics contamination found and fixed**: pairing-capture.jsonl had + grown two new lines (Sep 18 20:08 + 20:42) that were NOT Amber — they were + the e2e harness's fake-scanner events, appended because the capture path + was hardcoded and the loopback e2e test pairs through the same + `run_pairing_task`. Removed the two test events from the capture file + (backup: `pairing-capture.jsonl.bak-20260918`); loopback pairings now skip + the capture file and the pairing-session trace lines entirely (the + session-level `identity adopted` / `session failed` lines are shared with + the bunker flow and can still include a labelled e2e entry — distinguish + by the loopback relay set in the preceding `pairing started` line, which + real sessions never have). +- **Durable pairing trace (`pairing-trace.log`)**: every pairing decision + point now appends a timestamped line to + `~/Tools/keynctr-debug/pairing-trace.log` — pairing started (ephemeral key + + relay set), inbound 24133, decrypt failure (real NIP-44 error), + not-a-request (exact payload), pre-handshake method, connect answered, + identity adopted (npub), session failed (reason). Backend stderr only + reaches the Electron console and /tmp gets cleaned, so previously a failed + live handshake left NO durable trace. Verified working: the e2e run after + the change wrote `identity adopted ... CONNECTED` lines proving the trace + path end-to-end. + +## Commits added (newest first) +- `21c522b` chore(pairing): durable trace log + keep e2e loopback traffic + out of forensics files + +## How to reproduce / exercise +- Dev loop (unchanged): `npx vite --port 5173` in `frontend/` FIRST, then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .`. +- GUI: Signer mode -> "Show QR" -> scan in Amber -> approve. After the scan + (success OR failure), read `~/Tools/keynctr-debug/pairing-trace.log` — the + last lines name exactly where the handshake stopped. Raw frames still + append to `pairing-capture.jsonl` (live pairings only now). + +## Outstanding / next steps +1. **Live Amber re-scan still required** — nothing has exercised the + `188b2eb` lenient parser against real Amber traffic yet. The trace log + will show, without any terminal capture, whether the connect arrives, + decrypts, parses, and how far the handshake gets. +2. If trace shows `pre-handshake 'get_public_key' ignored`, relax the + connect-only gate (the log line names it outright). +3. `publish_profile_metadata` (kind 0) still signs locally — reroute + through `Signing` for external profiles (P2). +4. Step 5 (KDF upgrade), Step 6 (undo preserves ProfileSummary), Step 7 + (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — Amber pairing: lenient NIP-46 payload parse + real decrypt-error logging (2026-09-16) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`188b2eb`** ("fix(pairing): never reject a decrypted + NIP-46 payload on shape"). Previous feature HEADs: `aedde8f`, `759b5dd`, + `5aa122d`, `f59c2b1`. +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). +- Release binary rebuilt at `188b2eb` (2026-09-16 ~21:06) — the binary the + Sep 15/16 Amber scans ran against was the Sep 12 build; the id-coercion + + payload-dump diagnostics are live NOW. +- Verification (all green at `188b2eb`): `cargo test` **208 unit + 2 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green. Frontend: + `npm test` **116 passed (16 files)**, `npm run typecheck` clean, + `npm run lint` clean, `npm run format:check` clean, + `npm run electron:build` and `npm run build` green. + +## What was completed since the last checkpoint +- **Universal-lenient RawRequest parse (`188b2eb`)**: the derived + `Deserialize` (even after `aedde8f`'s params coercion) still rejected real + signer payloads. Replaced with a hand-written coercion deserializer: any + *valid JSON* deserializes — numeric/missing ids become text, object-shaped + `params` become a single param, a double-encoded JSON-string request is + unwrapped. A parse failure is now only possible for non-JSON plaintext, + which the log dumps verbatim. Regression tests for all five shapes. +- **Real decrypt-error logging (`188b2eb`)**: pairing decrypt failures now + log the actual NIP-44 error (invalid HMAC vs invalid padding vs wrong + conversation key) instead of a generic "wrong conversation key", and the + not-a-request log prints the exact decrypted payload. +- **Offline forensics (no commit)**: all four captured pairing frames in + `~/Tools/keynctr-debug/pairing-capture.jsonl` (latest: Sep 16 20:11) are + 163-byte NIP-44 v2 payloads = 1 ver + 32 nonce + 98 buffer + 32 MAC. + 98 is a VALID final-spec padding bucket (plaintext 65–96 bytes; the + observed 89 fits). So the frames are spec-conformant, decryption + succeeds, and the failure was purely the JSON-shape parse — confirming + the fix targets the right layer. Note `/tmp/keynctr-el*.log` no longer + exists (tmp-cleaner); backend stderr now only reaches the Electron + console — the next failed scan's payload dump needs + `npx electron .` launched from a terminal or with stderr redirected. + +## Commits added (newest first) +- `188b2eb` fix(pairing): never reject a decrypted NIP-46 payload on shape + +## How to reproduce / exercise +- Dev loop (unchanged): `npx vite --port 5173` in `frontend/` FIRST, then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .`. + To capture pairing diagnostics: run electron with stderr kept, e.g. + `... npx electron . 2>&1 | tee ~/Tools/keynctr-debug/el.log`. +- E2E: `cargo test --test nip46_e2e` (no network) — green. +- GUI: Signer mode -> "Show QR" -> scan in Amber -> approve. Raw pairing + events keep appending to `~/Tools/keynctr-debug/pairing-capture.jsonl`. + +## Outstanding / next steps +1. **Live Amber re-scan required** (cannot be done from an unattended run): + if pairing still fails, the backend log now contains the exact decrypted + 89-byte payload and/or the exact NIP-44 error — that text names the last + possible cause. +2. If the payload turns out to be valid JSON but not `method:"connect"` + (e.g. Amber's deferred-approval `get_public_key` first), the log will + show it and the handshake gate can be relaxed accordingly. +3. `publish_profile_metadata` (kind 0) still signs locally — reroute + through `Signing` for external profiles (P2). +4. Step 5 (KDF upgrade), Step 6 (undo preserves ProfileSummary), Step 7 + (rename pass incl. `homepage` URL). + +--- + +# Checkpoint — QR pairing, identity adoption, always-allow grants + pairing-relay widening (2026-09-12) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`f59c2b1`** ("fix(pairing): widen pairing relay set so + signer-chosen relays are covered"). Previous feature HEAD: `22d7c01`. +- Working tree: clean for tracked files. Untracked intentionally NOT + committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, + `deferred/` (stays deferred). `.directory`, `.opencode/`, `.impeccable/` + are now gitignored. Dead stub `src/signer/nip46_external.rs` **deleted** + (was untracked, never declared in `signer/mod.rs`, superseded by + `nip46_client.rs`). +- Verification (all green at `22d7c01`): `cargo test` **201 unit + 2 e2e + passed / 0 failed**, `cargo clippy --all-targets` 0 warnings, + `cargo fmt --check` clean, `cargo build --release` green. Frontend: + `npm test` **116 passed (16 files)**, `npm run typecheck` clean, + `npm run lint` clean, `npm run format:check` clean, + `npm run electron:build` and `npm run build` green. + +## What was completed since the last checkpoint (7 feature commits + hygiene) +- **QR pairing (`38499d4`)**: Keynctr is the NIP-46 *client*, Amber scans. + Signer screen mints a `nostrconnect://` pairing token (ephemeral key + + secret), renders it as a QR ("Show QR"), copy-link fallback, cancel. + Backend listens for the signer's connect request, echoes the secret + (anti-spoofing), persists the connection, adopts identity via + `get_public_key`. e2e covers scan -> secret echo -> identity -> sign -> + vault persistence with a fake QR scanner. +- **Electron allowlist (`c5004eb`)**: `nip46_pair_start` added to the + main-process renderer allowlist (was rejected with "not permitted"). +- **Lazy signer handle (`dc58f38`)**: all `nip46_*` IPC handlers ensure the + client signer handle exists (fresh backend no longer answers "not + initialized" until the mode is re-saved). +- **Pairing diagnostics (`9cfab4b`)**: pairing start + session failures + logged to stderr (captured by Electron). +- **Real identity adoption (`3d5302f`)**: paired profiles get the signer's + kind-0 display name/picture/nip05 (best-effort, 3s-capped) instead of a + generic pairing label. +- **Instant Connected (`286bbca`)**: session flips to Connected as soon as + identity is verified/persisted; metadata lands in a background task that + never overrides a user-chosen label (fixes "Amber said yes but nothing + changed"). +- **Always-allow grants (`81b082f`)**: standing per-(peer pubkey, method) + permission for external signer requests. Approvals gained an "Always + allow" option; grants listed with Revoke on the Signer screen; persist in + the encrypted vault (`src/vault.rs` grant storage). +- **Hygiene (`22d7c01`)**: gitignore editor artifacts, prettier-fix + `SignerScreen.tsx` (format:check had been failing since `81b082f`), + delete dead `nip46_external.rs` stub. + +## Commits added (newest first) +- `22d7c01` chore(hygiene): ignore editor artifacts; prettier SignerScreen +- `81b082f` feat(signer): always-allow grants for external signer requests +- `286bbca` fix(signer): connect immediately after identity; fetch kind-0 + metadata in background +- `3d5302f` feat(signer): adopt real display name/picture for paired NIP-46 + identities +- `9cfab4b` chore(signer): log pairing start and session failures to stderr +- `dc58f38` fix(ipc): lazily initialize the NIP-46 client signer handle +- `c5004eb` fix(electron): allow nip46_pair_start through the renderer method + allowlist +- `38499d4` feat(signer): QR pairing — client-initiated nostrconnect:// flow + for Amber + +## How to reproduce / exercise +- Dev loop (unchanged): `npx vite --port 5173` in `frontend/` FIRST, then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .`. +- E2E: `cargo test --test nip46_e2e` (no network). +- GUI: Signer mode screen -> "Show QR" -> scan in Amber -> approve -> + identity + display name appear; sign a note; approval dialog offers + "Always allow"; revoke on the Signer screen. + +## Outstanding / next steps +1. **On-device Amber verification** of everything above (QR pair + pasted + bunker://, identity/name/pic, sign + publish, always-allow grant, revoke, + re-prompt). Top item — never run against real Amber since `f917e5e`. +2. `publish_profile_metadata` (kind 0) still signs locally — reroute through + `Signing` for external profiles (P2). +3. Step 5 (KDF upgrade m=64MiB/t=3 + vault header versioning, gate + deprecated `RevealSecretKey`), Step 6 (undo preserves `ProfileSummary` -> + secret lost), Step 7 (Keynctr rename pass incl. `homepage` URL). +4. `wip/pairing-relay-widening` branch parked — needs an env seam before it + can merge (breaks e2e isolation as-is). + +--- + +# Checkpoint — NIP-46 e2e test + bunker:// frontend support (2026-09-11) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`85756df`** ("feat(signer): accept bunker:// URIs, async + signer permissions, NIP-46 e2e test"). Previous: `c096705`, `f917e5e`. +- Working tree: clean for tracked files. Untracked junk intentionally NOT + committed: `.opencode/`, `.impeccable/`, `.directory`, `COSMIC_THEME.md`, + `KeynectrAppIconPossibility02.jpeg`, `deferred/`, and the dead stub + `src/signer/nip46_external.rs`. +- Verification (all green this session): `cargo test` **200 + 1 e2e passed / 0 + failed**, `cargo clippy --all-targets` 0 warnings, `cargo fmt --check` clean, + `cargo build --release` green. Frontend: `npm test` **116 passed (16 files)**, + `npm run typecheck` clean, `npm run lint` clean, `npm run format:check` clean + (Prettier applied to the 4 previously-warning files), `npm run electron:build` + and `npm run build` green. + +## What was completed (this session) +- `tests/nip46_e2e.rs` (new, 450 lines): full in-process NIP-46 client test — + minimal local relay + fake Amber speaking the bunker flow, NIP-44 round-trip, + `connect` handshake, identity asserted to come from `get_public_key` (URI key + must never become identity), `sign_event` result verified against the signer + pubkey, vault persistence asserting `profile.secret_key` stays empty for + remote profiles. +- Frontend bunker:// support landed: `SignerManager.parseExternalSignerURI` + accepts `bunker://` (pubkey = authority before `@`) as well as + `nostrconnect://`; `SignerModeScreen` validates both, placeholder/hint explain + Amber vs Nostr Connect sources. +- Signer trait permission surface made async (`permissions`, `can_*`, + `is_connection_valid` now `async`, `blocking_lock` -> `lock().await`). +- Dev-loop gremlins fixed along the way: zombie vite on :5173 killed, stray + Electron (launched against dead vite, SIGTRAP core dump) cleaned up, app + relaunched and confirmed on screen. + +## Commits added (newest first) +- `85756df` feat(signer): accept bunker:// URIs, async signer permissions, + NIP-46 e2e test + +## How to reproduce / exercise +- Dev loop: `npm run dev` in `frontend/` (vite on :5173), THEN second terminal + `npm run start:dev` (or `NOSTR_GUI_DEV_URL=http://localhost:5173 + KEYNCTR_ENABLE_GPU=1 npx electron .`). Vite must be up FIRST or Electron + crashes on load. Rust edits need `cargo build --release` + backend restart. +- E2E test: `cargo test --test nip46_e2e` (~0.6s, no network). +- GUI: Signer mode screen -> paste Amber `bunker://...` link -> approve in + Amber -> app resolves identity via `get_public_key`. + +## Deferred / next steps +1. **Verify Amber end-to-end on device** (pair via Amber, sign a note, publish) + — unchanged, still the top item. +2. Delete dead stub `src/signer/nip46_external.rs`; `deferred/` stays deferred. +3. `publish_profile_metadata` (kind 0) still signs locally — reroute through + `Signing` for external profiles. +4. Step 4 (permissions UI), Step 5 (KDF upgrade), Step 6 (undo history), + Step 7 (rename/hygiene incl. `homepage` URL) — unchanged. + +--- + +# Checkpoint — Vault-load rewrite fix + Amber handshake lands (2026-09-11) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`c096705`** ("fix(vault): stop rewriting the vault on every + load; clippy cleanup"). Previous: `f917e5e` (Amber-compatible handshake). +- Working tree: clean for tracked files (untracked leftovers unchanged — see older + "Still untracked" sections). +- Verification: `cargo test` **200 passed / 0 failed**, `cargo clippy --all-targets` + 0 warnings, `cargo fmt --check` clean, `cargo build --release` green. + (Frontend untouched this session; vite dev server still running on :5173.) + +## What was completed (this session) + +**Carried-forward open question — CLOSED, landed as `c096705`.** +`migrate_vault_signer_modes` used to return `changed = true` unconditionally, so +`App::load` re-encrypted and re-saved the vault on every single start. Now a change +is reported only when `vault.version` actually moves: per-profile `signer_mode` +normalisation was always a no-op (the serde default fills missing fields at parse +time and the current version serialises it explicitly). The idempotency test was +tightened to assert `changed == false` for a current-version vault. Also dropped a +clone-on-Copy in `nip46_client.rs::get_public_key` (clippy warning from `f917e5e`). + +**`f917e5e` (committed earlier today, before this session):** Amber-compatible +NIP-46 handshake — `bunker://` URIs accepted, URI authority key no longer treated +as identity (Amber mints a per-connection comms key; real identity learned via +`get_public_key` after the connect ack), 120 s human-approval window with the +session held in Connecting (fail closed), absent `perms=` delegates enforcement to +the signer, `send_rpc` honours its timeout. **On-device Amber round-trip has not +been re-verified since this commit — that is the next task.** + +## Commits added (newest first) +- `c096705` fix(vault): stop rewriting the vault on every load; clippy cleanup +- `f917e5e` fix(signer): Amber-compatible handshake — bunker:// URIs, deferred + identity, ack wait (landed earlier today) + +## How to reproduce / exercise +- Dev loop (from memory, unchanged): `npx vite --port 5173`, then + `NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .` in + `frontend/` (backend from `target/release/keynectr serve`). Rust edits need + rebuild + backend restart. +- Exercise vault fix: start the app twice; the vault file's mtime should NOT change + on the second start when nothing was modified. + +## Deferred / next steps +1. **Verify Amber end-to-end on device** (pair via Amber, sign a note, publish). +2. Dead stub `src/signer/nip46_external.rs` (untracked, superseded by + `nip46_client.rs`) — delete or fold its docs; `deferred/SignerConnectionPanel.tsx.wip/` + stays deferred. +3. `publish_profile_metadata` (kind 0) still signs locally — reroute through + `Signing` for external profiles. +4. Step 4 (permissions UI), Step 5 (KDF upgrade), Step 6 (undo history), + Step 7 (rename/hygiene incl. `homepage` URL + 5 Prettier files) — unchanged. + +--- + +# Checkpoint — External NIP-46 signing works end-to-end (2026-09-10) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`1af79d8`** ("feat(signer): end-to-end external NIP-46 signing in + publish and upload auth"). +- Working tree: clean for tracked files (untracked leftovers unchanged — see older + "Still untracked" sections). +- Verification: `cargo test` **200 passed / 0 failed**, `cargo clippy --all-targets` + clean, `cargo fmt --check` clean, `cargo build --release` green. + +## What was completed (this session) + +**Step 3 sub-step 2 (IPC reroute) — DONE, landed as `1af79d8`.** Publishing from an +external (NIP-46) profile now signs remotely instead of returning +"External signer not yet supported": + +- **`src/signer/nip46_client.rs`** — the outbound/client half of NIP-46: + `send_remote_request` encrypts a request (NIP-44) and publishes it to the signer's + relay; incoming payloads shaped like responses (`result`/`error`) are demultiplexed + to the waiting caller (a response with no registered id is ignored); 30 s + `REQUEST_TIMEOUT`; pending waiters are woken with errors on disconnect/failure so + callers never hang the full timeout. `Signer::sign_event` is real now: permission + check → remote `sign_event` → verify the returned event (a) is signed by the + connected remote identity, (b) matches the exact unsigned event requested, (c) has a + valid signature — then return it. **No local-key fallback anywhere.** + `audit_permission_denied` uses `try_lock` (audit is best-effort; blocking here would + deadlock the very request being denied while the IPC dispatcher holds the App lock). +- **`src/app.rs`** — `App::signing_for(npub)` / `App::signing_active()`: the single + place that maps a profile's `SignerMode` to a `Signing` source. + `Embedded` → `Signing::Local` with the vault-resolved key; `Nip46Client` → + `Signing::External` wrapping the live signer **only when present and connected**, + else `ExternalSignerNotConnected` (fail closed, never a silent local fallback); + `Nip46Bunker` (not wired) also fails closed. +- **`src/publish.rs`** — `publish_with_keys` builds the unsigned event from the + `Signing`'s own pubkey (external identities validate there before any relay work) + and signs via `Signing::sign`; new `publish_signed` entry point for IPC. The CLI's + `publish_active`/`publish_as` keep the local vault path. +- **`src/ipc.rs`** — `PublishNote` and `UploadAuth` route through + `app.signing_active()`; no handler branches on signer mode anymore. + `Nip46Connect`/`Nip46Disconnect` now clone the signer handle and **drop the App + guard before awaiting** `connect()`/`disconnect()` (they re-lock the App + internally — a latent deadlock, fixed). +- **`src/relays.rs`** — keyless relay pool: `open_pool_inner(Option, …)` so + external signing can publish/relay without local keys (no relay AUTH). +- **`src/uploads.rs`** — `nip98_authorization(url, method, &Signing)` — NIP-98 upload + auth events sign through the same `Signing` source, so uploads authenticate with + the remote signer for external profiles. +- **`src/profiles.rs`** — `store_remote_profile(vault, npub, label)`: connecting a + NIP-46 signer creates/refreshes a **secretless** `Nip46Client` profile row (empty + `secret_key`, made active) so publish has a selection; refuses to silently convert + an existing local profile into a remote one. `connect()` calls it and adopts the + remote npub as the signer's active profile. + +New tests (`src/app.rs`): embedded → `Signing::Local`; external profile with no live +signer → `ExternalSignerNotConnected` (fail closed); no active profile → +`NoActiveProfile`; `store_remote_profile` creates a secretless external profile and +refuses to clobber a local one. + +Security properties: remote-signed events are triple-verified (identity, content +match, signature) before publish; external profiles never touch a local key; the +connection secret stays vault-encrypted (Step 3 sub-step 1 unchanged). + +## Out of scope this session (deliberate) +- `publish_profile_metadata` (kind 0) still signs locally from the vault — a + synchronous key-based path; rerouting it is a separate follow-up. +- Dead `src/signer/nip46_external.rs` stub cleanup (untracked leftover). +- Step 4 (permissions UI), Step 5 (KDF upgrade), Step 7 (rename/hygiene). + +--- + +# Checkpoint — Undo-delete restores working profiles (2026-09-10) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`d101b8e`** ("fix(undo): restore full profile with secret key on undo-delete"). +- Working tree: **clean for tracked files.** Only untracked entries are the pre-existing + hygiene leftovers plus two Rust scratch files (all intentionally untracked — see + "Still untracked"). + +## What was completed (this session) + +**Step 6 (undo history) — the hollow-undo bug is fixed, landed as `d101b8e`.** +Deleting a profile used to keep only a `ProfileSummary` on the undo stack, so +undo re-created the profile with `secret_key = ""` — a dead shell that could never +sign. The undo stack now keeps the full stored record: + +- **`src/profiles.rs`** — new `DeletedProfile { summary, stored }` struct; + `delete_profile_record()` returns the real stored secret (plaintext or encrypted + blob, exactly as on disk) plus the safe UI summary; `delete_profile()` stays as + the summary-only wrapper for callers that keep no undo entry. +- **`src/app.rs`** — `App.undo_history` is now `Vec` (secret material + never leaves the backend); `undo_delete()` restores the real `StoredProfile`, + refuses to create a hollow profile (empty secret → entry handed back + error), + and `state_view()` still exposes only `summary` items so the renderer never sees + a secret. New regression test + `undo_delete_restores_working_profile_without_leaking_secret` locks this in. +- **`src/ipc.rs`** — `DeleteProfile` routes through `delete_profile_record` so the + GUI undo entry carries the secret (previously it pushed a summary-only entry, + so GUI undo was still hollow). +- **`src/main.rs`** — CLI `delete-profile`/`undo-delete` use the same record path + (`delete_profile_direct` → `delete_profile_record`, `cli_undo_delete` → + `app.undo_delete()`); also fixes the old "label looked up after removal" bug by + capturing the label before deletion, and drops the now-unused imports. +- Compile fixes included: the inherited work-in-progress did not build (`DeletedProfile` + vs `ProfileSummary` mismatch in `ipc.rs`, partial moves in `undo_delete`); both + resolved, plus `cargo fmt` applied. + +Security properties: secret material stays backend-only (`AppStateView.undo_history` +is still `Vec`); undo restores the exact stored blob (no re-derivation, +no logging); empty-secret entries fail closed instead of writing hollow profiles. + +## Commits added this session (newest first) +| Hash | Message | +|------|---------| +| `d101b8e` | fix(undo): restore full profile with secret key on undo-delete | + +(Parent chain — `1d5940f` display/icons checkpoint, `d580139` icon alpha fix, +`0814a53` Linux display compat, `715c99c`/`510cb65` connection-secrets vault +integration — is unchanged.) + +## Verification (run this session, on top of `d101b8e`) +- **Rust**: `cargo test` → **197 passed**, 0 failed (196 pre-existing + 1 new + undo regression test); `cargo clippy --all-targets` → clean (exit 0); + `cargo fmt --check` → clean (exit 0); `cargo build --release` → Finished, exit 0. +- **Frontend** (in `frontend/`, Rust-only change so no frontend files touched): + `npm test` → **116/116 passed**; `npm run typecheck` → exit 0; + `npm run lint` → exit 0; `npm run electron:build` → exit 0; `npm run build` → + exit 0. `npm run format:check` → warns on the same 5 pre-existing files + (`ExportSecretKeyModal.tsx`, `SignerModeScreen.tsx`, `AppProvider.tsx`, + `ExportSecretKey.test.tsx`, `fakeBackend.ts`) documented in earlier checkpoints — + not introduced here, left untouched. + +## How to reproduce / exercise +- Backend: `cargo run --release -- serve` (JSON-lines IPC on stdio) or the CLI in + `src/main.rs`. +- GUI: from `frontend/`, `npm run electron:build && electron .` (prod) or `npm run + start:dev` with `NOSTR_GUI_DEV_URL`. +- Exercise undo: Profiles → delete a profile → Undo delete → the restored profile + signs/publishes (previously it came back secret-less). CLI equivalent: + `keynectr delete-profile ` then `keynectr undo-delete`. + +## Still untracked (do NOT lose; do NOT commit the hygiene junk) +- **Source JPEG** `KeynectrAppIconPossibility02.jpeg` — intentionally untracked. +- Pre-existing untracked hygiene leftovers: `COSMIC_THEME.md`, `.opencode/`, + `.impeccable/critique/`, `.directory`, `deferred/SignerConnectionPanel.tsx.wip/`. +- Rust scratch files (unreferenced, harmless — neither is wired into the build): + `src/signer/nip46_external.rs` (dead stub, not declared in `src/signer/mod.rs`), + `src/publish.rs.bak` (backup copy). Left alone this session; delete or wire up + in a later pass. +- Build artifacts `release/` and `dist/` are gitignored and not committed. +- `profiles_vault.json*` and `target/` remain correctly untracked and uncommitted. + +## Deferred / next steps (unchanged, minus the undo item) +- Step 3 sub-step 2 (IPC reroute) remains the next signer milestone; external + (remote) signing in the publish path still returns "not yet supported". +- External-signer permissions (Step 4), deferred security (Step 5: KDF upgrade, + `--allow-env-secret`, gate deprecated `RevealSecretKey`), hygiene (Step 7: + Keynctr rename incl. `package.json` → `homepage`, legacy Python removal, vault + relocation, Prettier pass over the 5 known files). +- Open question carried forward: `migrate_vault_signer_modes` reports a change on + every load (always `changed = true`), so `App::load` re-saves each start. + +--- + # Checkpoint — NIP-46 Connection Secrets in the Vault (2026-09-04) ## Where things are diff --git a/Cargo.lock b/Cargo.lock index 8dd71c3..0c2df61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] @@ -1281,6 +1283,7 @@ version = "0.45.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a" dependencies = [ + "aes 0.8.4", "base64", "bech32", "bip39", diff --git a/Cargo.toml b/Cargo.toml index 6212a43..431527f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -nostr = { version = "0.45", features = ["nip44", "nip98"] } +nostr = { version = "0.45", features = ["nip44", "nip46", "nip98"] } nostr-sdk = "0.45" tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } @@ -20,3 +20,11 @@ rpassword = "7" sha2 = "0.10" async-trait = "0.1" keyring = "4.2" +futures-util = "0.3" + +[dev-dependencies] +base64 = "0.22" +futures-util = "0.3" +getrandom = "0.2" +nostr = "0.45" +tokio-tungstenite = "0.28" diff --git a/DESIGN.md b/DESIGN.md index b496f5c..b1df175 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -109,7 +109,7 @@ components: **Creative North Star: "Vault & Atelier"** -Nostr Keynctr is an atelier, not a dashboard — a warm, quiet workshop where identity work is done with care. The space feels like heavy paper and soft stone, with ink that is near-black, not pure black. Instruments are laid out plainly; nothing shouts for attention. Trust is built through precision: consistent edges, settled type, and state that is always legible. The product truth — keys never leave Rust — is mirrored visually: the UI is restrained, the material is honest, and every destructive or security-relevant moment is given deliberate weight. +Nostr Keynctr is an atelier, not a dashboard — a warm, quiet workshop where identity work is done with care. The space feels like heavy paper and soft stone, with ink that is near-black, not pure black. Instruments are laid out plainly; nothing shouts for attention. Trust is built through precision: consistent edges, settled type, and state that is always legible. The product truth — in local modes keys never leave Rust, and in external-signer mode they never arrive on this machine at all — is mirrored visually: the UI is restrained, the material is honest, and every destructive or security-relevant moment is given deliberate weight. The aesthetic is *warm and human*, not technical or bold. Density is Operate: scannable lists, clear hierarchies, and generous but not loose spacing (8/12/16/20/32). The four themes (light, dark, glass/Aurora, neon) share the same semantic roles; only the material values shift. Neon and glass are gated expressions, never the default. diff --git a/PRODUCT.md b/PRODUCT.md index 0a7bf5d..daae4dd 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -11,10 +11,10 @@ Primary: Linux Nostr users (daily use) who manage one or more keypairs and need Secondary/expanded: Newcomers creating their first Nostr identity via a friendly GUI. The product is progressive — zero-to-first-profile onboarding is frictionless, but the same vault scales to power workflows (multiple profiles, CLI, remote signer). Success means the user can create, select, and publish as any profile, keep keys encrypted at rest, and never feel forced to paste an `nsec` elsewhere. ## Product Purpose -Nostr Keynctr (Nostr Feed Manager) pairs a hardened Rust core with an Electron + React desktop shell so private keys never leave the machine. It makes self-custodied Nostr publishing practical: generate/switch profiles, compose with preview and rich attachments, publish with per-relay receipts, curate relays and feeds, and serve as a NIP-46 remote signer ("bunker") for other Nostr apps. Success is a trustworthy, local-first identity manager you can use daily, via GUI or the same Rust CLI. +Nostr Keynctr (Nostr Feed Manager) pairs a hardened Rust core with an Electron + React desktop shell so private keys stay under your control: in embedded/bunker modes they live only in the local encrypted vault, and in external-signer mode they never touch this machine at all. It makes self-custodied Nostr publishing practical: generate/switch profiles, compose with preview and rich attachments, publish with per-relay receipts, curate relays and feeds, and serve as a NIP-46 remote signer ("bunker") for other Nostr apps. Success is a trustworthy, local-first identity manager you can use daily, via GUI or the same Rust CLI. ## Positioning -**Keys never leave the machine — and the architecture proves it.** The renderer never receives secret material; all key generation, signing, relay communication, and encryption happen inside the Rust backend over a JSON-lines IPC channel, with NIP-46 approval gating every external sign/decrypt request. A neighboring app could copy features, but cannot truthfully copy this verifiable separation while offering the same dual CLI + GUI surface. +**Keys under your control, in whichever mode you choose — and the architecture proves it.** In embedded/bunker modes the renderer never receives secret material: all key generation, signing, relay communication, and encryption happen inside the Rust backend over a JSON-lines IPC channel, with NIP-46 approval gating every external sign/decrypt request. In external-signer mode (Amber, hardware signer, remote bunker) no secret key is present on this machine at all — a stronger posture when the desktop itself is the thing you distrust, since a compromise of this machine cannot extract a key it never held. A neighboring app could copy features, but cannot truthfully copy this verifiable separation while offering the same dual CLI + GUI surface. ## Operating Context Workflows: create/switch/delete/undo profiles, compose (Write/Preview, character count, up to 3 link previews, image pick → nostr.build upload with NIP-92 imeta), publish with per-relay receipts, feed aggregation (all vs. My contacts, 24h window), relay add/remove/enable/disable/test, NIP-05 assignment, secret reveal after unlock, vault backup, lock/unlock. @@ -37,7 +37,7 @@ Name: Nostr Keynctr / Nostr Feed Manager (early beta v0.1.0, MIT, Forgejo-hosted Real content: Rust crate (`src/app, vault, crypto, profiles, publish, relays, signer, feed`), React screens (`Compose, Home, Profiles, Relays, Settings, Signer`), `frontend/src/lib/types`, `AppProvider` context, `fakeBackend` Vitest suite (99 tests), `CHECKPOINT-encryption.md`. No marketing site or pricing; no external testimonials to preserve. ## Product Principles -1. **Keys never leave** — every feature must preserve the Rust/renderer boundary and approval gates; convenience never bypasses explicit consent. +1. **Keys under your control** — every feature must preserve the Rust/renderer boundary and approval gates (secrets never reach the GUI in local modes, and never reach this machine in external-signer mode); convenience never bypasses explicit consent. 2. **Local-first, verifiable** — encrypt at rest, least-privilege files, per-relay receipts, and auditable IPC over transient convenience. 3. **Progressive disclosure** — newcomer can succeed in two clicks; power user can stay in CLI or manage many profiles without UI churn. 4. **One core, two doors** — GUI and CLI remain interchangeable via the same Rust engine; no feature lives only in one surface without justification. diff --git a/README.md b/README.md index 2304ccf..8e28086 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Nostr Feed Manager > A friendly Linux desktop app for managing Nostr profiles, publishing notes, and acting as a -> **NIP-46 remote signer** — all while your private keys never leave your machine. +> **NIP-46 remote signer** — your private keys stay under your control: encrypted in a local +> vault, or, when you connect an external signer, held only on that device. [![Version](https://img.shields.io/badge/version-0.1.0-blue)]() [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) @@ -258,8 +259,13 @@ Your keys are the crown jewels in any Nostr app, and nothing here compromises th (`set-password`, or Settings → Storage). Once set, every secret key is encrypted with **AES-256-GCM** under a key derived from your password with **Argon2id**. Labels and public keys remain readable so you can browse profiles while the vault is locked. -- **In-memory key only.** You unlock once per session; the derived key lives only in memory and is - never written to disk. +- **In-memory key only.** You unlock once per session; the derived key lives only in memory and + is never written to disk. +- **External signer mode can be *more* secure.** The Signer screen can also use a NIP-46 signer + located elsewhere (Amber on your phone, a hardware-backed signer, a bunker you host). In that + mode no secret key exists on this desktop at all — signing happens on the signer device, so a + compromise of this machine cannot expose the key. "Keys never leave the machine" describes + embedded and bunker modes; in external mode the key never *arrives* on this machine. - **Approve-before-any-signing.** The NIP-46 remote signer will not sign, encrypt, or decrypt until you explicitly approve each request. - **Least-privileged storage.** Files are written with directories `0700` and files `0600`. diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index b8be8f2..e6c79eb 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -524,12 +524,15 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'signer_disconnect', 'signer_status', 'signer_approve', + 'signer_grants_list', + 'signer_grant_revoke', // New signer modes (default: nip46_client most secure) 'signer_mode_get', 'signer_mode_set', 'embedded_signer_status', 'embedded_signer_approve', 'nip46_connect', + 'nip46_pair_start', 'nip46_disconnect', 'nip46_status', 'nip46_approve', diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 88f4e9c..d59b4a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "nostr-tools": "^2.25.1", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -19,6 +20,7 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^26.1.2", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^6.1.0", @@ -1537,6 +1539,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", @@ -2028,7 +2040,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2038,7 +2049,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2516,6 +2526,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2608,7 +2627,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2621,7 +2639,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2769,6 +2786,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2898,6 +2924,12 @@ "license": "MIT", "optional": true }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -3187,7 +3219,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -3761,7 +3792,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4218,7 +4248,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5250,6 +5279,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5280,7 +5318,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5394,6 +5431,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -5601,6 +5647,141 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -5693,7 +5874,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5709,6 +5889,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -5936,6 +6122,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6063,7 +6255,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6078,7 +6269,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6764,6 +6954,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 83ec6e5..e30ede6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "nostr-tools": "^2.25.1", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -38,6 +39,7 @@ "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^26.1.2", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^6.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4646a00..8aa3c87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { SignerScreen } from './screens/SignerScreen'; import { SignerModeScreen } from './screens/SignerModeScreen'; import { SettingsScreen } from './screens/SettingsScreen'; import { CreateProfileModal } from './screens/CreateProfileModal'; +import { ImportProfileModal } from './screens/ImportProfileModal'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; import type { Screen } from './lib/navigation'; @@ -21,6 +22,7 @@ function Shell() { const { state, loading, bootstrapError } = useApp(); const [screen, setScreen] = useState('home'); const [createOpen, setCreateOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [unlockOpen, setUnlockOpen] = useState(false); useThemeSync(state?.settings.theme); @@ -68,7 +70,11 @@ function Shell() { )} {screen === 'home' && ( - setCreateOpen(true)} /> + setCreateOpen(true)} + onImportProfile={() => setImportOpen(true)} + /> )} {screen === 'feed' && } {screen === 'profiles' && setCreateOpen(true)} />} @@ -79,6 +85,7 @@ function Shell() { {screen === 'settings' && } setCreateOpen(false)} /> + setImportOpen(false)} /> setUnlockOpen(false)} /> ); diff --git a/frontend/src/components/ExportSecretKeyModal.tsx b/frontend/src/components/ExportSecretKeyModal.tsx index 6aba1c4..c4f9ec5 100644 --- a/frontend/src/components/ExportSecretKeyModal.tsx +++ b/frontend/src/components/ExportSecretKeyModal.tsx @@ -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 { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1bfc078..7d6fdb5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -12,6 +12,7 @@ import type { RelayTestResult, RevealedKey, Settings, + SignerGrant, SignerMode, SignerStatus, UpdateApplyReport, @@ -118,17 +119,26 @@ export const api = { // NIP-46 client signer nip46Connect: (uri: string, label: string) => call('nip46_connect', { uri, label }), + nip46PairStart: (label: string) => call('nip46_pair_start', { label }), nip46Disconnect: () => call('nip46_disconnect'), nip46Status: () => call('nip46_status'), - nip46Approve: (id: string, approved: boolean) => - call('nip46_approve', { id, approved }), + nip46Approve: (id: string, approved: boolean, always = false) => + call('nip46_approve', { id, approved, always }), // Legacy NIP-46 bunker (deprecated, kept for compatibility) signerConnect: (uri: string) => call('signer_connect', { uri }), signerDisconnect: () => call('signer_disconnect'), signerStatus: () => call('signer_status'), - signerApprove: (id: string, approved: boolean) => - call('signer_approve', { id, approved }), + signerApprove: (id: string, approved: boolean, always = false) => + call('signer_approve', { id, approved, always }), + + // Standing "always allow" grants for apps using us as their signer. + signerGrantsList: () => call('signer_grants_list'), + signerGrantRevoke: (appPubkey: string, grantMethod: string) => + call<{ removed: boolean }>('signer_grant_revoke', { + app_pubkey: appPubkey, + grant_method: grantMethod, + }), deleteProfile: (npub: string) => call('delete_profile', { npub }), undoDelete: () => call('undo_delete'), diff --git a/frontend/src/lib/signer/SignerManager.ts b/frontend/src/lib/signer/SignerManager.ts index 063e451..5f58e79 100644 --- a/frontend/src/lib/signer/SignerManager.ts +++ b/frontend/src/lib/signer/SignerManager.ts @@ -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://@?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; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b3d8cf4..d0f98ab 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -29,6 +29,16 @@ export interface PendingApproval { details?: ApprovalDetails; } +/** A standing "always allow" grant: one app may use one method without a + * prompt. Created by choosing "Always allow" on an approval; revoked from + * the Signer screen. */ +export interface SignerGrant { + /** App's hex pubkey this grant applies to. */ + app_pubkey: string; + /** NIP-46 method that runs without prompting (e.g. "sign_event"). */ + method: string; +} + /** Non-secret snapshot of the NIP-46 remote signer for display. */ export interface SignerStatus { phase: SignerPhase; @@ -63,6 +73,8 @@ export interface Nip46SignerStatus { connected_relays: string[]; error?: string; pending_approvals: PendingApproval[]; + /** nostrconnect:// pairing token while a QR pairing is in flight. */ + pairing_uri?: string; } /** Union of all signer statuses. */ @@ -130,6 +142,10 @@ export interface FeedItem { author: string; /** Bech32 `npub` of the author, for display. */ author_npub: string; + /** Author display name from their latest kind-0, when one was found. */ + author_name?: string | null; + /** Author picture URL from their latest kind-0, when one was found. */ + author_picture?: string | null; content: string; /** Unix timestamp the note was created. */ created_at: number; diff --git a/frontend/src/screens/CreateProfileModal.tsx b/frontend/src/screens/CreateProfileModal.tsx index f6ca69b..7c259e5 100644 --- a/frontend/src/screens/CreateProfileModal.tsx +++ b/frontend/src/screens/CreateProfileModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; +import QRCode from 'qrcode'; import { useApp } from '../state/AppProvider'; import { shortenNpub } from '../lib/format'; import { Button } from '../components/Button'; @@ -11,14 +12,15 @@ interface CreateProfileModalProps { onClose: () => void; } -type Phase = 'form' | 'creating' | 'success'; +type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success'; export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { - const { state, createProfile } = useApp(); + const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp(); const [label, setLabel] = useState(''); - const [phase, setPhase] = useState('form'); + const [phase, setPhase] = useState('choice'); const [error, setError] = useState(null); const [createdNpub, setCreatedNpub] = useState(null); + const [pairingQr, setPairingQr] = useState(null); const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`); const inputRef = useRef(null); const shorten = state?.settings.shorten_npub ?? true; @@ -26,15 +28,88 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { useEffect(() => { if (open) { setLabel(''); - setPhase('form'); + setPhase('choice'); setError(null); setCreatedNpub(null); - // Let the modal mount before focusing. + setPairingQr(null); + return undefined; + } + return undefined; + }, [open]); + + // Let the local-form input take focus once that step mounts. + useEffect(() => { + if (phase === 'local') { const frame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(frame); } return undefined; - }, [open]); + }, [phase]); + + // Pairing is a server-side handshake with no push channel: poll the + // signer status while this modal sits on the QR, exactly like the Signer + // Mode screen does. Connected → show success; an error → show it (the + // backend also clears pairing_uri, so the QR view exits on failure). + useEffect(() => { + if (phase !== 'pairing') return undefined; + let cancelled = false; + const tick = async () => { + try { + const status = await nip46Status(); + if (cancelled) return; + if (status.connected) { + await refresh().catch(() => {}); + if (!cancelled) setPhase('paired'); + } else if (status.error) { + if (!cancelled) setError(status.error); + } + } catch { + // Transient IPC errors are fine; the next poll retries. + } + }; + void tick(); + const timer = setInterval(() => void tick(), 2000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [phase, nip46Status, refresh]); + + const [livePairingUri, setLivePairingUri] = useState(null); + useEffect(() => { + if (phase !== 'pairing' || livePairingUri) return undefined; + let cancelled = false; + void nip46Status() + .then((status) => { + if (!cancelled && status.pairing_uri) setLivePairingUri(status.pairing_uri); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [phase, livePairingUri, nip46Status]); + + useEffect(() => { + if (!livePairingUri) { + setPairingQr(null); + return; + } + let cancelled = false; + QRCode.toDataURL(livePairingUri, { + width: 480, + margin: 2, + errorCorrectionLevel: 'M', + }) + .then((url) => { + if (!cancelled) setPairingQr(url); + }) + .catch(() => { + if (!cancelled) setError('Could not render the pairing QR code.'); + }); + return () => { + cancelled = true; + }; + }, [livePairingUri]); const canSubmit = label.trim().length > 0 && phase !== 'creating'; @@ -50,13 +125,157 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { setCreatedNpub(summary.npub); setPhase('success'); } catch (err) { - setPhase('form'); + setPhase('local'); setError(err instanceof Error ? err.message : String(err)); } }; + const startPairing = async () => { + setError(null); + try { + // No user-typed label: the profile is named automatically. The + // backend fetches the account's kind-0 after the handshake and + // upgrades this seed label to the account's real display name + // (adopt_identity background enrichment); a nameless account keeps + // 'Amber', which beats a manual step the user must fight with a + // backspace key (Sep 25 feedback). + const status = await nip46PairStart(label.trim() || 'Amber'); + if (status.pairing_uri) setLivePairingUri(status.pairing_uri); + setPhase('pairing'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + // Leaving the QR view mid-pairing aborts the in-flight pairing; nothing + // was persisted yet, so teardown is safe at any point (same as Signer + // Mode's "Cancel pairing"). + const cancelPairing = async () => { + setLivePairingUri(null); + setPairingQr(null); + setPhase('choice'); + try { + await nip46Disconnect(); + } catch { + // Best-effort abort; a dead pairing attempt expires on its own. + } + }; + + const handleClose = () => { + if (phase === 'pairing') { + void cancelPairing(); + } + onClose(); + }; + + if (phase === 'choice') { + return ( + +
+

How do you want this profile to sign?

+
+ {error && {error}} +
+ +

+ Show a QR code to Amber on your phone — your keys stay on the phone, and every signature + is approved there. +

+ +

+ A brand-new local identity whose private key lives in this app's vault. +

+
+
+ ); + } + + if (phase === 'pairing') { + return ( + +
+

+ Scan this code with Amber (or any NIP-46 signer) and approve the + connection. +

+ {pairingQr ? ( + Pairing QR code + ) : ( +

+ Preparing the pairing code… +

+ )} +

+ Waiting for the signer to scan… the profile appears automatically once approved. The + code expires after a few minutes. +

+ {error && {error}} +
+ +
+ {livePairingUri && ( +
+ Or copy the pairing link + + {livePairingUri} + +
+ )} +
+
+ ); + } + + if (phase === 'paired') { + return ( + +
+ +

Amber is now your signer!

+

+ The connected account was added as a profile and selected. Every signature will ask for + approval in Amber — nothing to install here, and the connection comes back automatically + after restarts. +

+
+ +
+
+
+ ); + } + return ( - + {phase === 'success' && createdNpub ? (
- +
+ + + +
} /> diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index 60c292b..c66a083 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -576,9 +576,11 @@ function RenameModal({ npub: target.npub, perform: () => renameProfile(target.npub, trimmed), successMessage: (report) => - report.failed.length === 0 - ? `Renamed to "${trimmed}" and published to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.` - : `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, + report.succeeded.length === 0 && report.failed.length === 0 + ? `Renamed to "${trimmed}" and saved on this device. Use "Publish name" to announce it network-wide — Amber will ask you to approve.` + : report.failed.length === 0 + ? `Renamed to "${trimmed}" and published to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.` + : `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, onSaved, onError, onSavingChange, diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx index dcabe76..2bdd00f 100644 --- a/frontend/src/screens/SignerModeScreen.tsx +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import QRCode from 'qrcode'; import { Alert } from '../components/Alert'; import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; @@ -14,6 +15,7 @@ export function SignerModeScreen() { embeddedSignerStatus, nip46Status, nip46Connect, + nip46PairStart, nip46Disconnect, nip46Approve, embeddedSignerApprove, @@ -30,14 +32,15 @@ export function SignerModeScreen() { const [error, setError] = useState(null); const [connecting, setConnecting] = useState(false); const [loading, setLoading] = useState(true); + const [pairingQr, setPairingQr] = useState(null); + const [pairError, setPairError] = useState(null); // 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 +56,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 +109,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 +131,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); @@ -129,6 +152,58 @@ export function SignerModeScreen() { } }, [uri, label, nip46Connect]); + // Start a client-initiated pairing: the backend mints a nostrconnect:// + // token and waits for the signer (Amber) to scan it. The token arrives via + // status().pairing_uri; we render it as a QR. + const handlePairStart = useCallback(async () => { + setPairError(null); + setConnecting(true); + try { + const status = await nip46PairStart(label.trim() || 'Remote Signer'); + setNip46StatusState(status); + } catch (err) { + setPairError(err instanceof Error ? err.message : String(err)); + } finally { + setConnecting(false); + } + }, [label, nip46PairStart]); + + const pairingUri = nip46StatusState?.pairing_uri ?? null; + + useEffect(() => { + if (!pairingUri) { + setPairingQr(null); + return; + } + let cancelled = false; + QRCode.toDataURL(pairingUri, { + width: 480, + margin: 2, + errorCorrectionLevel: 'M', + }) + .then((url) => { + if (!cancelled) setPairingQr(url); + }) + .catch(() => { + if (!cancelled) setPairError('Could not render the pairing QR code.'); + }); + return () => { + cancelled = true; + }; + }, [pairingUri]); + + // Abort an in-flight pairing (e.g. expired QR) — same teardown as a + // disconnect; nothing was persisted yet so it is safe at any point. + const handlePairCancel = useCallback(async () => { + setPairError(null); + try { + const status = await nip46Disconnect(); + setNip46StatusState(status); + } catch (err) { + setPairError(err instanceof Error ? err.message : String(err)); + } + }, [nip46Disconnect]); + const handleNip46Disconnect = useCallback(async () => { setError(null); try { @@ -153,10 +228,10 @@ export function SignerModeScreen() { ); const handleNip46Approve = useCallback( - async (id: string, approved: boolean) => { + async (id: string, approved: boolean, always = false) => { setError(null); try { - const status = await nip46Approve(id, approved); + const status = await nip46Approve(id, approved, always); setNip46StatusState(status); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -183,12 +258,16 @@ export function SignerModeScreen() { return isEmbeddedActive ? ( Embedded (Least Secure) ) : ( - Embedded {vaultLocked ? '(Vault Locked)' : ''} + + Embedded {vaultLocked ? '(Vault Locked)' : ''} + ); }; 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 +316,15 @@ export function SignerModeScreen() {
Keypair - {hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'} + + {hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'} +
Vault - {vaultLocked ? 'Locked' : 'Unlocked / No password'} + + {vaultLocked ? 'Locked' : 'Unlocked / No password'} +
Current Mode @@ -249,12 +332,20 @@ export function SignerModeScreen() {
{!hasProfile && ( - )} {hasProfile && vaultLocked && ( - )} @@ -269,7 +360,9 @@ export function SignerModeScreen() {
{/* 1. Most Secure */} -
{/* 2. Moderately Secure */} -
{/* 3. Least Secure */} -
{mode === 'nip46_bunker' && !canSwitchToBunker && ( - {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.'} )} {mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && ( @@ -364,7 +469,8 @@ export function SignerModeScreen() { )} {!hasProfile && mode !== 'nip46_client' && ( - 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.) )} {error && {error}} @@ -379,12 +485,14 @@ export function SignerModeScreen() { {embeddedStatus!.pending.length}
- {(embeddedStatus!.pending).map((req, idx) => ( + {embeddedStatus!.pending.map((req, idx) => (
{req.method}

{req.summary}

- {req.details?.is_sensitive && Sensitive} + {req.details?.is_sensitive && ( + Sensitive + )}
@@ -427,10 +543,22 @@ export function SignerModeScreen() {

{r.summary}

- - +
@@ -439,12 +567,59 @@ export function SignerModeScreen() {
)}
+ ) : pairingUri ? ( +
+

+ Scan this code with Amber (or any NIP-46 signer) to connect. +

+ {pairingQr && ( + Pairing QR code + )} +

+ Waiting for the signer to scan… the connection appears automatically once + approved. The code expires after a few minutes. +

+ {nip46StatusState?.error && ( + + {nip46StatusState.error} + + )} + {pairError && {pairError}} +
+ +
+
+ Or copy the pairing link + + {pairingUri} + +
+
) : (
setUri(e.target.value)} autoComplete="off" @@ -452,17 +627,51 @@ export function SignerModeScreen() { />

{mode === 'nip46_client' - ? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.' + ? 'Easiest: press “Show QR” below and scan it with Amber. Or paste a bunker:// link from a self-hosted bunker / nostrconnect:// link from another app.' : 'Share this with client apps that want to connect to this bunker.'}

- setLabel(e.target.value)} placeholder="Remote Signer" /> + setLabel(e.target.value)} + placeholder="Remote Signer" + />
{error && {error}} + {pairError && {pairError}} + {/* A failed handshake must never look like an idle form: + surface the backend's error so a timeout is visible. */} + {nip46StatusState?.error && ( + + {nip46StatusState.error} + + )} + {/* A sent-but-unapproved connection request is in flight: + say so instead of showing a blank form. */} + {!nip46StatusState?.error && (nip46StatusState?.relays?.length ?? 0) > 0 && ( +

+ Connection request sent — approve it in Amber. This updates automatically; it + can take up to a couple of minutes on a slow network. +

+ )}
- + )} + + +
+
+ ))} + + + )} +

Connect a Nostr app

diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 602369a..543620c 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -21,6 +21,7 @@ import type { RelayTestResult, RevealedKey, Settings, + SignerGrant, SignerMode, SignerStatus, Theme, @@ -79,14 +80,17 @@ interface AppContextValue { embeddedSignerApprove: (index: number, approved: boolean) => Promise; // NIP-46 client signer nip46Connect: (uri: string, label: string) => Promise; + nip46PairStart: (label: string) => Promise; nip46Disconnect: () => Promise; nip46Status: () => Promise; - nip46Approve: (id: string, approved: boolean) => Promise; + nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise; // Legacy NIP-46 bunker (deprecated) signerConnect: (uri: string) => Promise; signerDisconnect: () => Promise; signerStatus: () => Promise; - signerApprove: (id: string, approved: boolean) => Promise; + signerApprove: (id: string, approved: boolean, always?: boolean) => Promise; + signerGrantsList: () => Promise; + signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>; deleteProfile: (npub: string) => Promise; undoDelete: () => Promise; clearLastDeleted: () => void; @@ -103,7 +107,17 @@ export function AppProvider({ children }: { children: ReactNode }) { const refresh = useCallback(async () => { const fresh = await api.getState(); - setState(fresh); + // The 5s poll must be silent when nothing changed: a fresh object + // identity every tick would re-render every screen and refire effects + // keyed on state slices (e.g. Home's publications loader flickering + // between "Loading…" and done forever). + setState((prev) => { + try { + return JSON.stringify(prev) === JSON.stringify(fresh) ? prev : fresh; + } catch { + return fresh; + } + }); }, []); useEffect(() => { @@ -137,6 +151,18 @@ export function AppProvider({ children }: { children: ReactNode }) { }; }, []); + // Background pairing completes server-side with no push channel to the UI + // (IPC is request/response), so poll for fresh state: otherwise Home and + // Profiles keep showing the pre-pairing snapshot after Amber connects. + // getState is a cheap local vault read; errors are ignored here since every + // screen surfaces its own request failures. + useEffect(() => { + const timer = setInterval(() => { + void refresh().catch(() => {}); + }, 5000); + return () => clearInterval(timer); + }, [refresh]); + const createProfile = useCallback( async (label: string): Promise => { const result = await api.createProfile(label, state?.settings); @@ -264,8 +290,15 @@ export function AppProvider({ children }: { children: ReactNode }) { ); const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []); const nip46Status = useCallback(() => api.nip46Status(), []); + const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []); const nip46Approve = useCallback( - (id: string, approved: boolean) => api.nip46Approve(id, approved), + (id: string, approved: boolean, always = false) => api.nip46Approve(id, approved, always), + [], + ); + + const signerGrantsList = useCallback(() => api.signerGrantsList(), []); + const signerGrantRevoke = useCallback( + (appPubkey: string, grantMethod: string) => api.signerGrantRevoke(appPubkey, grantMethod), [], ); @@ -284,8 +317,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(), []); @@ -351,6 +383,7 @@ export function AppProvider({ children }: { children: ReactNode }) { embeddedSignerStatus, embeddedSignerApprove, nip46Connect, + nip46PairStart, nip46Disconnect, nip46Status, nip46Approve, @@ -358,6 +391,8 @@ export function AppProvider({ children }: { children: ReactNode }) { signerDisconnect, signerStatus, signerApprove, + signerGrantsList, + signerGrantRevoke, deleteProfile, undoDelete, publishProfileMetadata, @@ -408,6 +443,7 @@ export function AppProvider({ children }: { children: ReactNode }) { embeddedSignerStatus, embeddedSignerApprove, nip46Connect, + nip46PairStart, nip46Disconnect, nip46Status, nip46Approve, @@ -415,6 +451,8 @@ export function AppProvider({ children }: { children: ReactNode }) { signerDisconnect, signerStatus, signerApprove, + signerGrantsList, + signerGrantRevoke, copyText, ], ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4294cb6..3d1f373 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1402,6 +1402,18 @@ select { margin-top: 8px; } +.onboarding-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.onboarding-actions .btn { + min-width: 260px; + justify-content: center; +} + /* ------------------------------------------------------------------------- Home ------------------------------------------------------------------------- */ diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index 221d7dc..0c8a526 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -16,13 +16,13 @@ describe('App', () => { render(); expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create a new profile/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument(); expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /Create your first profile/i })); - expect( - await screen.findByRole('dialog', { name: 'Create a Nostr profile' }), - ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Create a new profile/i })); + expect(await screen.findByRole('dialog', { name: 'Add a profile' })).toBeInTheDocument(); }); it('renders the main screen after loading with an existing profile', async () => { diff --git a/frontend/src/test/CreateProfileModal.test.tsx b/frontend/src/test/CreateProfileModal.test.tsx index 0fc5225..da456cb 100644 --- a/frontend/src/test/CreateProfileModal.test.tsx +++ b/frontend/src/test/CreateProfileModal.test.tsx @@ -5,14 +5,39 @@ import { renderWithApp } from './render'; import { makeEmptyState } from './apiMock'; import { createFakeBackend, installFakeBackend } from './fakeBackend'; +vi.mock('qrcode', () => ({ + default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') }, +})); + +async function startPairingFlow(user: ReturnType) { + await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ })); +} + describe('CreateProfileModal', () => { - it('creates a profile through the backend and shows a success confirmation', async () => { + it('offers the signer (Amber) and local-key choices first', async () => { + const backend = createFakeBackend(makeEmptyState()); + installFakeBackend(backend); + renderWithApp(); + + await screen.findByRole('dialog', { name: 'Add a profile' }); + expect( + screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Create a new key on this computer/ }), + ).toBeInTheDocument(); + }); + + it('creates a local-key profile through the backend and shows a success confirmation', async () => { const backend = createFakeBackend(makeEmptyState()); installFakeBackend(backend); const onClose = vi.fn(); renderWithApp(); - await screen.findByRole('dialog', { name: 'Create a Nostr profile' }); + await screen.findByRole('dialog', { name: 'Add a profile' }); + await userEvent + .setup() + .click(screen.getByRole('button', { name: /Create a new key on this computer/ })); const input = screen.getByLabelText('Profile name'); await userEvent.setup().type(input, 'Sam'); @@ -36,20 +61,85 @@ describe('CreateProfileModal', () => { installFakeBackend(backend); renderWithApp(); - await screen.findByRole('dialog', { name: 'Create a Nostr profile' }); + await screen.findByRole('dialog', { name: 'Add a profile' }); + await userEvent + .setup() + .click(screen.getByRole('button', { name: /Create a new key on this computer/ })); expect(screen.getByRole('button', { name: 'Create profile' })).toBeDisabled(); }); - it('closes without creating when Cancel is clicked', async () => { + it('closes without creating when Back then close is used', async () => { const backend = createFakeBackend(makeEmptyState()); installFakeBackend(backend); const onClose = vi.fn(); renderWithApp(); + await userEvent + .setup() + .click(screen.getByRole('button', { name: /Create a new key on this computer/ })); await userEvent.setup().type(screen.getByLabelText('Profile name'), 'Sam'); - await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' })); - - expect(onClose).toHaveBeenCalled(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Back' })); + // Back returns to the choice step; nothing was created yet. expect(backend.state.profiles).toHaveLength(0); + expect( + screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }), + ).toBeInTheDocument(); + }); + + it('starts Amber pairing from the choice step and shows the QR', async () => { + const backend = createFakeBackend(makeEmptyState()); + installFakeBackend(backend); + renderWithApp(); + + const user = userEvent.setup(); + // No label step (Sep 25 feedback: typing/fighting a prefilled name was + // friction). One click mints the QR with the 'Amber' seed label; the + // backend upgrades it to the account's real kind-0 display name. + await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ })); + + expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument(); + const start = backend.requests.find((r) => r.method === 'nip46_pair_start'); + expect(start?.params.label).toBe('Amber'); + expect(await screen.findByAltText('Pairing QR code')).toBeInTheDocument(); + }); + + it('shows the connected confirmation once the poll reports the signer online', async () => { + const backend = createFakeBackend(makeEmptyState()); + installFakeBackend(backend); + renderWithApp(); + + const user = userEvent.setup(); + await startPairingFlow(user); + await screen.findByText(/Waiting for the signer to scan/i); + + // Amber approves: the fake backend now reports the handshake done. The + // modal polls the signer status every 2s, so wait past one interval. + backend.setNip46({ + type: 'nip46', + connected: true, + relays: ['wss://relay.test'], + connected_relays: ['wss://relay.test'], + pending_approvals: [], + }); + + expect( + await screen.findByText('Amber is now your signer!', {}, { timeout: 5000 }), + ).toBeInTheDocument(); + }); + + it('aborts an in-flight pairing when Cancel pairing is clicked', async () => { + const backend = createFakeBackend(makeEmptyState()); + installFakeBackend(backend); + renderWithApp(); + + const user = userEvent.setup(); + await startPairingFlow(user); + await screen.findByText(/Waiting for the signer to scan/i); + + await user.click(screen.getByRole('button', { name: 'Cancel pairing' })); + expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true); + expect( + screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }), + ).toBeInTheDocument(); }); }); diff --git a/frontend/src/test/ExportSecretKey.test.tsx b/frontend/src/test/ExportSecretKey.test.tsx index cfbb87b..26d680f 100644 --- a/frontend/src/test/ExportSecretKey.test.tsx +++ b/frontend/src/test/ExportSecretKey.test.tsx @@ -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(); }); }); diff --git a/frontend/src/test/FeedScreen.test.tsx b/frontend/src/test/FeedScreen.test.tsx index bb2a93c..99c3d1f 100644 --- a/frontend/src/test/FeedScreen.test.tsx +++ b/frontend/src/test/FeedScreen.test.tsx @@ -22,6 +22,22 @@ describe('FeedScreen', () => { expect(screen.getByText('2 relays')).toBeInTheDocument(); }); + it('shows the author name and picture when the feed resolved them', async () => { + const backend = createFakeBackend(); + backend.feedItems = backend.feedItems.map((item, index) => + index === 0 + ? { ...item, author_name: 'Alice Liddell', author_picture: 'https://example.com/alice.png' } + : item, + ); + renderFeed(backend); + renderWithApp(); + + expect(await screen.findByText('Alice Liddell')).toBeInTheDocument(); + // The avatar image is decorative (empty alt), so query by src. + const avatar = document.querySelector('img[src="https://example.com/alice.png"]'); + expect(avatar).not.toBeNull(); + }); + it('disable relays shows an empty state that can navigate to relays', async () => { const settings = { theme: 'light' as const, diff --git a/frontend/src/test/HomeScreen.test.tsx b/frontend/src/test/HomeScreen.test.tsx index 9eec91b..9ba0a1f 100644 --- a/frontend/src/test/HomeScreen.test.tsx +++ b/frontend/src/test/HomeScreen.test.tsx @@ -7,13 +7,18 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend'; function renderHome( backend: ReturnType, - overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {}, + overrides: { + onNavigate?: () => void; + onCreateProfile?: () => void; + onImportProfile?: () => void; + } = {}, ) { installFakeBackend(backend); return { user: userEvent.setup(), onNavigate: overrides.onNavigate ?? vi.fn(), onCreateProfile: overrides.onCreateProfile ?? vi.fn(), + onImportProfile: overrides.onImportProfile ?? vi.fn(), }; } @@ -21,7 +26,9 @@ describe('HomeScreen', () => { it('shows the active profile, a shortened npub, and a compose button', async () => { const backend = createFakeBackend(); const { onNavigate } = renderHome(backend); - renderWithApp(); + renderWithApp( + , + ); // The active profile appears in the profile list with its shortened npub. const profileList = await screen.findByRole('listbox'); @@ -36,7 +43,9 @@ describe('HomeScreen', () => { it('copies the complete npub when the copy button is clicked', async () => { const backend = createFakeBackend(); renderHome(backend); - renderWithApp(); + renderWithApp( + , + ); // The active profile row carries the "Selected" badge; find Alice via the profile list. const profileList = await screen.findByRole('listbox'); @@ -51,20 +60,32 @@ describe('HomeScreen', () => { it('shows the first-run state and guides the user to create a profile', async () => { const backend = createFakeBackend(makeEmptyState()); - const { onCreateProfile } = renderHome(backend); - renderWithApp(); + const { onCreateProfile, onImportProfile } = renderHome(backend); + renderWithApp( + , + ); expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: /Create a new profile/i })); + expect(onCreateProfile).toHaveBeenCalled(); await userEvent .setup() - .click(screen.getByRole('button', { name: /Create your first profile/i })); - expect(onCreateProfile).toHaveBeenCalled(); + .click(screen.getByRole('button', { name: /I already have an account/i })); + expect(onImportProfile).toHaveBeenCalled(); }); it('selects a profile when its row is clicked (not just the Select button)', async () => { const backend = createFakeBackend(); renderHome(backend); - renderWithApp(); + renderWithApp( + , + ); const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement; // Clicking the name (not the Select button) should select the profile. @@ -77,4 +98,31 @@ describe('HomeScreen', () => { const aliceRow = screen.getByText('Alice').closest('.home-profile-row') as HTMLElement; expect(within(aliceRow).getByRole('button', { name: 'Select' })).toBeInTheDocument(); }); + + it('does not refetch publications on every background state poll', async () => { + // Regression: the 5s AppProvider poll must not refire the publications + // loader (it flickered Home between "Loading…" and done forever). + // Fake timers from the start: the poll interval must be scheduled under + // fake time, and RTL async queries stall under fake timers, so drive + // everything with explicit timer advances instead. + vi.useFakeTimers(); + try { + const backend = createFakeBackend(); + renderHome(backend); + renderWithApp( + , + ); + + // Initial load completes. + await vi.advanceTimersByTimeAsync(500); + const initialFetches = backend.requests.filter((r) => r.method === 'feed_get').length; + expect(initialFetches).toBeGreaterThan(0); + + // Two full poll ticks with unchanged state must not refetch. + await vi.advanceTimersByTimeAsync(12000); + expect(backend.requests.filter((r) => r.method === 'feed_get').length).toBe(initialFetches); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/frontend/src/test/SignerModeScreen.test.tsx b/frontend/src/test/SignerModeScreen.test.tsx new file mode 100644 index 0000000..a4a94f4 --- /dev/null +++ b/frontend/src/test/SignerModeScreen.test.tsx @@ -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(); + + 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(); + + 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(); + + 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), + ); + }); +}); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index f4f2551..47e6954 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -2,10 +2,12 @@ import type { AppState, BackendResponse, FeedItem, + Nip46SignerStatus, ProfileSummary, PublishReport, RelayTestResult, Settings, + SignerGrant, SignerStatus, UpdateApplyReport, UpdateCheckReport, @@ -41,6 +43,11 @@ export interface FakeBackend { /** Current NIP-46 signer status. */ signer: SignerStatus; 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. */ + signerGrants: SignerGrant[]; /** Notes returned by `feed_get`. */ feedItems: FeedItem[]; /** Notes returned by `feed_get` with `contacts_only: true`. */ @@ -106,6 +113,17 @@ export function createFakeBackend(initial?: AppState): FakeBackend { setSigner(next) { backend.signer = next; }, + nip46: { + type: 'nip46', + connected: false, + relays: [], + connected_relays: [], + pending_approvals: [], + }, + setNip46(next) { + backend.nip46 = next; + }, + signerGrants: [], feedItems: [ { id: 'note1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -175,7 +193,41 @@ export function createFakeBackend(initial?: AppState): FakeBackend { switch (method) { case 'init': case 'get_state': - return state; + // Deep-copy like the real IPC boundary (fresh JSON per call), so + // tests observe new object identities exactly as production does. + return structuredClone(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': { const label = String(params.label ?? ''); @@ -344,14 +396,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend { case 'signer_approve': { const id = String(params.id ?? ''); + const entry = backend.signer.pending.find((request) => request.id === id); const next: SignerStatus = { ...backend.signer, pending: backend.signer.pending.filter((request) => request.id !== id), }; backend.setSigner(next); + if (params.approved === true && params.always === true && entry) { + if (!backend.signerGrants.some((g) => g.method === entry.method)) { + backend.signerGrants = [ + ...backend.signerGrants, + { app_pubkey: backend.signer.peer ?? '', method: entry.method }, + ]; + } + } return next; } + case 'signer_grants_list': + return backend.signerGrants; + + case 'signer_grant_revoke': { + const app = String(params.app_pubkey ?? ''); + const method = String(params.grant_method ?? ''); + const before = backend.signerGrants.length; + backend.signerGrants = backend.signerGrants.filter( + (g) => !(g.app_pubkey === app && g.method === method), + ); + return { removed: backend.signerGrants.length < before }; + } + case 'relay_add': { const url = String(params.url); const nextSettings: Settings = { @@ -445,10 +519,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 +534,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)}` }; diff --git a/frontend/src/test/publications.test.tsx b/frontend/src/test/publications.test.tsx index 90d62c8..02583f8 100644 --- a/frontend/src/test/publications.test.tsx +++ b/frontend/src/test/publications.test.tsx @@ -23,7 +23,9 @@ function makeItem(overrides: Partial & { id: string; relays: string[] function renderHome(backend: ReturnType) { installFakeBackend(backend); - renderWithApp(); + renderWithApp( + , + ); } async function waitForData() { diff --git a/src/app.rs b/src/app.rs index b6f9372..8ee1134 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,9 +9,12 @@ use crate::crypto::{self, VaultKey}; use crate::errors::AppError; use crate::profiles::{self, ProfileSummary}; use crate::settings::Settings; +use crate::signer::Signer as SignerTrait; +use crate::signer::Signing; use crate::vault::{ self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto, }; +use nostr_sdk::prelude::{Keys, PublicKey}; /// Minimum password length accepted when encrypting the vault. pub const MIN_PASSWORD_LEN: usize = 8; @@ -22,8 +25,10 @@ pub struct App { pub settings: Settings, /// Derived vault key, present only while the encrypted vault is unlocked. unlock_key: Option, - /// Stack of deleted profiles for undo functionality. - pub undo_history: Vec, + /// Stack of deleted profiles for undo functionality. Holds the full + /// stored record (including secret key material, exactly as it was on + /// disk) so undo restores a working profile, not an empty shell. + pub undo_history: Vec, /// The most recent publish report, persisted across restarts. pub last_publish: Option, /// Active signer mode. @@ -60,7 +65,8 @@ pub struct AppStateView { pub active_profile: Option, pub profiles: Vec, pub settings: Settings, - /// Recently deleted profiles, newest last, for undo. + /// Recently deleted profiles (safe summaries only — never secret material), + /// newest last, for undo. #[serde(skip_serializing_if = "Vec::is_empty")] pub undo_history: Vec, /// The most recent publish report, persisted across restarts. @@ -113,6 +119,56 @@ impl App { self.vault.is_encrypted() && self.unlock_key.is_none() } + /// The [`Signing`] source for user content of a specific profile. + /// + /// The single place the "where does signing happen" decision is made, so + /// no caller branches on signer mode itself: + /// + /// - Profile mode `Embedded` → [`Signing::Local`] with the vault-resolved + /// key (locked vault surfaces as the usual `VaultLocked` error). + /// - Profile mode `Nip46Client` → [`Signing::External`] wrapping the live + /// NIP-46 client signer, **only** when one is present and connected. + /// Never falls back to the local key: an external profile that cannot + /// reach its signer fails with `ExternalSignerNotConnected`. + /// - `Nip46Bunker` (legacy, not wired) → fails closed like a missing + /// connection. + pub async fn signing_for(&self, npub: &str) -> Result { + let profile = profiles::find_stored_profile(&self.vault, npub)?; + match profile.signer_mode { + SignerMode::Embedded => { + let secret_hex = profiles::resolve_secret_key(&self.vault, npub, self.vault_key())?; + let secret_key = profiles::parse_secret_key(&secret_hex)?; + Ok(Signing::Local(Keys::new(secret_key))) + } + SignerMode::Nip46Client => { + let Some(signer) = self.nip46_signer.clone() else { + return Err(AppError::external_signer_not_connected()); + }; + if !signer.is_available().await { + return Err(AppError::external_signer_not_connected()); + } + let profile_pubkey = PublicKey::parse(npub).map_err(|e| { + AppError::internal(format!("Stored profile npub is not valid: {e}")) + })?; + Ok(Signing::External { + signer, + profile_pubkey, + }) + } + SignerMode::Nip46Bunker => Err(AppError::external_signer_not_connected()), + } + } + + /// [`Signing`] for the active profile (see [`App::signing_for`]). + pub async fn signing_active(&self) -> Result { + let npub = self + .vault + .active_profile + .clone() + .ok_or_else(AppError::no_active_profile)?; + self.signing_for(&npub).await + } + /// Verify a password and keep the derived key in memory for the session. pub fn unlock(&mut self, password: &str) -> Result<(), AppError> { let crypto = self @@ -215,36 +271,45 @@ impl App { Ok(revealed) } - /// Undo the last profile deletion, restoring the profile to the vault. + /// Undo the last profile deletion, restoring the profile — with its real + /// stored secret key — to the vault. /// Returns the restored profile summary, or an error if there is no undo history. pub fn undo_delete(&mut self) -> Result { - if self.undo_history.is_empty() { + let Some(deleted) = self.undo_history.pop() else { return Err(AppError::config("No profile deletions to undo.")); - } - let restored = self.undo_history.pop().unwrap(); - // Re-add the profile to the vault + }; + let restored = deleted.stored.clone(); + // Re-add the profile to the vault unless it is somehow already there. if !self .vault .profiles .iter() - .any(|p| p.public_key == restored.npub) + .any(|p| p.public_key == restored.public_key) { - let stored = StoredProfile { - label: restored.label.clone(), - public_key: restored.npub.clone(), - secret_key: "".to_string(), - created_at: restored.created_at, - picture: restored.picture.clone(), - nip05: restored.nip05.clone(), - signer_mode: SignerMode::Embedded, - }; - self.vault.profiles.push(stored); - // If no active profile, this restored one becomes active - if self.vault.active_profile.is_none() { - self.vault.active_profile = Some(restored.npub.clone()); + // A secret-less record (should not happen for records created by + // delete_profile_record) must not silently create a hollow + // profile: refuse and hand the entry back rather than corrupt the + // vault. + if restored.secret_key.trim().is_empty() { + self.undo_history.push(deleted); + return Err(AppError::internal( + "The undo entry is missing its secret key; the profile was not restored.", + )); } + self.vault + .active_profile + .get_or_insert(restored.public_key.clone()); + self.vault.profiles.push(restored.clone()); } - Ok(restored) + let is_active = self.vault.active_profile.as_deref() == Some(restored.public_key.as_str()); + Ok(ProfileSummary { + label: restored.label.clone(), + npub: restored.public_key.clone(), + created_at: restored.created_at, + is_active, + picture: restored.picture.clone(), + nip05: restored.nip05.clone(), + }) } /// Protect the vault with `new_password`, re-encrypting every stored key. @@ -361,7 +426,11 @@ impl App { active_profile: profiles::active_summary(&self.vault), profiles: profiles::summaries(&self.vault), settings: self.settings.clone(), - undo_history: self.undo_history.clone(), + undo_history: self + .undo_history + .iter() + .map(|deleted| deleted.summary.clone()) + .collect(), last_publish: self.last_publish.clone(), signer_mode: self.signer_mode, } @@ -435,6 +504,70 @@ mod tests { } } + #[test] + fn signing_for_embedded_profile_yields_local_signing() { + let app = sample_app(); + let npub = app.vault.profiles[0].public_key.clone(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let signing = runtime + .block_on(app.signing_for(&npub)) + .expect("embedded profile must select local signing"); + assert!(matches!(signing, crate::signer::Signing::Local(_))); + } + + #[test] + fn signing_for_external_profile_without_connection_fails_closed() { + let mut app = sample_app(); + // Mark the active profile as externally signed; no signer is + // connected (and none can be without a live NIP-46 session). + app.vault.profiles[0].signer_mode = SignerMode::Nip46Client; + let npub = app.vault.profiles[0].public_key.clone(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + match runtime.block_on(app.signing_for(&npub)) { + Err(err) => assert_eq!(err.kind(), ErrorKind::ExternalSignerNotConnected), + Ok(_) => panic!("external profile with no signer must fail closed"), + } + } + + #[test] + fn signing_active_requires_a_profile() { + let mut app = sample_app(); + app.vault.active_profile = None; + let runtime = tokio::runtime::Runtime::new().unwrap(); + match runtime.block_on(app.signing_active()) { + Err(err) => assert_eq!(err.kind(), ErrorKind::NoActiveProfile), + Ok(_) => panic!("no active profile must error"), + } + } + + #[test] + fn store_remote_profile_creates_secretless_external_profile() { + use nostr::nips::nip19::ToBech32; + let mut vault = plaintext_vault(); + let remote = Keys::generate(); + let npub = remote.public_key().to_bech32().unwrap(); + let summary = profiles::store_remote_profile(&mut vault, &npub, "Remote".to_string()) + .expect("remote profile must be created"); + assert_eq!(summary.npub, npub); + assert!(summary.is_active); + let stored = profiles::find_stored_profile(&vault, &npub).unwrap(); + assert_eq!(stored.signer_mode, SignerMode::Nip46Client); + assert!(stored.secret_key.is_empty(), "no local secret for remote"); + } + + #[test] + fn store_remote_profile_refuses_to_clobber_local_profile() { + let mut vault = plaintext_vault(); + let existing = vault.profiles[0].public_key.clone(); + let err = profiles::store_remote_profile(&mut vault, &existing, "Hijack".to_string()) + .expect_err("a local profile must not be converted silently"); + assert!(err.message().contains("local profile")); + // Untouched: still embedded, secret intact, active unchanged. + let stored = profiles::find_stored_profile(&vault, &existing).unwrap(); + assert_eq!(stored.signer_mode, SignerMode::Embedded); + assert!(!stored.secret_key.is_empty()); + } + #[test] fn set_password_encrypts_every_secret() { let mut app = sample_app(); @@ -622,4 +755,32 @@ mod tests { assert_eq!(view.profiles.len(), 2); assert!(view.profiles.iter().all(|p| p.npub.starts_with("npub1"))); } + + #[test] + fn undo_delete_restores_working_profile_without_leaking_secret() { + let mut app = sample_app(); + let target = app.vault.profiles[0].clone(); + let secret = target.secret_key.clone(); + + let deleted = profiles::delete_profile_record(&mut app.vault, &target.public_key).unwrap(); + app.undo_history.push(deleted); + assert_eq!(app.vault.profiles.len(), 1); + + let restored = app.undo_delete().unwrap(); + assert_eq!(restored.npub, target.public_key); + assert_eq!(app.vault.profiles.len(), 2); + let stored = app + .vault + .profiles + .iter() + .find(|p| p.public_key == target.public_key) + .unwrap(); + assert_eq!(stored.secret_key, secret, "undo must restore the real key"); + assert!(!stored.secret_key.is_empty()); + + // The UI-facing view carries summaries only — never secret material. + let view_json = serde_json::to_string(&app.state_view()).unwrap(); + assert!(!view_json.contains(&secret)); + assert!(app.undo_history.is_empty()); + } } diff --git a/src/bunker.rs b/src/bunker.rs index 307f69a..3f2b47b 100644 --- a/src/bunker.rs +++ b/src/bunker.rs @@ -750,10 +750,21 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C Ok(request) => request, Err(_) => continue, }; - // Key-using methods wait for an explicit user approval before they run; - // everything else is answered immediately. + // Key-using methods wait for an explicit user approval before they + // run — unless the user granted this app standing "always allow" + // permission for that method. Everything else is answered immediately. let response = if requires_approval(&request.method) { - gated_response(&signer, &keys, &request).await + let granted = { + let guard = app.lock().await; + guard + .vault + .has_signer_grant(&uri.peer.to_hex(), &request.method) + }; + if granted { + approved_response(&keys, &request) + } else { + gated_response(&signer, &keys, &request).await + } } else { handle_request(&signer, &keys, &uri, &request) }; diff --git a/src/feed.rs b/src/feed.rs index 2674a62..f876012 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -44,6 +44,11 @@ pub struct FeedItem { pub author: String, /// Bech32 `npub` of the author, for display. pub author_npub: String, + /// Author display name from their latest kind-0, when one was found. + /// `None` means "show the npub" — never an error. + pub author_name: Option, + /// Author picture URL from their latest kind-0, when one was found. + pub author_picture: Option, pub content: String, /// Unix timestamp the note was created. pub created_at: u64, @@ -181,7 +186,96 @@ async fn aggregate_for( } client.disconnect().await; - Ok(feed.finish()) + let mut items = feed.finish(); + // Best-effort author enrichment: one batched kind-0 lookup for every + // distinct author, so the feed can show names/pictures instead of bare + // npubs. Runs on a fresh throwaway pool (the client above is already + // disconnected); any failure just leaves the npub fallback in place. + attach_author_metadata(&mut items, &relay_urls).await; + Ok(items) +} + +/// How long the batched kind-0 author lookup may take. Short on purpose: +/// names are decoration, and the notes themselves are already in hand. +const AUTHOR_TIMEOUT: Duration = Duration::from_secs(8); + +/// Fill `author_name` / `author_picture` for feed items from the authors' +/// latest kind-0 metadata. One batched relay query for all distinct authors; +/// silently does nothing when relays are unreachable, so the npub fallback +/// always survives. +async fn attach_author_metadata(items: &mut [FeedItem], relay_urls: &[String]) { + use std::collections::HashSet; + + let authors: Vec = { + let mut seen = HashSet::new(); + items + .iter() + .filter_map(|item| PublicKey::from_hex(&item.author).ok()) + .filter(|key| seen.insert(key.to_hex())) + .collect() + }; + if authors.is_empty() || relay_urls.is_empty() { + return; + } + let client = Client::builder() + .authenticator(SignerAuthenticator::new(Keys::generate())) + .build(); + for url in relay_urls { + let _ = client.add_relay(url.as_str()).await; + } + client.connect().await; + let events = client + .fetch_events( + Filter::new() + .kind(Kind::Metadata) + .authors(authors) + .limit(100), + ) + .timeout(AUTHOR_TIMEOUT) + .await + .ok(); + client.disconnect().await; + if let Some(events) = events { + apply_author_metadata(items, events.into_iter()); + } +} + +/// Fold kind-0 events into feed items: newest event per author wins; the +/// display name prefers `display_name` over `name`; blank values stay `None` +/// so the UI falls back to the npub. +fn apply_author_metadata(items: &mut [FeedItem], events: I) +where + I: Iterator, +{ + use std::collections::HashMap; + + let mut best: HashMap = HashMap::new(); + let mut order: Vec = events.collect(); + order.sort_by_key(|e| e.created_at); + for event in &order { + best.insert(event.pubkey.to_hex(), event); + } + for item in items.iter_mut() { + let Some(event) = best.get(&item.author) else { + continue; + }; + let Ok(meta) = serde_json::from_str::(&event.content) else { + continue; + }; + item.author_name = meta + .display_name + .as_deref() + .or(meta.name.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + item.author_picture = meta + .picture + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + } } /// URLs of every enabled relay. @@ -256,6 +350,8 @@ impl FeedItem { id, author: event.pubkey.to_hex(), author_npub, + author_name: None, + author_picture: None, content: event.content.trim().to_string(), created_at: event.created_at.as_secs(), relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(), @@ -300,6 +396,8 @@ mod tests { id, author: "a".into(), author_npub: "npub1a".into(), + author_name: None, + author_picture: None, content: "c".into(), created_at: created, relays: vec![], @@ -335,6 +433,68 @@ mod tests { assert!(builder.items.is_empty()); } + #[test] + fn author_metadata_newest_wins_and_blanks_fall_back() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let alice = Keys::generate(); + let bob = Keys::generate(); + let mut items = vec![ + FeedItem { + id: "1".into(), + author: alice.public_key().to_hex(), + author_npub: alice.public_key().to_bech32().unwrap(), + author_name: None, + author_picture: None, + content: "hi".into(), + created_at: 100, + relays: vec![], + }, + FeedItem { + id: "2".into(), + author: bob.public_key().to_hex(), + author_npub: bob.public_key().to_bech32().unwrap(), + author_name: None, + author_picture: None, + content: "hey".into(), + created_at: 90, + relays: vec![], + }, + ]; + // Older Alice metadata loses to the newer one; display_name wins. + let old = EventBuilder::new( + Kind::Metadata, + r#"{"name":"A","picture":"https://old.example/a.png"}"#.to_string(), + ) + .custom_created_at(Timestamp::from(10)) + .finalize_async(&alice) + .await + .unwrap(); + let new = EventBuilder::new( + Kind::Metadata, + r#"{"name":"A","display_name":"Alice Liddell","picture":"https://new.example/a.png"}"#.to_string(), + ) + .custom_created_at(Timestamp::from(20)) + .finalize_async(&alice) + .await + .unwrap(); + // Bob's metadata is blank: fallback stays npub. + let blank = EventBuilder::new(Kind::Metadata, r#"{"name":" "}"#.to_string()) + .custom_created_at(Timestamp::from(30)) + .finalize_async(&bob) + .await + .unwrap(); + apply_author_metadata(&mut items, vec![old, new, blank].into_iter()); + assert_eq!(items[0].author_name.as_deref(), Some("Alice Liddell")); + assert_eq!( + items[0].author_picture.as_deref(), + Some("https://new.example/a.png") + ); + assert!(items[1].author_name.is_none()); + assert!(items[1].author_picture.is_none()); + }); + } + #[tokio::test] async fn limit_stops_collection_when_full() { let mut builder = FeedBuilder::new(2, None); diff --git a/src/ipc.rs b/src/ipc.rs index cfa1bed..5ed59d5 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::Mutex; -use crate::app::App; +use crate::app::{App, Nip46ClientSignerHandle}; use crate::errors::AppError; use crate::feed; use crate::profiles; @@ -165,14 +165,24 @@ pub enum Request { uri: String, label: String, }, + /// Start a client-initiated pairing: the reply carries `pairing_uri` + /// (a nostrconnect:// token) for the GUI to render as a QR the signer + /// app scans. Status polls report when the scan lands. + Nip46PairStart { + label: String, + }, /// Disconnect from the NIP-46 signer. Nip46Disconnect, /// Get NIP-46 connection status. Nip46Status, - /// Approve/reject a pending NIP-46 request. + /// Approve/reject a pending NIP-46 request. `always = true` additionally + /// records a standing grant so this peer's future requests of the same + /// method run without prompting. Nip46Approve { id: String, approved: bool, + #[serde(default)] + always: bool, }, /// ===== LEGACY NIP-46 BUNKER (server mode) ===== /// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). @@ -190,6 +200,18 @@ pub enum Request { id: String, /// `true` to run the request, `false` to reject it. approved: bool, + /// `true` alongside `approved` records a standing "always allow" + /// grant for this peer + method. + #[serde(default)] + always: bool, + }, + /// List standing "always allow" grants for apps using us as signer. + SignerGrantsList, + /// Revoke one standing grant (app pubkey + method). The field avoids the + /// name `method` because the request enum is internally tagged on it. + SignerGrantRevoke { + app_pubkey: String, + grant_method: String, }, DeleteProfile { npub: String, @@ -237,6 +259,31 @@ pub async fn serve() -> Result<(), AppError> { // Shared state, so the NIP-46 signer's background task and the request loop // both see the same vault (including its unlock key) without racing writes. let app = Arc::new(Mutex::new(App::load()?)); + + // Restore saved NIP-46 signer sessions (spec: "reuse previously + // established signer sessions whenever possible"). When the vault is not + // password-encrypted the stored client keys resolve right now, so Amber + // never sees a fresh scan for an already-approved connection. An + // encrypted vault restores later, on UnlockVault, once the keys can be + // decrypted — this call simply no-ops until then. Fail-safe: a restore + // error must never prevent the backend from serving the GUI. + { + let restorable = { + let guard = app.lock().await; + matches!(guard.signer_mode, SignerMode::Nip46Client) && guard.vault.crypto.is_none() + }; + if restorable { + // `ensure_nip46_signer` constructs the handle lazily; App::load + // leaves it None until the mode is touched, so go through it + // rather than reading the field. + if let Some(signer) = ensure_nip46_signer(&app).await { + if let Err(e) = signer.reactivate_saved_sessions().await { + eprintln!("[NIP46] session restore at startup failed: {e}"); + } + } + } + } + let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); let stdin = tokio::io::stdin(); @@ -331,6 +378,46 @@ fn error_code(err: &AppError) -> String { .unwrap_or_else(|_| "error".to_string()) } +/// Lazily ensure the NIP-46 client signer handle exists and return it. +/// +/// App startup defaults to `signer_mode = Nip46Client` but leaves the handle +/// `None` (only SignerModeSet builds one), so a fresh backend answers +/// nip46_* requests with "not initialized" until the user re-saves the mode. +/// Any nip46_* request initializes the handle when the mode is the NIP-46 +/// client mode, matching what SignerModeSet would do. The guard is dropped +/// before returning so callers can await on the handle without deadlocking. +async fn ensure_nip46_signer(app: &Arc>) -> Option { + let mut guard = app.lock().await; + if guard.nip46_signer.is_none() && matches!(guard.signer_mode, SignerMode::Nip46Client) { + guard.nip46_signer = Some(Arc::new(Nip46ClientSigner::new(app.clone()))); + } + guard.nip46_signer.clone() +} + +/// Translate the NIP-46 client signer's status into the shape the Signer +/// (bunker) screen consumes, so one live session serves both UIs. +fn nip46_status_as_bunker_json(status: &crate::signer::types::Nip46Status) -> serde_json::Value { + let phase = if status.connected { + "connected" + } else if status.pairing_uri.is_some() { + "connecting" + } else { + "stopped" + }; + json!({ + "phase": phase, + "peer": status.signer_pubkey, + "relays": status.relays, + "connectedRelays": status.connected_relays, + "error": status.error, + "pending": status.pending_approvals.iter().map(|p| json!({ + "id": p.id, + "method": p.method, + "summary": p.summary, + })).collect::>(), + }) +} + /// Main request dispatcher. async fn run(app: &Arc>, request: Request) -> Result { match request { @@ -396,44 +483,58 @@ async fn run(app: &Arc>, request: Request) -> Result { - let guard = app.lock().await; - if let Some(signer) = &guard.nip46_signer { - let status = signer.connect(&uri, label).await?; - Ok(json!(status)) - } else { - Err(AppError::config( + // Take the signer handle (initializing it if needed), then drop + // the guard before awaiting: connect() re-locks the App + // internally (to persist the connection and resolve its secret), + // so holding the guard across the await would deadlock. + let Some(signer) = ensure_nip46_signer(app).await else { + return Err(AppError::config( "NIP-46 signer not initialized. Set signer mode to nip46 first.", - )) - } + )); + }; + let status = signer.connect(&uri, label).await?; + Ok(json!(status)) + } + Request::Nip46PairStart { label } => { + // Same lock discipline as Nip46Connect: pairing persists to the + // vault from its background task, so the guard must not be held + // across the await. + let Some(signer) = ensure_nip46_signer(app).await else { + return Err(AppError::config( + "NIP-46 signer not initialized. Set signer mode to nip46 first.", + )); + }; + let status = signer.start_pairing(label).await?; + Ok(json!(status)) } Request::Nip46Disconnect => { - let guard = app.lock().await; - if let Some(signer) = &guard.nip46_signer { - signer.disconnect().await?; - let status = signer.status().await; - Ok(json!(status)) - } else { - Err(AppError::config("NIP-46 signer not initialized")) - } + let Some(signer) = ensure_nip46_signer(app).await else { + return Err(AppError::config("NIP-46 signer not initialized")); + }; + signer.disconnect().await?; + let status = signer.status().await; + Ok(json!(status)) } Request::Nip46Status => { - let guard = app.lock().await; - if let Some(signer) = &guard.nip46_signer { - let status = signer.status().await; - Ok(json!(status)) - } else { - Ok(json!({ "connected": false, "error": "Not initialized" })) - } + let Some(signer) = ensure_nip46_signer(app).await else { + return Ok(json!({ "connected": false, "error": "Not initialized" })); + }; + let status = signer.status().await; + Ok(json!(status)) } - Request::Nip46Approve { id, approved } => { - let guard = app.lock().await; - if let Some(signer) = &guard.nip46_signer { - signer.respond_to_approval(&id, approved).await?; - let status = signer.status().await; - Ok(json!(status)) - } else { - Err(AppError::config("NIP-46 signer not initialized")) - } + Request::Nip46Approve { + id, + approved, + always, + } => { + let Some(signer) = ensure_nip46_signer(app).await else { + return Err(AppError::config("NIP-46 signer not initialized")); + }; + signer + .respond_to_approval_with_always(&id, approved, always) + .await?; + let status = signer.status().await; + Ok(json!(status)) } // Legacy NIP-46 bunker (server mode) @@ -442,7 +543,7 @@ async fn run(app: &Arc>, request: Request) -> Result>, request: Request) -> Result>, request: Request) -> Result { + Request::SignerApprove { + id, + approved, + always, + } => { let guard = app.lock().await; if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if let Some(signer) = &guard.nip46_signer { - signer.respond_to_approval(&id, approved).await?; + signer + .respond_to_approval_with_always(&id, approved, always) + .await?; let status = signer.status().await; - return Ok(json!(status)); + return Ok(nip46_status_as_bunker_json(&status)); } } Err(AppError::config("Not in NIP-46 client mode")) } + Request::SignerGrantsList => { + let guard = app.lock().await; + Ok(json!(guard.vault.signer_grants)) + } + Request::SignerGrantRevoke { + app_pubkey, + grant_method, + } => { + let mut guard = app.lock().await; + let removed = guard.vault.revoke_signer_grant(&app_pubkey, &grant_method); + if removed { + guard.save_vault()?; + } + Ok(json!({ "removed": removed })) + } // Network-only requests (no shared state lock) Request::RelayTest { url } => { @@ -605,9 +727,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let key = app.vault_key().copied(); - let report = - profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?; + // Route through the profile's Signing source, exactly like + // PublishNote: an embedded profile signs locally, a paired + // profile's kind-0 is signed by the remote signer (Amber shows + // an approval prompt), and a disconnected one fails closed. + // The shared App guard is held across the round-trip; the + // signer's demux needs no App lock to deliver the response. + let signing = app.signing_for(&npub).await?; + let stored = profiles::find_stored_profile(&app.vault, &npub)?.clone(); + let settings = app.settings.clone(); + let report = profiles::publish_metadata_signed( + &settings, + &stored.label, + stored.picture.clone(), + stored.nip05.clone(), + &signing, + ) + .await?; Ok(json!(report)) } @@ -646,9 +782,17 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let report = - publish::publish_active(&app.vault, &app.settings, &content, app.vault_key()) - .await?; + // Signer selection lives in App::signing_for: an embedded profile + // signs with the vault key; an external (NIP-46) profile's note + // round-trips to the connected signer, and an unconnected one + // fails closed — never with a silent fallback to the local key. + // + // As before, the shared App guard is held across the publish. + // The signer's background task needs no App lock to deliver the + // sign response (only audit paths take it, briefly), so the + // round-trip completes with the guard held. + let signing = app.signing_active().await?; + let report = publish::publish_signed(&app.settings, &content, &signing).await?; let stored = crate::vault::StoredPublishReport { event_id: report.event_id.clone(), succeeded: report.succeeded.clone(), @@ -710,6 +854,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result Result { - let authorization = crate::uploads::nip98_authorization( - &app.vault, - &url, - &http_method, - app.vault_key(), - ) - .await?; + let signing = app.signing_active().await?; + let authorization = + crate::uploads::nip98_authorization(&url, &http_method, &signing).await?; Ok(json!({ "authorization": authorization })) } @@ -793,9 +939,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let deleted = profiles::delete_profile(&mut app.vault, &npub)?; + let deleted = profiles::delete_profile_record(&mut app.vault, &npub)?; app.save_vault()?; - app.undo_history.push(deleted.clone()); + app.undo_history.push(deleted); Ok(json!(app.state_view())) } Request::UndoDelete => { diff --git a/src/main.rs b/src/main.rs index c578b63..525b731 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,11 @@ use keynectr::app::App; use keynectr::bunker::Signer; use keynectr::errors::{AppError, ErrorKind}; use keynectr::ipc; -use keynectr::profiles::{self, ProfileSummary}; +use keynectr::profiles; use keynectr::publish; use keynectr::relays; use keynectr::settings::Theme; -use keynectr::vault::{self, StoredProfile, Vault}; +use keynectr::vault::{self, Vault}; const USAGE: &str = "\ keynectr [args...] @@ -629,26 +629,13 @@ fn cli_info() -> Result { } /// Delete a profile by npub, moving it to the undo stack. -/// Returns the deleted profile summary, or an error if not found. -fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result { - let pos = vault - .profiles - .iter() - .position(|p| p.public_key == npub) - .ok_or_else(|| AppError::profile_not_found(npub))?; - let stored = vault.profiles.remove(pos); - // Clear the active_profile if it was the one deleted - if vault.active_profile.as_deref() == Some(npub) { - vault.active_profile = None; - } - Ok(ProfileSummary { - label: stored.label, - npub: stored.public_key, - created_at: stored.created_at, - is_active: false, - picture: stored.picture, - nip05: stored.nip05, - }) +/// Returns the full deleted record so the CLI can report it and push the +/// same entry the IPC path uses. +fn delete_profile_direct( + vault: &mut Vault, + npub: &str, +) -> Result { + profiles::delete_profile_record(vault, npub) } fn cli_delete_profile(args: &[String]) -> Result { @@ -657,37 +644,22 @@ fn cli_delete_profile(args: &[String]) -> Result { } let npub = args[2].clone(); let mut app = App::load()?; + // Capture the label BEFORE removal; the profile is gone afterwards. + let label = profiles::profile_label(&app.vault, &npub) + .unwrap_or(&npub) + .to_string(); let deleted = delete_profile_direct(&mut app.vault, &npub)?; app.save_vault()?; - // Add to undo history - app.undo_history.push(deleted.clone()); + // Add to undo history (full record: undo restores a working profile). + app.undo_history.push(deleted); Ok(format!( - "Profile '{}' deleted (npub: {}). Use 'undo-delete' to restore.", - profiles::profile_label(&app.vault, &npub).unwrap_or(&npub), - npub + "Profile '{label}' deleted (npub: {npub}). Use 'undo-delete' to restore." )) } fn cli_undo_delete() -> Result { let mut app = App::load()?; - if app.undo_history.is_empty() { - return Err(AppError::config("No profile deletions to undo.")); - } - let restored = app.undo_history.pop().unwrap(); - // Re-add the profile to the vault - let stored = StoredProfile { - label: restored.label.clone(), - public_key: restored.npub.clone(), - secret_key: "".to_string(), - created_at: restored.created_at, - picture: restored.picture, - nip05: restored.nip05, - signer_mode: keynectr::vault::SignerMode::Embedded, - }; - app.vault.profiles.push(stored); - if app.vault.active_profile.is_none() { - app.vault.active_profile = Some(restored.npub.clone()); - } + let restored = app.undo_delete()?; app.save_vault()?; Ok(format!( "Profile '{}' restored from undo stack.", diff --git a/src/profiles.rs b/src/profiles.rs index ec6ad14..d5b8dfc 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -9,7 +9,7 @@ use crate::errors::AppError; use crate::publish::RelayFailure; use crate::relays; use crate::settings::Settings; -use crate::vault::{unix_timestamp, StoredProfile, Vault}; +use crate::vault::{unix_timestamp, SignerMode, StoredProfile, Vault}; /// A safe view of a profile that contains no secret key material. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -190,11 +190,68 @@ pub struct MetadataPublishReport { pub failed: Vec, } +/// Publish a profile's stored label (and picture, when set) as kind 0 metadata +/// through an explicit [`Signing`] source (embedded or external). +/// +/// This is what the GUI "Publish name" path uses: for a paired profile the +/// kind-0 event is signed by the remote signer (Amber shows an approval +/// prompt), so the name becomes visible network-wide instead of staying a +/// local vault label. A disconnected external profile fails closed with +/// `ExternalSignerNotConnected` — never with a silent local-key fallback. +pub async fn publish_metadata_signed( + settings: &Settings, + label: &str, + picture: Option, + nip05: Option, + signing: &crate::signer::Signing, +) -> Result { + let relay_urls = relays::enabled_urls(settings); + if relay_urls.is_empty() { + return Err(AppError::no_enabled_relays()); + } + let mut metadata = Metadata::new().name(label).display_name(label); + if let Some(picture) = &picture { + if let Ok(parsed) = Url::parse(picture) { + metadata = metadata.picture(parsed); + } + } + if let Some(nip05) = &nip05 { + metadata = metadata.nip05(nip05); + } + // Identity first (validates the signer controls this profile), then sign + // through `Signing` — local key or the NIP-46 round-trip. + let pubkey = signing.pubkey().await.map_err(AppError::from)?; + let unsigned = EventBuilder::new(Kind::Metadata, metadata.as_json()).finalize_unsigned(pubkey); + let signed = signing + .sign(unsigned) + .await + .map_err(|e| AppError::sign_failed(format!("{e}")))?; + + // Local signing can answer NIP-42 AUTH challenges; with an external + // signer the app holds no key, so the pool opens without an + // authenticator and auth-gated relays report per-relay. + let client = match signing { + crate::signer::Signing::Local(keys) => { + relays::open_pool(keys.clone(), &relay_urls, None).await? + } + crate::signer::Signing::External { .. } => { + relays::open_pool_anon(&relay_urls, None).await? + } + }; + let (succeeded, failed) = + crate::publish::send_to_all_relays(&client, relay_urls, &signed, "metadata").await; + Ok(MetadataPublishReport { succeeded, failed }) +} + /// Publish a profile's stored label (and picture, when set) as kind 0 metadata /// so external clients (Iris, Yakihonne, ...) display its name. Returns a /// per-relay report. /// /// `key` must be the unlocked vault key when the vault is password-protected. +/// +/// Local-only: always signs from the vault. The CLI uses this (it has no +/// signer instances); GUI callers use [`publish_metadata_signed`] with an +/// [`App::signing_for`] source so paired profiles sign remotely. pub fn publish_profile_metadata( vault: &Vault, npub: &str, @@ -298,6 +355,39 @@ pub fn rename_profile( return Err(AppError::config("The profile name cannot be empty.")); } + // Remote (secretless) profiles have no local key to sign a kind-0 + // metadata event with — and the kind-0 reroute through the external + // signer is still pending (P2). The label is still useful as the local + // display name, so rename it vault-side and report zero relays rather + // than failing the whole rename outright. + let is_remote = vault + .profiles + .iter() + .find(|p| p.public_key == npub) + .is_some_and(|p| { + p.signer_mode == SignerMode::Nip46Client || p.secret_key.trim().is_empty() + }); + if is_remote { + let is_active = vault.active_profile.as_deref() == Some(npub); + let stored = find_profile_mut(vault, npub)?; + stored.label = trimmed.to_string(); + let summary = ProfileSummary { + label: stored.label.clone(), + npub: stored.public_key.clone(), + created_at: stored.created_at, + is_active, + picture: stored.picture.clone(), + nip05: stored.nip05.clone(), + }; + return Ok(( + summary, + MetadataPublishReport { + succeeded: Vec::new(), + failed: Vec::new(), + }, + )); + } + // Resolve and sign before mutating so a locked vault or bad key changes // nothing on disk. let secret_hex = resolve_secret_key(vault, npub, key)?; @@ -463,6 +553,58 @@ pub fn find_stored_profile<'a>( .ok_or_else(|| AppError::profile_not_found(npub)) } +/// Create or refresh the vault profile for a remote (NIP-46) identity. +/// +/// When a NIP-46 client connection is established the identity lives on the +/// remote signer, but the user still needs a profile row so publishing has a +/// selection. The row is marked `Nip46Client` and carries **no secret key** +/// (there is none locally): every signing operation for it must go through +/// the connected signer, and key export refuses it. Re-connecting updates the +/// label and re-activates the profile rather than duplicating it. +pub fn store_remote_profile( + vault: &mut Vault, + npub: &str, + label: String, +) -> Result { + // Validate the identity before writing anything. + PublicKey::parse(npub) + .map_err(|e| AppError::internal(format!("Remote signer identity is not valid: {e}")))?; + // An existing local profile must never be silently converted to remote: + // refuse *before* mutating if it carries a local secret. + if let Some(existing) = vault.profiles.iter().find(|p| p.public_key == npub) { + if existing.signer_mode != SignerMode::Nip46Client && !existing.secret_key.trim().is_empty() + { + return Err(AppError::config( + "That identity already exists as a local profile. Delete it first if you want to use an external signer for it.", + )); + } + } + if let Some(existing) = vault.profiles.iter_mut().find(|p| p.public_key == npub) { + existing.signer_mode = SignerMode::Nip46Client; + existing.label = label; + } else { + vault.profiles.push(StoredProfile { + label: label.clone(), + public_key: npub.to_string(), + secret_key: String::new(), // no local key — identity lives on the signer + created_at: unix_timestamp()?, + picture: None, + nip05: None, + signer_mode: SignerMode::Nip46Client, + }); + } + vault.active_profile = Some(npub.to_string()); + let stored = find_profile(vault, npub)?; + Ok(ProfileSummary { + label: stored.label.clone(), + npub: stored.public_key.clone(), + created_at: stored.created_at, + is_active: true, + picture: stored.picture.clone(), + nip05: stored.nip05.clone(), + }) +} + fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> { vault .profiles @@ -509,7 +651,7 @@ fn publish_metadata_blocking( /// Best-effort lookup of the account's latest kind-0 metadata. Import must /// still succeed when relays are unavailable, so lookup failures are ignored. -fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option { +pub fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option { if relay_urls.is_empty() { return None; } @@ -745,9 +887,19 @@ pub fn parse_secret_key(hex_str: &str) -> Result { SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}"))) } -/// Delete a profile by npub, returning the deleted profile for undo. -/// The vault must not be encrypted, or the key must be provided. -pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result { +/// A profile removed from the vault together with everything needed to put it +/// back: the safe summary for the UI *and* the full `StoredProfile` including +/// its secret key material (plaintext or encrypted blob, exactly as stored). +#[derive(Debug, Clone)] +pub struct DeletedProfile { + pub summary: ProfileSummary, + pub stored: StoredProfile, +} + +/// Delete a profile by npub, returning the deleted record for undo. The +/// returned `DeletedProfile` carries the real stored secret so undo can +/// restore a fully functional profile. Never serialize it to the UI. +pub fn delete_profile_record(vault: &mut Vault, npub: &str) -> Result { let pos = vault .profiles .iter() @@ -757,14 +909,22 @@ pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result Result { + delete_profile_record(vault, npub).map(|deleted| deleted.summary) } #[cfg(test)] @@ -1160,6 +1320,34 @@ mod tests { assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); } + #[test] + fn rename_profile_updates_label_for_secretless_remote_profiles() { + // Paired (NIP-46) profiles carry no local key, so no kind-0 can be + // signed — but the local display label must still be renameable. + use nostr::nips::nip19::ToBech32; + let mut vault = Vault::empty(); + let remote = Keys::generate(); + let npub = remote.public_key().to_bech32().unwrap(); + store_remote_profile(&mut vault, &npub, "Remote Signer".to_string()).unwrap(); + + let (renamed, report) = rename_profile( + &mut vault, + &npub, + "Phone Key".to_string(), + None, + &offline_settings(), + ) + .expect("remote rename must succeed locally"); + assert_eq!(renamed.label, "Phone Key"); + assert_eq!(vault.profiles[0].label, "Phone Key"); + assert!( + vault.profiles[0].secret_key.is_empty(), + "rename must not fabricate a secret" + ); + assert!(report.succeeded.is_empty()); + assert!(report.failed.is_empty()); + } + #[test] fn set_nip05_stores_identifier_and_skips_publish_without_relays() { let mut vault = Vault::empty(); diff --git a/src/publish.rs b/src/publish.rs index de3fbad..30b13b9 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -48,6 +48,9 @@ impl PublishReport { /// Publish a text note with the active profile. /// /// `key` must be the unlocked vault key when the vault is password-protected. +/// Always signs locally from the vault — used by the CLI, which has no signer +/// instances. GUI callers use [`publish_signed`] with an [`App::signing_for`] +/// signing source so external-signer profiles route to their remote signer. pub async fn publish_active( vault: &Vault, settings: &Settings, @@ -61,6 +64,17 @@ pub async fn publish_active( publish_with_keys(settings, content, &signing).await } +/// Publish a text note through an explicit [`Signing`] source (embedded or +/// external). This is what the GUI publish path uses. +pub async fn publish_signed( + settings: &Settings, + content: &str, + signing: &Signing, +) -> Result { + validate_content(content)?; + publish_with_keys(settings, content, signing).await +} + /// Publish a text note as a specific profile (used by the CLI). /// /// `key` must be the unlocked vault key when the vault is password-protected. @@ -164,33 +178,19 @@ async fn publish_with_keys( return Err(AppError::no_enabled_relays()); } - // Extract &Keys from Signing::Local for EventBuilder operations. - // Currently Signing::Local is used from publish_active/publish_as, - // but the pattern supports External signers in the future. - let keys = match signing { - Signing::Local(k) => k, - Signing::External { - signer: _, - profile_pubkey: _, - } => { - return Err(AppError::sign_failed( - "External signer not yet supported in publish_with_keys", - )); - } - }; + // The pubkey comes from the Signing itself. For an external signer this + // performs identity validation first: a signer that does not control the + // active profile's key fails here, before any event is built. + let pubkey = signing.pubkey().await.map_err(AppError::from)?; - // Build the unsigned event. + // Build the unsigned event under the signing identity, then sign it + // through `Signing` (local key or the NIP-46 round-trip). This is the + // core reroute: the IPC layer never calls Keys::sign_event directly, and + // an external profile never needs a local secret. let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content)); - let unsigned = builder - .finalize_async(keys) - .await - .map_err(|e| AppError::sign_failed(format!("{e}")))?; - - // Sign the event through the Signing trait (routes to Keys::sign_event or - // Signer::sign_event depending on the variant). This is the core refactor: - // the IPC layer no longer calls Keys::sign_event directly. + let unsigned = builder.finalize_unsigned(pubkey); let signed = signing - .sign(unsigned.into()) + .sign(unsigned) .await .map_err(|e| AppError::sign_failed(format!("{e}")))?; @@ -199,7 +199,13 @@ async fn publish_with_keys( .to_bech32() .map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?; - let client = relays::open_pool(keys.clone(), &relay_urls, None).await?; + // Local signing can answer NIP-42 AUTH challenges; with an external + // signer the app holds no key, so the pool opens without an + // authenticator and auth-gated relays report their rejection per-relay. + let client = match signing { + Signing::Local(keys) => relays::open_pool(keys.clone(), &relay_urls, None).await?, + Signing::External { .. } => relays::open_pool_anon(&relay_urls, None).await?, + }; let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &signed, "note").await; if succeeded.is_empty() { diff --git a/src/relays.rs b/src/relays.rs index 303698c..e9fa3c7 100644 --- a/src/relays.rs +++ b/src/relays.rs @@ -14,6 +14,43 @@ pub fn default_relays() -> Vec { ] } +/// Extra relays always included in the NIP-46 *pairing* set (on top of the +/// user's enabled relays). Signer apps (Amber et al.) are free to pick any +/// relay listed in the nostrconnect:// URI, and relays differ wildly in +/// reliability for ephemeral kind-24133 traffic; listening on a few extra +/// well-known relays costs nothing and makes pairing robust whichever one +/// the signer happens to choose. +/// +/// This set was curated with a live write+readback canary (Sep 22, 2026): +/// - wss://purplepag.es REJECTS kind 24133 ("blocked: kind 24133 is not +/// allowed") — a signer that picks it reports "connected" while its +/// connect event is thrown away, so it must never be in the pairing URI. +/// - wss://relay.nostr.band currently hangs the WebSocket handshake; it is +/// also pay-to-read. Dropped from the pairing set. +/// - wss://nos.lol and wss://relay.primal.net are the two relays with +/// PROVEN bidirectional ephemeral-24133 traffic in the live Sep 23 scans +/// (both stored Amber's connect reply AND our identity RPC). +/// - wss://relay.damus.io returned HTTP 503 to reads and answered connects +/// inconsistently during the same scans; while flapping it splits the +/// conversation across relays the signer never reads. +/// - wss://relay.snort.social accepts ephemeral 24133 publishes but does +/// not persist them; with damus out it adds no shared ground. +/// +/// Sep 23 PM addendum: Amber 6.6.5 receives our pairing publishes on NONE of +/// the above (its activity log shows only the Connect ack; our get_public_key +/// RPCs are accepted by both relays but never answered). Amber's own issue +/// history documents NIP-46 working over relay.damus.io, and a same-day +/// write-canary from this network shows damus connected + accepting ephemeral +/// 24133 publishes, so damus rejoins the set FIRST — the signer is most +/// likely to meet us where its own client is proven to work. +pub fn pairing_relays() -> Vec { + vec![ + "wss://relay.damus.io".to_string(), + "wss://relay.primal.net".to_string(), + "wss://nos.lol".to_string(), + ] +} + /// Validate that a string is a well-formed relay URL. pub fn validate_url(raw: &str) -> Result<(), AppError> { let cleaned = raw.trim().trim_end_matches('/'); @@ -78,12 +115,36 @@ pub(crate) async fn open_pool( keys: Keys, relay_urls: &[String], wait: Option, +) -> Result { + open_pool_inner(Some(keys), relay_urls, wait).await +} + +/// Open a relay pool with no signing identity. +/// +/// Used when user content is signed by an external (NIP-46) signer: the app +/// holds no key to answer NIP-42 AUTH challenges with, so the pool is built +/// without an authenticator. Relays that demand auth will reject reads/ +/// writes at the protocol level, which `send_to_all_relays` already reports +/// per-relay. +pub(crate) async fn open_pool_anon( + relay_urls: &[String], + wait: Option, +) -> Result { + open_pool_inner(None, relay_urls, wait).await +} + +async fn open_pool_inner( + keys: Option, + relay_urls: &[String], + wait: Option, ) -> Result { // The authenticator answers NIP-42 AUTH challenges automatically on every // path that opens a client (nostr-sdk >= 0.45 has no implicit signer). - let client = Client::builder() - .authenticator(SignerAuthenticator::new(keys)) - .build(); + let builder = match keys { + Some(keys) => Client::builder().authenticator(SignerAuthenticator::new(keys)), + None => Client::builder(), + }; + let client = builder.build(); for url in relay_urls { client .add_relay(url.as_str()) @@ -156,6 +217,25 @@ mod tests { assert!(relays.iter().all(|r| r.enabled)); } + #[test] + fn pairing_relays_exclude_24133_blockers() { + // purplepag.es returns "blocked: kind 24133 is not allowed" and + // relay.nostr.band hangs the handshake (canary, Sep 22 2026). A + // signer that picks a blocking relay says "connected" while its + // connect event is discarded, so neither may appear in the URI. + let relays = pairing_relays(); + assert!(!relays.iter().any(|r| r.contains("purplepag"))); + assert!(!relays.iter().any(|r| r.contains("nostr.band"))); + // Every entry is a well-formed wss URL and the set is deduped. + for url in &relays { + validate_url(url).expect("pairing relay must be a valid wss URL"); + } + let mut sorted = relays.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), relays.len()); + } + #[test] fn validate_url_accepts_wss_and_ws() { assert!(validate_url("wss://relay.example.com").is_ok()); diff --git a/src/signer/mod.rs b/src/signer/mod.rs index 47fc612..3e9e21b 100644 --- a/src/signer/mod.rs +++ b/src/signer/mod.rs @@ -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 { + async fn permissions(&self) -> Option { 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 } } diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs index 1319443..44ddf95 100644 --- a/src/signer/nip46_client.rs +++ b/src/signer/nip46_client.rs @@ -10,6 +10,7 @@ use base64::Engine; use getrandom::getrandom; use nostr::nips::nip44::v2; use nostr::nips::nip44::v2::ConversationKey; +use nostr::nips::nip46::NostrConnectUri; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -31,6 +32,56 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); /// Maximum number of requests kept waiting for approval at once. const MAX_PENDING_APPROVALS: usize = 20; +/// How long an outbound NIP-46 request (e.g. our own `sign_event`) may wait +/// for the remote signer's response before it is abandoned. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// How long the connect handshake can involve a human approving the app on +/// the signer's screen, so it gets a far longer leash than ordinary RPCs. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(120); +/// How long a `sign_event` may wait for the signer's answer. Like the +/// handshake, every sign waits for a human to notice and approve the prompt +/// on the signer's device, so it needs the same long leash rather than the +/// 30s ordinary-RPC one: live pairing (Sep 23) showed a perfectly valid +/// signature arriving after the 30s leash expired, discarded as +/// "stale/duplicate response: no waiter" while the user watched failures. +const SIGN_TIMEOUT: Duration = Duration::from_secs(120); +/// How long a pairing QR (client-initiated `nostrconnect://`) stays live +/// while a human opens their signer and scans it. +const PAIRING_TIMEOUT: Duration = Duration::from_secs(300); +/// App name advertised to signers during client-initiated pairing. +const APP_NAME: &str = "Keynectr"; + +/// Append a line to the local pairing trace log so a failed handshake +/// survives the session: backend stderr reaches the dev console only, and +/// nothing else (logs, vault) records why pairing stopped. Local-only debug +/// surface — this file never leaves the machine. +fn pairing_trace(msg: &str) { + let path = std::path::Path::new("/home/avi/Tools/keynctr-debug/pairing-trace.log"); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + use std::io::Write; + let now = crate::vault::unix_timestamp().unwrap_or(0); + let _ = writeln!(f, "[{now}] {msg}"); + } +} + +/// Whether a relay set touches the public network. Loopback-only sets +/// belong to the e2e harness, and their traffic must never land in the +/// live pairing forensics files: a test-run "identity adopted — CONNECTED" +/// line sitting among real ones was twice mistaken for a live Amber +/// pairing during debugging. +fn live_relays(relays: &[String]) -> bool { + relays.iter().any(|u| { + let u = u.to_lowercase(); + !(u.contains("127.0.0.1") || u.contains("localhost") || u.contains("[::1]")) + }) +} /// Internal state for a pending approval. struct PendingApprovalInner { @@ -39,12 +90,37 @@ struct PendingApprovalInner { sender: oneshot::Sender, } +/// A client-initiated pairing session: we minted an ephemeral key + secret, +/// published a `nostrconnect://` token for the signer to scan, and are +/// listening on the relays for the signer's inbound `connect` request. +struct PairingSession { + /// The `nostrconnect://` URI to render as a QR / copyable link. + uri: String, + /// The task waiting for the inbound connect request. + task: tokio::task::JoinHandle<()>, +} + +/// A response awaited from the *remote signer* for a request we sent +/// (the client half of the NIP-46 flow, e.g. our `sign_event` request). +struct PendingRemoteRequest { + sender: oneshot::Sender>, +} + /// Parsed nostrconnect:// URI. struct ConnectUri { peer: PublicKey, relays: Vec, secret: Option, permissions: Option, + /// True when this session was rebuilt from a saved vault row + /// (`reactivate_saved_sessions`) rather than dialed from a fresh URI. + /// A restored session re-sends `connect` with the ORIGINAL pairing + /// secret, but an already-approved signer answers `true` without + /// re-echoing it (NIP-46 reserves the echo for proving possession + /// during the initial pairing) — so the echo check is skipped and the + /// anti-spoofing is `expected_identity` in `adopt_identity` instead: + /// answering as any other account fails closed. + restore: bool, } /// The NIP-46 client signer. @@ -60,8 +136,24 @@ struct Nip46Inner { conversation_key: Option, client: Option, pending: HashMap, + /// Outbound requests we sent to the remote signer (e.g. `sign_event`) + /// waiting for its encrypted response, keyed by request id. + remote_pending: HashMap, keys: Option, active_npub: Option, + /// An in-flight client-initiated pairing (QR) session, if any. + pairing: Option, + /// The remote signer's REAL identity key, learned from the + /// `get_public_key` RPC after the connect handshake completes. The + /// pubkey in the connect URI may be a per-connection communication key + /// (Amber's `bunker://` flow mints one per app) and must never be used + /// as an identity. `None` until the handshake resolves it. + identity: Option, + /// On a restored (re-dialed) session: the account npub the signer MUST + /// answer as. Enforced inside `adopt_identity` — a session that reveals + /// a different identity is refused, never adopted. `None` on fresh + /// pairings, where the signer's answer defines the identity. + expected_identity: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -84,8 +176,12 @@ impl Nip46ClientSigner { conversation_key: None, client: None, pending: HashMap::new(), + remote_pending: HashMap::new(), keys: None, active_npub: None, + pairing: None, + identity: None, + expected_identity: None, })), app, } @@ -97,10 +193,16 @@ impl Nip46ClientSigner { } /// Emit an audit event for a permission-denied NIP-46 operation. + /// + /// Uses `try_lock`: the IPC dispatcher may hold the App lock while a + /// sign request is in flight (PublishNote), and audit is best-effort — + /// blocking here would deadlock the very request being denied. async fn audit_permission_denied(&self, method: &str) { let npub = self.inner.lock().await.active_npub.clone(); if let Some(npub) = npub { - let mut app = self.app.lock().await; + let Ok(mut app) = self.app.try_lock() else { + return; + }; if let Some(ref mut log) = app.audit_log { let _ = log.record( &npub, @@ -113,11 +215,23 @@ impl Nip46ClientSigner { } } - /// Parse a nostrconnect:// URI. + /// Parse a `nostrconnect://` or `bunker://` URI. + /// + /// Both carry the same wire shape (authority = the signer's public key, + /// `relay=` params, optional `secret=`). `nostrconnect://` is the + /// client-initiated flow (we generated the URI); `bunker://` is the + /// signer-initiated flow (Amber and self-hosted bunkers show one of + /// these). NOTE: with `bunker://` the authority key may be a + /// per-connection communication key (Amber mints one per app), NOT the + /// user's identity — identity is learned later via `get_public_key`. fn parse_connect_uri(raw: &str) -> Result { - let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| { - AppError::config("Paste the nostrconnect:// link from your Nostr app.") - })?; + let rest = raw + .trim() + .strip_prefix("nostrconnect://") + .or_else(|| raw.trim().strip_prefix("bunker://")) + .ok_or_else(|| { + AppError::config("Paste the bunker:// or nostrconnect:// link from your Nostr app.") + })?; let (authority, query) = match rest.split_once('?') { Some((a, q)) => (a, Some(q)), @@ -169,38 +283,47 @@ impl Nip46ClientSigner { relays, secret, permissions, + restore: false, }) } /// Connect to a NIP-46 signer using a nostrconnect:// URI. pub async fn connect(&self, uri: &str, label: String) -> Result { let parsed = Self::parse_connect_uri(uri)?; - - // For NIP-46 Client the identity lives on the external signer, so local - // profile is optional. Use ephemeral keys for the session, preferring - // the local vault profile if available and unlocked. - let (keys, active_npub) = { - let app = self.app.lock().await; - if let Some(npub) = 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)) - } + eprintln!( + "[NIP46] parsed connect URI: peer={} relays={:?} secret={} perms={}", + parsed.peer.to_hex(), + parsed + .relays + .iter() + .map(|r| r.to_string()) + .collect::>(), + if parsed.secret.is_some() { + "present" } else { - (Keys::generate(), None) - } + "absent" + }, + if parsed.permissions.is_some() { + "present" + } else { + "absent" + }, + ); + + // Per NIP-46 ("client-keypair ... largely disposable ... delete it on + // logout") the client keypair is ALWAYS ephemeral: it identifies this + // session on the wire and must never be confused with the user's + // 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; + app.vault.active_profile.clone() }; // Check for permission broadening against stored connections for @@ -244,6 +367,11 @@ impl Nip46ClientSigner { // encrypted under the vault key (when a password is set) and keyed by // the connection's opaque VaultRef — never stored inline on the // connection, never logged. + // + // No profile is created yet: the URI key may be a per-connection + // communication key (Amber's bunker:// flow mints one per app), NOT + // the user's identity. The profile row is created by the handshake + // once `get_public_key` reveals the real identity. { let mut app = self.app.lock().await; // Remove any existing connection for the same signer from the @@ -267,6 +395,16 @@ impl Nip46ClientSigner { // The reconnect carries no secret — drop any stale stored one. crate::vault::delete_connection_secret(&mut app.vault, &vault_ref); } + // Persist OUR client secret key under the same ref: the signer + // remembers our client pubkey for the life of the connection, so + // this is what lets the session be re-dialed after a restart + // without a fresh scan. + crate::vault::store_connection_client_key( + &mut app.vault, + vault_key.as_ref(), + &vault_ref, + &hex::encode(keys.secret_key().to_secret_bytes()), + )?; app.vault.nip46_connections.push(connection.clone()); app.save_vault()?; } @@ -283,6 +421,11 @@ impl Nip46ClientSigner { inner.connection = Some(connection.clone()); inner.conversation_key = Some(conversation); inner.keys = Some(keys.clone()); + // Identity is unknown until the handshake's `get_public_key` + // resolves it; nothing may sign before then. + inner.identity = None; + inner.expected_identity = None; + inner.active_npub = None; inner.pending.clear(); } @@ -305,6 +448,9 @@ impl Nip46ClientSigner { if let Some(task) = inner.task.take() { task.abort(); } + if let Some(pairing) = inner.pairing.take() { + pairing.task.abort(); + } if let Some(client) = inner.client.take() { let _ = client.disconnect().await; } @@ -319,6 +465,7 @@ impl Nip46ClientSigner { stored.revoked_at = crate::vault::unix_timestamp().ok(); } crate::vault::delete_connection_secret(&mut app.vault, &vault_ref); + crate::vault::delete_connection_client_key(&mut app.vault, &vault_ref); let _ = app.save_vault(); } inner.phase = Nip46Phase::Stopped; @@ -326,6 +473,13 @@ impl Nip46ClientSigner { inner.conversation_key = None; inner.keys = None; inner.pending.clear(); + // Wake any callers awaiting a remote response; their waiters turn + // into `NotConnected` rather than hanging until the request timeout. + for (_, waiter) in inner.remote_pending.drain() { + let _ = waiter.sender.send(Err( + "Disconnected from the signer before it responded.".to_string() + )); + } Ok(()) } @@ -334,6 +488,715 @@ impl Nip46ClientSigner { self.disconnect().await } + /// Re-dial the saved signer sessions after startup/unlock, no scan. + /// + /// Amber remembers our *client pubkey* as the identity of an approved + /// connection for its whole life, and this app now persists that client + /// secret key (encrypted, per [`crate::vault::Vault::connection_client_keys`]). + /// A saved connection is therefore re-dialable: we reuse the exact client + /// keypair, re-derive the NIP-44 conversation key, send `connect` again, + /// and — critically — require the signer's `get_public_key` answer to + /// equal the stored profile identity (`expected_identity`, enforced in + /// [`Self::adopt_identity`]). A signer that answers as anyone else fails + /// the restore; the session is never adopted, so a re-dial can never + /// silently bind the wrong account (spec: "prevent cross-account signer + /// use"). + /// + /// Connections without a stored client key (pairings created before key + /// persistence existed) are skipped — those need one fresh scan, after + /// which they become restorable too. Only one session is restored per + /// call (the signer holds a single live session): the active profile's + /// connection first, then any other live connection. + /// + /// Returns `Ok(1)` when a re-dial was started, `Ok(0)` when there was + /// nothing restorable or a session is already live. A started re-dial + /// resolves asynchronously through the same handshake as a fresh + /// `connect()`; until it reports Connected the profile stays effectively + /// read-only (every signing path fails closed on `Connecting`). + pub async fn reactivate_saved_sessions(&self) -> Result { + { + let inner = self.inner.lock().await; + if inner.task.is_some() || inner.pairing.is_some() { + return Ok(0); + } + } + + // Pick the connection to restore and everything needed to re-dial it, + // all in one vault snapshot. The vault key is copied out before the + // mutable borrows, mirroring `connect()`. + let picked = { + let app = self.app.lock().await; + let vault_key = app.vault_key().copied(); + let now = crate::vault::unix_timestamp().unwrap_or(0); + + let mut candidates: Vec<&Nip46Connection> = app + .vault + .nip46_connections + .iter() + .filter(|c| { + // Live rows only: revoked, expired, or identity-less + // connections cannot be re-dialed. + c.revoked_at.is_none() + && c.expires_at.map(|t| t > now).unwrap_or(true) + && c.profile_npub.is_some() + }) + .collect(); + // Prefer the active profile's connection, then creation order. + let active = app.vault.active_profile.clone(); + candidates + .sort_by_key(|c| (c.profile_npub.as_deref() != active.as_deref(), c.created_at)); + + let mut picked = None; + for conn in candidates { + let vault_ref = crate::signer::VaultRef::from_connection(conn); + // A restorable session needs the client key we persisted at + // pairing. Legacy rows without one are skipped. + let Ok(Some(client_key_hex)) = crate::vault::resolve_connection_client_key( + &app.vault, + vault_key.as_ref(), + &vault_ref, + ) else { + continue; + }; + // The pairing secret is optional on a re-dial (a bunker-style + // signer accepts a bare `connect`), but resolve it when the + // vault still holds one. + let connect_secret = crate::vault::resolve_connection_secret( + &app.vault, + vault_key.as_ref(), + &vault_ref, + ) + .ok() + .flatten() + .map(|s| s.to_string()); + let expected = match conn + .profile_npub + .as_deref() + .and_then(|npub| PublicKey::from_bech32(npub).ok()) + { + Some(pk) => pk, + None => continue, + }; + picked = Some(( + conn.clone(), + client_key_hex.to_string(), + connect_secret, + expected, + )); + break; + } + picked + }; + + let Some((connection, client_key_hex, connect_secret, expected_identity)) = picked else { + return Ok(0); + }; + + // Rebuild the exact wire identity of the saved session. + let secret_key = SecretKey::from_hex(client_key_hex.trim()) + .map_err(|e| AppError::internal(format!("Stored client key is unreadable: {e}")))?; + let keys = Keys::new(secret_key); + let peer = PublicKey::from_hex(&connection.signer_pubkey) + .map_err(|e| AppError::internal(format!("Stored signer key is unreadable: {e}")))?; + let relays: Vec = connection + .relays + .iter() + .filter_map(|r| RelayUrl::parse(r).ok()) + .collect(); + if relays.is_empty() { + return Err(AppError::config( + "The saved signer connection names no usable relays.", + )); + } + let conversation = ConversationKey::derive(keys.secret_key(), &peer) + .map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?; + + eprintln!( + "[NIP46] restoring session: peer={} as {} (client pubkey={})", + peer.to_hex(), + expected_identity.to_bech32().unwrap_or_default(), + keys.public_key().to_hex(), + ); + + { + let mut inner = self.inner.lock().await; + if inner.task.is_some() { + return Ok(0); + } + inner.phase = Nip46Phase::Connecting; + inner.connection = Some(connection.clone()); + inner.conversation_key = Some(conversation); + inner.keys = Some(keys.clone()); + // Identity is unknown until the handshake re-proves it; nothing + // may sign before then, and what it proves MUST be this account. + inner.identity = None; + inner.expected_identity = Some(expected_identity); + inner.active_npub = None; + inner.pending.clear(); + } + + let uri = ConnectUri { + peer, + relays, + secret: connect_secret, + permissions: None, + // Restored vault session: already-approved signers answer the + // re-`connect` with `true` instead of re-echoing the pairing + // secret; the handshake skips the echo and binds identity via + // `expected_identity` instead. + restore: true, + }; + let signer = self.clone(); + let task = tokio::spawn(async move { + if let Err(e) = signer.clone().run_sign_task(uri).await { + signer.fail(e); + } + }); + self.inner.lock().await.task = Some(task); + Ok(1) + } + + /// Begin a client-initiated pairing (NIP-46 §Direct connection initiated + /// by the client): we mint an ephemeral key + secret, publish a + /// `nostrconnect://` token (returned in `status().pairing_uri` for the + /// GUI to render as a QR), and wait on the configured relays for the + /// signer app (Amber and friends) to scan it and send us a `connect` + /// request. When it arrives we echo the secret back (anti-spoofing), + /// then resolve the signer's identity exactly like the bunker:// flow. + pub async fn start_pairing(&self, label: String) -> Result { + { + let inner = self.inner.lock().await; + if inner.task.is_some() || inner.pairing.is_some() { + return Err(AppError::config( + "Already connected or pairing with a signer. Disconnect first.", + )); + } + } + + // Pair over the user's enabled relays UNION a curated fallback set: + // signer apps (Amber et al.) pick whichever relay they like from the + // nostrconnect:// URI, and relays vary wildly in reliability for + // ephemeral kind-24133 traffic. Listening on a few extra well-known + // relays costs nothing and covers whichever one the signer chooses. + // Loopback-only relay sets (the e2e harness) skip widening so test + // pairing traffic never touches the real network. + let relays: Vec = { + let app = self.app.lock().await; + let mut urls: Vec = app + .settings + .relays + .iter() + .filter(|r| r.enabled) + .map(|r| r.url.clone()) + .collect(); + let loopback_only = !urls.is_empty() + && urls.iter().all(|u| { + let u = u.to_lowercase(); + u.contains("127.0.0.1") || u.contains("localhost") || u.contains("[::1]") + }); + if !loopback_only { + for extra in crate::relays::pairing_relays() { + if !urls.contains(&extra) { + urls.push(extra); + } + } + } + urls + } + .iter() + .filter_map(|u| RelayUrl::parse(u).ok()) + .collect(); + if relays.is_empty() { + return Err(AppError::config( + "No usable relays are configured, so there is nowhere to pair over.", + )); + } + // Loopback-only relay sets belong to the e2e harness: their fake + // scanner events must never pollute the real pairing capture/trace + // files used to dissect live signer handshakes. + let live_capture = relays.iter().any(|r| { + let u = r.to_string().to_lowercase(); + !(u.contains("127.0.0.1") || u.contains("localhost") || u.contains("[::1]")) + }); + + // Ephemeral session keys: the URI authority is this throwaway key, + // never a profile key. The secret proves WE are the app the user + // scanned — the signer must echo it back. + let keys = Keys::generate(); + let secret = crate::crypto::random_bytes::<16>()? + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let uri = NostrConnectUri::client_with_secret( + keys.public_key(), + relays.clone(), + APP_NAME, + secret.clone(), + ) + .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; + // Re-check under the lock: a concurrent connect() must lose. + if inner.task.is_some() || inner.pairing.is_some() { + return Err(AppError::config( + "Already connected or pairing with a signer. Disconnect first.", + )); + } + inner.phase = Nip46Phase::Connecting; + if live_capture { + pairing_trace(&format!( + "pairing started: ephemeral={} relays={}", + keys.public_key().to_hex(), + relays + .iter() + .map(|r| r.to_string()) + .collect::>() + .join(",") + )); + } + let signer = self.clone(); + let task = tokio::spawn(async move { + if let Err(e) = signer + .clone() + .run_pairing_task(relays, keys, secret, label) + .await + { + signer.fail(e); + } + }); + inner.pairing = Some(PairingSession { uri, task }); + } + Ok(self.status().await) + } + + /// The pairing background task: listen for the signer's inbound + /// `connect` request, echo the secret, persist the connection, then hand + /// over to the normal identity handshake + demux loop. + async fn run_pairing_task( + self, + relays: Vec, + keys: Keys, + secret: String, + label: String, + ) -> Result<(), String> { + // The e2e harness pairs over loopback relays only; its fake-scanner + // events must not pollute the real pairing forensics files. + let live_capture = relays.iter().any(|r| { + let u = r.to_string().to_lowercase(); + !(u.contains("127.0.0.1") || u.contains("localhost") || u.contains("[::1]")) + }); + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .build(); + for url in &relays { + eprintln!("[NIP46] relay connecting: {url}"); + client + .add_relay(url.to_string()) + .await + .map_err(|e| format!("Could not add relay {url}: {e}"))?; + } + client.connect().and_wait(CONNECT_TIMEOUT).await; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + let connected = loop { + let map = client.relays().all().await; + let urls: Vec = map + .into_iter() + .filter(|(_, relay)| relay.status().is_connected()) + .map(|(url, _)| url.to_string()) + .collect(); + if !urls.is_empty() || tokio::time::Instant::now() >= deadline { + break urls; + } + tokio::time::sleep(Duration::from_millis(250)).await + }; + for url in &connected { + eprintln!("[NIP46] relay connected: {url}"); + } + if connected.is_empty() { + return Err("None of the relays answered".to_string()); + } + + // Kind 24133 events tagged to OUR ephemeral pubkey: that is what a + // signer replies to after scanning the QR. The author is unknown + // until the first event lands. + let our_pk = keys.public_key(); + eprintln!( + "[NIP46] subscription created: kind=24133 #p={} (pairing listen)", + our_pk.to_hex() + ); + let filter = Filter::new() + .kind(Kind::NostrConnect) + .tag(Tag::public_key(our_pk)); + let mut notifications = client.notifications(); + let subscription = client + .subscribe(filter) + .await + .map_err(|e| format!("Could not subscribe for pairing: {e}"))?; + + let pair_deadline = tokio::time::Instant::now() + PAIRING_TIMEOUT; + let (peer, conversation, requested_perms) = loop { + let remaining = pair_deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err("Pairing timed out — nobody scanned the code.".to_string()); + } + let incoming = match tokio::time::timeout(remaining, notifications.next()).await { + Ok(Some(nostr_sdk::client::ClientNotification::Event { + subscription_id, + event, + .. + })) if subscription_id == *subscription.id() => event, + Ok(Some(nostr_sdk::client::ClientNotification::Shutdown)) | Ok(None) => { + return Err("Relay connection closed while pairing".to_string()); + } + Ok(Some(_)) => continue, + Err(_elapsed) => { + return Err("Pairing timed out — nobody scanned the code.".to_string()) + } + }; + let event = *incoming; + eprintln!( + "[nip46 pairing] inbound 24133 from {} (content len {})", + &event.pubkey.to_hex()[..16], + 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 + // full event so a failed handshake can be dissected offline. + // Loopback (e2e harness) pairings are excluded so test traffic + // never pollutes the forensics files. + if live_capture { + let path = + std::path::Path::new("/home/avi/Tools/keynctr-debug/pairing-capture.jsonl"); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + use std::io::Write; + let _ = writeln!(f, "{}", event.as_json()); + } + pairing_trace(&format!( + "inbound 24133 from {} (content len {})", + event.pubkey.to_hex(), + event.content.len() + )); + } + // Never answer ourselves. + if event.pubkey == our_pk { + continue; + } + // Not addressed to us unless it decrypts with a conversation + // keyed to this author. + let Ok(conv) = ConversationKey::derive(keys.secret_key(), &event.pubkey) else { + eprintln!("[nip46 pairing] could not derive a conversation with this author"); + continue; + }; + let plain = match nip44_decrypt(&conv, &event.content) { + Ok(p) => { + eprintln!( + "[NIP46] decrypting response from {}: OK ({} plaintext bytes)", + event.pubkey.to_hex(), + p.len() + ); + p + } + Err(e) => { + // Surface the REAL failure (HMAC vs wrong key vs invalid + // padding): a malformed NIP-44 frame looks identical to a + // wrong conversation key otherwise. + eprintln!( + "[nip46 pairing] payload did not decrypt: {e} (frame {} chars b64)", + event.content.len() + ); + if live_capture { + pairing_trace(&format!( + "payload did not decrypt: {e} (frame {} chars b64)", + event.content.len() + )); + } + continue; + } + }; + let shaped: serde_json::Value = + serde_json::from_str(&plain).unwrap_or(serde_json::Value::Null); + // Per NIP-46, for a client-initiated `nostrconnect://` the signer + // sends a connect *response* — `{"id":…,"result":""}` — + // not a `connect` request: there is nothing to answer, the secret + // echo IS the handshake. (Spec: "the _remote-signer_ … then sends + // `connect` *response* event to the `client-pubkey`"; result is + // `"ack"` OR the secret.) The legacy request shape is still + // handled below for signers that send `{"method":"connect"}`. + if shaped.get("method").is_none() + && (shaped.get("result").is_some() || shaped.get("error").is_some()) + { + if shaped.get("error").is_some() && !shaped["error"].is_null() { + let msg = shaped["error"] + .as_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| shaped["error"].to_string()); + return Err(format!("The signer rejected the connection: {msg}")); + } + let result = shaped["result"].as_str().unwrap_or(""); + // Never log the secret values themselves — only the verdict. + if result != secret && result != "ack" { + eprintln!("[NIP46] secret validation: FAIL (echo did not match)"); + eprintln!( + "[nip46 pairing] connect response echoed the wrong secret — ignoring" + ); + if live_capture { + pairing_trace("connect response echoed the wrong secret — ignoring"); + } + continue; + } + 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 { + pairing_trace("connect response accepted (secret echo verified)"); + } + // Tear down the pairing subscription BEFORE handing over: + // from here on the demux loop owns the conversation. + client.unsubscribe(subscription.id()).await.ok(); + break Ok::<_, String>((event.pubkey, conv, None)); + } + let Ok(request) = serde_json::from_str::(&plain) else { + eprintln!("[nip46 pairing] decrypted payload is not a NIP-46 request: {plain}"); + if live_capture { + pairing_trace(&format!( + "decrypted payload is not a NIP-46 request: {plain}" + )); + } + continue; + }; + if request.method != "connect" { + // Anything before the handshake is premature — ignore. + eprintln!( + "[NIP46] decrypted method: {} (pre-handshake — ignored)", + request.method + ); + eprintln!("[nip46 pairing] pre-handshake '{}' ignored", request.method); + if live_capture { + pairing_trace(&format!("pre-handshake '{}' ignored", request.method)); + } + continue; + } + // Legacy path: a signer that sends an actual `connect` *request* + // gets the secret as the answer, and it verifies the echo. + let response = response_ok(&request.id, secret.clone()); + // Tear down the pairing subscription BEFORE answering: from here + // on the demux loop owns the conversation. If both subscriptions + // stayed open, a relay could deliver the signer's next message + // under the pairing subscription id, where nobody routes it. + client.unsubscribe(subscription.id()).await.ok(); + if let Err(e) = self + .publish_payload(&client, &keys, &conv, &event.pubkey, &response) + .await + { + return Err(format!("Could not answer the connect request: {e}")); + } + 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 { + pairing_trace("connect answered; awaiting identity handshake"); + } + // Optional requested_perms ride in params[1]; parse leniently — + // a malformed grant list degrades to "no local enforcement", + // which defers to the signer's own approval prompts. + let perms = request + .params + .get(1) + .and_then(|raw| Nip46Permissions::parse(raw).ok()); + break Ok::<_, String>((event.pubkey, conv, perms)); + }?; + + // Paired. Persist the connection row + its secret BEFORE adopting the + // identity, so a crash mid-handshake still leaves a recoverable + // (if unverified) row, and so `send_rpc` inside the handshake can + // find its conversation and peer. + let connection = Nip46Connection { + profile_npub: None, + signer_pubkey: peer.to_hex(), + relays: relays.iter().map(|r| r.to_string()).collect(), + label, + created_at: crate::vault::unix_timestamp().map_err(|e| e.to_string())?, + permissions: requested_perms, + expires_at: None, + revoked_at: None, + }; + { + let mut app = self.app.lock().await; + app.vault.nip46_connections.retain(|c| { + !(c.signer_pubkey == connection.signer_pubkey + && c.profile_npub == connection.profile_npub) + }); + let vault_ref = crate::signer::VaultRef::from_connection(&connection); + let vault_key = app.vault_key().copied(); + crate::vault::store_connection_secret( + &mut app.vault, + vault_key.as_ref(), + &vault_ref, + &secret, + ) + .map_err(|e| e.to_string())?; + // Persist OUR client secret key alongside the pairing secret: + // Amber remembers this client pubkey as our identity for the + // life of the connection, so re-dialing after a restart must + // reuse it (see Vault::connection_client_keys). + crate::vault::store_connection_client_key( + &mut app.vault, + vault_key.as_ref(), + &vault_ref, + &hex::encode(keys.secret_key().to_secret_bytes()), + ) + .map_err(|e| e.to_string())?; + app.vault.nip46_connections.push(connection.clone()); + app.save_vault().map_err(|e| e.to_string())?; + } + + // The QR has been consumed: drop the pairing session (its URI leaves + // status) and move the session state where send_rpc expects it. The + // phase stays `Connecting` — identity is still unverified — and the + // demux loop (which answers the handshake's RPCs) takes over. + if live_capture { + pairing_trace("paired: connection stored; handing over to identity handshake"); + } + let demux_uri = ConnectUri { + peer, + relays, + secret: Some(secret), + permissions: connection.permissions.clone(), + restore: false, + }; + let paired = { + let mut inner = self.inner.lock().await; + if inner.pairing.take().is_none() { + // disconnect() raced us — tear the fresh session down. + Some(()) + } else { + inner.connection = Some(connection); + inner.conversation_key = Some(conversation); + inner.keys = Some(keys.clone()); + inner.client = Some(client.clone()); + inner.identity = None; + inner.pending.clear(); + None + } + }; + if paired.is_some() { + let _ = client.disconnect().await; + return Err("Pairing was cancelled".to_string()); + } + + let signer = self.clone(); + let task = tokio::spawn(async move { + if let Err(e) = signer + .clone() + .run_paired(demux_uri, keys, conversation, client) + .await + { + signer.fail(e); + } + }); + self.inner.lock().await.task = Some(task); + Ok(()) + } + + /// Post-pairing session body: adopt the signer's identity (learned via + /// `get_public_key` — the scanner could present a per-connection comms + /// key) while the demux loop answers its RPCs. In this flow WE already + /// answered the signer's `connect` with the secret echo, so there is no + /// outbound `connect` to send — just identity resolution, then serve. + async fn run_paired( + self, + uri: ConnectUri, + keys: Keys, + conversation: ConversationKey, + client: Client, + ) -> Result<(), String> { + // Subscribe BEFORE the handshake publishes anything: relays only push + // events to subscriptions that exist at delivery time, and the + // identity RPC (`get_public_key`) fires within milliseconds of this + // point. When the spawn raced ahead of the subscription, Amber's fast + // reply landed in the gap and was lost — the live Sep 23 scan died + // exactly there ("signer would not reveal its public key" after a + // clean `connect` + secret echo). + let (notifications, subscription) = self.subscribe_to_signer(&client, uri.peer).await?; + { + let handshake = self.clone(); + let peer = uri.peer; + tokio::spawn(async move { + if let Err(e) = handshake.clone().adopt_identity(peer).await { + handshake.fail(e); + } + }); + } + self.run_demux(uri, keys, conversation, client, notifications, subscription) + .await + } + + /// Open the notification stream and register the kind-24133 author + /// subscription for the signer. Kept separate so every connect path can + /// establish it BEFORE publishing its first RPC. + async fn subscribe_to_signer( + &self, + client: &Client, + peer: PublicKey, + ) -> Result< + ( + std::pin::Pin + Send>>, + Output, + ), + String, + > { + 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 subscription = client + .subscribe(filter) + .await + .map_err(|e| format!("Could not subscribe: {e}"))?; + eprintln!( + "[NIP46] subscription registered: id={} — listening before any publish", + subscription.id() + ); + Ok((notifications, subscription)) + } + /// Get current connection status. pub async fn status(&self) -> Nip46Status { let inner = self.inner.lock().await; @@ -372,6 +1235,11 @@ impl Nip46ClientSigner { _ => None, }, pending_approvals: pending, + pairing_uri: if matches!(inner.phase, Nip46Phase::Connecting) { + inner.pairing.as_ref().map(|p| p.uri.clone()) + } else { + None + }, } } @@ -392,10 +1260,39 @@ impl Nip46ClientSigner { /// Approve or reject a pending request. pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> { - let mut inner = self.inner.lock().await; - let Some(entry) = inner.pending.remove(id) else { - return Err(AppError::config("Request no longer pending")); + self.respond_to_approval_with_always(id, approved, false) + .await + } + + /// Answer a pending request, optionally recording a standing grant so + /// this peer's future requests of the same method skip the prompt + /// ("always allow", like Amber and other signer apps offer). + pub async fn respond_to_approval_with_always( + &self, + id: &str, + approved: bool, + always: bool, + ) -> Result<(), AppError> { + let (entry, peer_hex) = { + let mut inner = self.inner.lock().await; + let entry = inner + .pending + .remove(id) + .ok_or_else(|| AppError::config("Request no longer pending"))?; + // The grant is scoped to whoever SENT the request — the + // connection's remote pubkey. + let peer = inner + .connection + .as_ref() + .map(|c| c.signer_pubkey.clone()) + .unwrap_or_default(); + (entry, peer) }; + if approved && always && !peer_hex.is_empty() { + let mut app = self.app.lock().await; + app.vault.grant_signer_method(&peer_hex, &entry.method)?; + app.save_vault()?; + } let _ = entry.sender.send(if approved { ApprovalResult::Approved } else { @@ -405,13 +1302,29 @@ impl Nip46ClientSigner { } fn fail(&self, message: impl Into) { + let message = message.into(); + eprintln!("[nip46] session failed: {message}"); + eprintln!("[NIP46] session failed: {message}"); + pairing_trace(&format!("session failed: {message}")); if let Ok(mut inner) = self.inner.try_lock() { - inner.phase = Nip46Phase::Error(message.into()); + // 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.task = None; inner.client = None; inner.conversation_key = None; inner.keys = None; inner.pending.clear(); + for (_, waiter) in inner.remote_pending.drain() { + let _ = waiter.sender.send(Err( + "Lost the connection to the signer before it responded.".to_string(), + )); + } } } @@ -447,6 +1360,153 @@ impl Nip46ClientSigner { } } + /// Send a NIP-46 request to the connected remote signer and await its + /// encrypted response (the client half of the protocol — e.g. asking the + /// signer to `sign_event` an event for us). + /// + /// Fails closed: no connection, no live session, or a signer error all + /// return [`SigningError`] rather than falling back to any local key. The + /// waiter is always removed from the pending map, even on timeout, so a + /// late response to an abandoned request finds nothing to wake. + /// + /// Uses the human-approval leash ([`SIGN_TIMEOUT`]), not the 30s + /// ordinary-RPC one: every call here asks a human to approve on the + /// signer's device. + async fn send_remote_request( + &self, + method: &str, + params: Vec, + ) -> Result { + self.send_rpc(method, params, SIGN_TIMEOUT, true).await + } + + /// Send an encrypted NIP-46 RPC and await its response. + /// + /// `require_connected` gates on the session phase: ordinary RPCs need a + /// fully established session, while the handshake itself runs while the + /// phase is still `Connecting`. + async fn send_rpc( + &self, + method: &str, + params: Vec, + timeout: Duration, + require_connected: bool, + ) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + + // Snapshot the live session, register the waiter, and send the + // encrypted request in one lock pass. Holding the lock across the send + // is safe (the send is a relay hand-off, never a re-entry into this + // signer) and guarantees registration cannot interleave with the send. + let send_result = { + let mut inner = self.inner.lock().await; + let client = inner.client.clone().ok_or(SigningError::NotConnected)?; + let keys = inner.keys.clone().ok_or(SigningError::NotConnected)?; + let conversation = inner + .conversation_key + .as_ref() + .cloned() + .ok_or(SigningError::NotConnected)?; + let connection = inner.connection.clone().ok_or(SigningError::NotConnected)?; + if !connection_valid_now(&connection) { + return Err(if connection.revoked_at.is_some() { + SigningError::ConnectionRevoked + } else { + SigningError::ConnectionExpired + }); + } + if require_connected && !matches!(inner.phase, Nip46Phase::Connected) { + return Err(SigningError::NotConnected); + } + if inner.remote_pending.len() >= MAX_PENDING_APPROVALS { + return Err(SigningError::Internal { + detail: "Too many requests already awaiting the signer.".to_string(), + }); + } + let peer = PublicKey::from_hex(&connection.signer_pubkey).map_err(|e| { + SigningError::Internal { + detail: format!("Invalid signer public key: {e}"), + } + })?; + inner + .remote_pending + .insert(id.clone(), PendingRemoteRequest { sender }); + + let payload = json!({ "id": id, "method": method, "params": params }).to_string(); + match self + .publish_payload(&client, &keys, &conversation, &peer, &payload) + .await + { + Ok(accepted) => { + if live_relays(&connection.relays) { + pairing_trace(&format!( + "rpc published: method={} id={} accepted_by={}", + method, + &id[..8], + if accepted.is_empty() { + "NONE".to_string() + } else { + accepted.join(",") + } + )); + } + Ok(()) + } + Err(detail) => Err(detail), + } + }; + if let Err(detail) = send_result { + self.inner.lock().await.remote_pending.remove(&id); + 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 { + 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(_)) => { + // 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(_) => { + // 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); + Err(SigningError::Timeout) + } + } + } + /// Main background task: connect to relays, subscribe, handle requests. async fn run_sign_task(self, uri: ConnectUri) -> Result<(), String> { let (conversation, keys) = { @@ -461,11 +1521,17 @@ impl Nip46ClientSigner { }; // 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::>(), + ); let client = Client::builder() .authenticator(SignerAuthenticator::new(keys.clone())) .build(); for url in &uri.relays { + eprintln!("[NIP46] relay connecting: {url}"); client .add_relay(url.to_string()) .await @@ -487,6 +1553,9 @@ impl Nip46ClientSigner { } tokio::time::sleep(Duration::from_millis(250)).await }; + for url in &connected { + eprintln!("[NIP46] relay connected: {url}"); + } if connected.is_empty() { return Err("None of the relays answered".to_string()); @@ -495,49 +1564,163 @@ impl Nip46ClientSigner { // Update connected relays self.inner.lock().await.client = Some(client.clone()); - // Subscribe to kind 24133 from signer - let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); - let mut notifications = client.notifications(); - let subscription = client - .subscribe(filter) + // Subscribe BEFORE the handshake publishes `connect`: relays only + // push the signer's replies to subscriptions that exist at delivery + // time, so registering after the first publish can silently drop the + // ack (see run_paired for the live incident this fixes). + let (notifications, subscription) = self.subscribe_to_signer(&client, uri.peer).await?; + + // The connect handshake runs as its own task: it publishes `connect` + // and then must AWAIT the signer's ack — which can wait on a human + // approving us in Amber — followed by `get_public_key` to learn the + // REAL signing identity. Responses only arrive through the demux + // loop below, so the handshake must not block it. The session stays + // `Connecting` (and every signing path fails closed) until the + // handshake resolves the identity. + { + let handshake = self.clone(); + let peer = uri.peer; + let expected_secret = uri.secret.clone(); + let restore = uri.restore; + tokio::spawn(async move { + if let Err(e) = handshake + .clone() + .run_handshake(peer, expected_secret, restore) + .await + { + handshake.fail(e); + } + }); + } + + self.run_demux(uri, keys, conversation, client, notifications, subscription) .await - .map_err(|e| format!("Could not subscribe: {e}"))?; - - // Send connect request - self.send_connect(&client, &keys, &conversation, &uri) - .await?; - - // Mark as connected - self.inner.lock().await.phase = Nip46Phase::Connected; + } + /// The long-lived request/response demux loop, shared by both connect + /// directions: bunker:// (we dialed out and spawned the handshake) and + /// nostrconnect:// QR pairing (the signer dialed in and we already + /// answered its `connect`). Terminates when the relays close or the + /// handshake task fails the session. + async fn run_demux( + self, + uri: ConnectUri, + keys: Keys, + conversation: ConversationKey, + client: Client, + mut notifications: std::pin::Pin< + Box + Send>, + >, + subscription: Output, + ) -> Result<(), String> { // Handle incoming requests loop { - let incoming = match notifications.next().await { - Some(nostr_sdk::client::ClientNotification::Event { - subscription_id, - event, - .. - }) if subscription_id == *subscription.id() => event, - Some(nostr_sdk::client::ClientNotification::Shutdown) | None => { - return Err("Connection closed".to_string()); - } - Some(_) => continue, - }; + let incoming = + match tokio::time::timeout(Duration::from_secs(2), notifications.next()).await { + Ok(Some(nostr_sdk::client::ClientNotification::Event { + subscription_id, + event, + .. + })) if subscription_id == *subscription.id() => event, + Ok(Some(nostr_sdk::client::ClientNotification::Shutdown)) | Ok(None) => { + return Err("Connection closed".to_string()); + } + Ok(Some(_)) => continue, + Err(_elapsed) => { + // Idle tick: if the handshake task failed, the session is + // dead — exit so teardown runs instead of listening on a + // connection that can never sign. + if matches!(self.inner.lock().await.phase, Nip46Phase::Error(_)) { + return Err("Connect handshake failed".to_string()); + } + continue; + } + }; 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; } let plaintext = match nip44_decrypt(&conversation, &event.content) { 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 + // WE sent (e.g. `sign_event`), not an incoming signer request. + // Deliver it to the waiting caller; unknown ids are ignored — a + // stray response must never earn an error reply back to the + // signer, and must never fall through to the request path. + let shaped: serde_json::Value = + serde_json::from_str(&plaintext).unwrap_or(serde_json::Value::Null); + 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 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"] + .as_str() + .unwrap_or("The signer reported an error.") + .to_string()) + } else { + Ok(shaped["result"].as_str().unwrap_or("").to_string()) + }; + let waiter = self.inner.lock().await.remote_pending.remove(id); + match waiter { + Some(pending) => { + eprintln!("[NIP46] waiter found for id={} — delivering", id); + let _ = pending.sender.send(outcome); + } + None => { + eprintln!( + "[NIP46] stale/duplicate response: no waiter for id={} — dropped", + id + ); + eprintln!("Ignoring NIP-46 response with no waiting request: {id}"); + } + } + continue; + } + let request: RawRequest = match serde_json::from_str(&plaintext) { 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) { self.gated_response(&keys, &request).await @@ -558,7 +1741,7 @@ impl Nip46ClientSigner { async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option { // 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(), @@ -574,10 +1757,10 @@ impl Nip46ClientSigner { .and_then(|json| serde_json::from_str::(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, }; @@ -589,6 +1772,28 @@ impl Nip46ClientSigner { )); } + // Standing grant ("always allow") for this peer + method: skip the + // prompt and run. Grants are per (peer pubkey, method) and revocable + // from the Signer screen. + { + let peer_hex = self + .inner + .lock() + .await + .connection + .as_ref() + .map(|c| c.signer_pubkey.clone()) + .unwrap_or_default(); + if !peer_hex.is_empty() { + let app = self.app.lock().await; + if app.vault.has_signer_grant(&peer_hex, &request.method) { + drop(app); + self.inner.lock().await.phase = Nip46Phase::Connected; + return self.approved_response(keys, request); + } + } + } + self.inner.lock().await.phase = Nip46Phase::Connected; let details = self.describe_request(request); match self.await_approval(details).await { @@ -608,7 +1813,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(), @@ -628,7 +1833,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, @@ -638,7 +1843,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, @@ -781,19 +1986,44 @@ impl Nip46ClientSigner { } } - async fn send_connect( - &self, - client: &Client, - keys: &Keys, - conversation: &ConversationKey, - uri: &ConnectUri, + /// 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 { + 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. + /// + /// 1. Send `connect` (our client pubkey + the URI secret, resolved + /// on-demand from the vault — never from memory). + /// 2. Await the signer's ack. This is where a human approving us in + /// Amber happens, so the wait is long (HANDSHAKE_TIMEOUT). + /// 3. Call `get_public_key` to learn the REAL signing identity. The + /// pubkey in the connect URI may be a per-connection communication + /// key (Amber mints one per app); trusting it would publish under a + /// throwaway key. + /// 4. Persist the identity: create/refresh the secretless + /// `Nip46Client` profile row, re-key the vault secret store under + /// it, and only then flip the phase to `Connected`. + /// + /// Until step 4 completes the session is `Connecting`, so every signing + /// path fails closed — nothing can publish under an unverified key. + async fn run_handshake( + self, + peer: PublicKey, + expected_secret: Option, + restore: bool, ) -> Result<(), String> { - // Resolve the nostrconnect secret ON-DEMAND from the vault — the single - // source of truth — rather than reading an in-memory copy. The - // connection carries no secret; it is keyed by its VaultRef and fetched - // fresh here. Fail-closed: if the vault cannot produce the secret - // (e.g. it is encrypted and currently locked) the connect is refused - // instead of being sent without it. + // Resolve the connect secret ON-DEMAND from the vault — the single + // source of truth. Fail-closed: if the vault cannot produce the + // secret the connect is refused rather than sent incomplete. let secret = { let connection = { let inner = self.inner.lock().await; @@ -820,18 +2050,374 @@ impl Nip46ClientSigner { } }; - let mut params = vec![keys.public_key().to_hex()]; - if let Some(secret) = &secret { - params.push(secret.clone()); - } - let payload = json!({ - "id": uuid::Uuid::new_v4().to_string(), - "method": "connect", - "params": params, - }) - .to_string(); - self.publish_payload(client, keys, conversation, &uri.peer, &payload) + let params = Self::connect_params(peer, secret.as_deref()); + eprintln!( + "[NIP46] requesting connect: peer={} params=[remote_pubkey, {}] — awaiting signer ack", + peer.to_hex(), + if secret.is_some() { + "secret:present" + } else { + "secret:absent" + }, + ); + + // Await the ack (may wait on a human approving in Amber). + let ack = self + .send_rpc("connect", params, HANDSHAKE_TIMEOUT, false) .await + .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:// + // (secret-carrying) handshake must echo the secret back. A mismatch + // means something other than the intended signer answered. + // + // RESTORED sessions are exempt from the echo: the secret they resend + // is the ORIGINAL pairing secret, and a signer that already approved + // this client answers `true` instead of re-echoing (an echo would + // re-prove possession that the initial pairing proved once). Real + // Amber does exactly this, so requiring the echo made every restart + // fail with "did not echo the connection secret". The restore path's + // anti-spoofing is `expected_identity`, enforced in adopt_identity: + // anything that answers as another account is refused, and only the + // key holder can decrypt our traffic on the stored conversation key. + if let Some(expected) = &expected_secret { + let trimmed = ack.trim(); + if trimmed == expected.as_str() { + eprintln!("[NIP46] secret validation: PASS (connect ack echo matched)"); + } else if restore { + eprintln!( + "[NIP46] secret validation: SKIPPED (restored session; ack={trimmed}) — identity will be re-proved" + ); + } else { + eprintln!("[NIP46] secret validation: FAIL (connect ack echo did not match)"); + return Err( + "The signer did not echo the connection secret; refusing the connection." + .to_string(), + ); + } + } + + self.adopt_identity(peer).await + } + + /// Resolve the signer's REAL identity and make it the session identity. + /// + /// Shared by both connect directions: after the `connect` handshake is + /// acked (bunker:// flow, where WE sent connect) or after we answered the + /// signer's inbound `connect` request (nostrconnect:// QR flow). Steps: + /// + /// 1. `get_public_key` — the URI key may be a per-connection comms key + /// (Amber mints one per app); only the signer's answer is identity. + /// 2. Persist: create/refresh the secretless `Nip46Client` profile row, + /// re-key the vault secret store under the identity npub. + /// 3. Flip the phase to `Connected` — until then every signing path + /// fails closed on an unverified key. + async fn adopt_identity(&self, peer: PublicKey) -> Result<(), String> { + // Snapshot the pre-identity connection (label + vault ref for the + // secret re-key) before touching the App lock. + let (connection, label) = { + let inner = self.inner.lock().await; + let connection = inner + .connection + .clone() + .ok_or("No active NIP-46 connection to adopt an identity for")?; + (connection.clone(), connection.label.clone()) + }; + + // Learn the REAL signing identity from the signer itself. + // + // RETRIED with backoff on timeout. The live Sep 23 scans proved a + // structural race on the SIGNER side: our first `get_public_key` + // publishes the instant the connect echo is verified, but Amber's + // own subscription to our client key opens milliseconds later, so + // the request is already in the relays' past when its listener + // starts — relays never replay history to a fresh subscription and + // the answer never comes. A retry lands while the signer is + // listening. Non-timeout failures (refusal, bad payload) still fail + // fast. + eprintln!("[NIP46] requesting get_public_key (identity resolution)"); + let mut attempt = 0u32; + let identity = loop { + 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 = { + 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 + .send_rpc("get_public_key", vec![], REQUEST_TIMEOUT, false) + .await + { + Ok(identity) => { + eprintln!("[NIP46] received get_public_key response"); + break identity; + } + Err(e) => { + if !matches!(e, SigningError::Timeout) || attempt >= 4 { + 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) { + pairing_trace(&format!( + "identity attempt {attempt} timed out; retrying get_public_key" + )); + } + tokio::time::sleep(Duration::from_secs(match attempt { + 1 => 3, + 2 => 8, + _ => 15, + })) + .await; + } + } + }; + let identity = PublicKey::from_hex(identity.trim()) + .map_err(|e| format!("The signer returned an unreadable public key: {e}"))?; + // Cross-account guard: on a RESTORED session the account identity was + // already pinned before we dialed. If the signer answers as a + // different key — a different Amber account, a mistyped bunker, a + // relay spoof — refuse the session outright. Fresh pairings have no + // expectation (the signer's answer defines the identity) and are + // unaffected. + let expected = self.inner.lock().await.expected_identity; + if let Some(expected) = expected { + if identity != expected { + eprintln!( + "[NIP46] identity check on restored session: FAIL (signer answered {}, expected {})", + identity.to_hex(), + expected.to_hex() + ); + return Err(format!( + "The restored signer answered as a different account ({}). Refusing to connect it to this profile — reconnect with a fresh scan.", + identity.to_bech32().unwrap_or_else(|_| identity.to_hex()) + )); + } + eprintln!("[NIP46] identity check on restored session: PASS"); + } + let identity_npub = identity + .to_bech32() + .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 + // AFTER the session flips to Connected, as a background enrichment + // (see below). A slow or silent relay must never delay the connection + // becoming usable — this used to block here and made pairing look + // dead in the UI for many seconds after the signer had already + // approved. + let pairing_label = label; + let display_label = pairing_label.clone(); + + // Persist: profile row for the identity, connection + secret store + // re-keyed under it. Refuses (fails the handshake) if the identity + // collides with a local profile. + { + let mut app = self.app.lock().await; + profiles::store_remote_profile(&mut app.vault, &identity_npub, display_label.clone()) + .map_err(|e| e.message().to_string())?; + // Re-key the stored secret under the identity npub so the + // connection row, VaultRef, and secret store all agree. + let connect_ref = crate::signer::VaultRef::from_connection(&connection); + let vault_key = app.vault_key().copied(); + let secret = crate::vault::resolve_connection_secret( + &app.vault, + vault_key.as_ref(), + &connect_ref, + ) + .ok() + .flatten(); + let new_ref = crate::signer::VaultRef::new(Some(identity_npub.clone()), peer.to_hex()); + if let Some(s) = &secret { + crate::vault::store_connection_secret( + &mut app.vault, + vault_key.as_ref(), + &new_ref, + s, + ) + .map_err(|e| e.message().to_string())?; + crate::vault::delete_connection_secret(&mut app.vault, &connect_ref); + } + // Re-key OUR client secret key the same way, so the + // identity-keyed VaultRef can resolve it for reactivation. + // MUST run even when there is no pairing secret (a bare + // bunker:// connect carries none) — otherwise the key strands + // under the pre-identity ref and the session is never + // restorable. + if let Some(ck) = crate::vault::resolve_connection_client_key( + &app.vault, + vault_key.as_ref(), + &connect_ref, + ) + .ok() + .flatten() + { + crate::vault::store_connection_client_key( + &mut app.vault, + vault_key.as_ref(), + &new_ref, + &ck, + ) + .map_err(|e| e.message().to_string())?; + crate::vault::delete_connection_client_key(&mut app.vault, &connect_ref); + } + if let Some(conn) = app.vault.nip46_connections.iter_mut().find(|c| { + c.signer_pubkey == peer.to_hex() && c.profile_npub != Some(identity_npub.clone()) + }) { + conn.profile_npub = Some(identity_npub.clone()); + } + app.save_vault().map_err(|e| e.message().to_string())?; + } + + // Identity verified: adopt it and open the session for signing. + let mut inner = self.inner.lock().await; + inner.identity = Some(identity); + inner.active_npub = Some(identity_npub.clone()); + // Keep the in-memory connection in sync with the re-keyed vault row, + // so later teardown (disconnect) deletes the secret under the ref it + // was actually stored at. + if let Some(conn) = inner.connection.as_mut() { + conn.profile_npub = Some(identity_npub.clone()); + } + inner.phase = Nip46Phase::Connected; + // Only trace LIVE pairings: adopt_identity runs in the e2e harness + // too, and an un-gated line here put test-run "CONNECTED" entries + // into the forensics log where they were mistaken for a live Amber + // scan during debugging. + if live_relays(&connection.relays) { + pairing_trace(&format!( + "identity adopted: npub={} peer={} — CONNECTED", + identity_npub, + peer.to_hex() + )); + } + drop(inner); + eprintln!("[NIP46] UI connection state updated: Connected ({identity_npub})"); + + // Background enrichment (post-Connected by design): look up the + // identity's kind-0 metadata so the profile row carries the real + // display name / picture / nip05 instead of the generic pairing + // label. A slow or silent relay must never gate the session — the + // UI polls status and vault, so the row fills in a moment later. + { + let app = self.app.clone(); + tokio::spawn(async move { + let relays_for_meta = { + let app = app.lock().await; + crate::relays::enabled_urls(&app.settings) + }; + // Outer budget must exceed the inner worst case: + // fetch_profile_metadata waits up to 10s for the pool to + // connect (relay.nostr.band is a known handshake-hanger and + // sits in the user's relay list), then fetches per relay. + // Retry a few times with a pause: live Sep 25 nos.lol — the + // only relay holding the account's kind-0 — 502'd every + // client for minutes, then served it fine. A single-shot + // attempt turns such a blip into a permanently generic + // label. + let mut meta = None; + for attempt in 1..=4 { + match tokio::time::timeout( + Duration::from_secs(75), + tokio::task::spawn_blocking({ + let identity = identity; + let relays_for_meta = relays_for_meta.clone(); + move || profiles::fetch_profile_metadata(&identity, &relays_for_meta) + }), + ) + .await + { + Ok(Ok(Some(found))) => { + meta = Some(found); + break; + } + Ok(Ok(None)) => pairing_trace(&format!( + "auto-name attempt {attempt}: no kind-0 found on any relay" + )), + Ok(Err(join_err)) => pairing_trace(&format!( + "auto-name attempt {attempt}: fetch task panicked: {join_err}" + )), + Err(_) => pairing_trace(&format!( + "auto-name attempt {attempt}: fetch timed out (75s budget)" + )), + } + if attempt < 4 { + tokio::time::sleep(Duration::from_secs(20)).await; + } + } + let meta = match meta { + Some(meta) => meta, + None => return, + }; + pairing_trace(&format!( + "auto-name: kind-0 fetched (display_name={:?} name={:?})", + meta.display_name, meta.name + )); + let mut app = app.lock().await; + let mut changed = false; + if let Some(row) = app + .vault + .profiles + .iter_mut() + .find(|p| p.public_key == identity_npub) + { + if row.picture.is_none() { + row.picture = meta.picture.clone(); + changed = true; + } + if row.nip05.is_none() { + row.nip05 = meta.nip05.clone(); + changed = true; + } + // Upgrade the generic pairing label to the real display + // name only while the row still carries the label we set + // during pairing — a user rename always wins. + let real_name = meta + .display_name + .as_deref() + .or(meta.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()); + if let Some(name) = real_name { + if row.label == pairing_label { + row.label = name.to_string(); + changed = true; + } + } + } + if changed { + let _ = app.save_vault(); + } + }); + } + Ok(()) } async fn publish_payload( @@ -841,7 +2427,7 @@ impl Nip46ClientSigner { conversation: &ConversationKey, peer: &PublicKey, payload: &str, - ) -> Result<(), String> { + ) -> Result, String> { let content = nip44_encrypt(conversation, payload).map_err(|e| e.message().to_string())?; let tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?; let event = EventBuilder::new(Kind::NostrConnect, content) @@ -849,11 +2435,11 @@ impl Nip46ClientSigner { .finalize_async(keys) .await .map_err(|e| format!("Could not sign: {e}"))?; - client + let output = client .send_event(&event) .await .map_err(|e| format!("{e}"))?; - Ok(()) + Ok(output.success.keys().map(|u| u.to_string()).collect()) } } @@ -870,25 +2456,47 @@ impl Clone for Nip46ClientSigner { impl Signer for Nip46ClientSigner { async fn get_public_key(&self) -> Result { let inner = self.inner.lock().await; - let connection = inner - .connection - .as_ref() - .ok_or(SigningError::NotConnected)?; - PublicKey::from_hex(&connection.signer_pubkey).map_err(|e| SigningError::Internal { - detail: format!("Invalid signer public key: {e}"), - }) + // The identity learned from the signer during the handshake — NOT + // the URI key, which for bunker:// flows is a per-connection comms + // key. Until the handshake resolves it the session is not usable. + inner.identity.ok_or(SigningError::NotConnected) } - async fn sign_event(&self, _event: UnsignedEvent) -> Result { - // For NIP-46 client, signing happens via the NIP-46 channel with user - // approval. The actual flow uses request_approval + - // respond_to_approval; this method exists to satisfy the object-safe - // trait and fails closed if a caller tries to bypass it. - Err(SigningError::Internal { - detail: - "NIP-46 signing uses the async approval flow; direct sign_event is not supported" - .to_string(), - }) + async fn sign_event(&self, event: UnsignedEvent) -> Result { + // The client half of NIP-46: ask the remote signer to sign, await the + // 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()).await { + self.audit_permission_denied("sign_event").await; + return Err(SigningError::PermissionDenied { + method: "sign_event".to_string(), + }); + } + let unsigned_json = event.try_as_json().map_err(|e| SigningError::Internal { + detail: format!("Could not encode the event for signing: {e}"), + })?; + let response = self + .send_remote_request("sign_event", vec![unsigned_json]) + .await?; + let signed = Event::from_json(response.as_bytes()).map_err(|e| SigningError::Internal { + detail: format!("The signer returned an unreadable event: {e}"), + })?; + // The signer must sign WITH the identity it is connected as... + let signer_pubkey = Signer::get_public_key(self).await?; + if signed.pubkey != signer_pubkey { + return Err(SigningError::IdentityMismatch); + } + // ...the exact event we sent (same id covers pubkey, kind, tags, + // content, timestamp)... + if signed.id != event.compute_id() { + return Err(SigningError::InvalidSignature); + } + // ...and with a cryptographically valid signature. + signed + .verify() + .map_err(|_| SigningError::InvalidSignature)?; + Ok(signed) } fn get_signer_type(&self) -> SignerType { @@ -933,54 +2541,60 @@ impl Signer for Nip46ClientSigner { // ── Permission checks ─────────────────────────────────────────────── - fn permissions(&self) -> Option { - // 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 { + let inner = self.inner.lock().await; inner .connection .as_ref() .and_then(|c| c.permissions.clone()) } - fn can_sign_event(&self, kind: u16) -> bool { - match self.permissions() { + // NOTE on the `None` arms below: when the connect URI declared no + // `perms=`, there is no local grant list to enforce — enforcement lives + // on the signer itself (Amber shows an approval screen per request). + // Sending the request and letting the signer decide is the NIP-46 flow; + // refusing locally would make bunker:// connections (which carry no + // perms) unusable. When perms WERE declared, the local list is enforced + // as an additional guard. + async fn can_sign_event(&self, kind: u16) -> bool { + match self.permissions().await { Some(ref perms) => perms.is_sign_event_kind_allowed(kind), - None => false, + 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 => false, + 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 => false, + None => true, } } - fn can_get_public_key(&self) -> bool { - match self.permissions() { + 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().await { Some(ref perms) => perms.is_get_public_key_allowed(), - None => false, + 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) => { @@ -996,19 +2610,89 @@ impl Signer for Nip46ClientSigner { } } -/// Minimal decrypted NIP-46 request. -#[derive(Debug, Deserialize)] +/// Minimal decrypted NIP-46 request. Deserialization is maximally lenient: +/// real signers deviate from the spec'd shape (`id` numeric, `params` an +/// object instead of an array, raw JSON grants embedded in params), and a +/// strict parse silently dropped the pairing handshake. Every scalar is +/// coerced to text instead of rejecting the request. +#[derive(Debug)] struct RawRequest { id: String, method: String, - #[serde(default)] params: Vec, } +impl<'de> serde::Deserialize<'de> for RawRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + fn to_text(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } + } + let value = serde_json::Value::deserialize(deserializer)?; + let mut obj = match value { + serde_json::Value::Object(map) => map, + // Double-encoded payload: a JSON string wrapping the request + // object. Unwrap one level and retry. + serde_json::Value::String(ref s) => { + match serde_json::from_str::(s) { + Ok(serde_json::Value::Object(map)) => map, + _ => { + return Ok(RawRequest { + id: String::new(), + method: String::new(), + params: vec![s.clone()], + }); + } + } + } + // Any other non-object is not a JSON-RPC request; surface it as + // an empty method (the caller logs and ignores it) rather than + // failing, so the raw text still reaches the diagnostic log. + other => { + return Ok(RawRequest { + id: String::new(), + method: String::new(), + params: vec![to_text(&other)], + }); + } + }; + let id = obj.remove("id").map(|v| to_text(&v)).unwrap_or_default(); + let method = obj + .remove("method") + .map(|v| to_text(&v)) + .unwrap_or_default(); + let params = match obj.remove("params") { + Some(serde_json::Value::Array(arr)) => arr.iter().map(to_text).collect(), + Some(serde_json::Value::Null) | None => Vec::new(), + // `params` shaped as an object: treat it as a single param. + Some(other) => vec![to_text(&other)], + }; + Ok(RawRequest { id, method, params }) + } +} + fn response_ok(id: &str, result: String) -> String { json!({ "id": id, "result": result, "error": null }).to_string() } +/// Whether a stored connection is usable *right now* (not revoked, not +/// expired), computed without taking the signer's async lock. The trait's +/// `is_connection_valid` cannot be used from contexts that already hold it. +fn connection_valid_now(conn: &Nip46Connection) -> bool { + if conn.revoked_at.is_some() { + return false; + } + match conn.expires_at { + Some(expires_at) => crate::vault::unix_timestamp().unwrap_or(0) < expires_at, + None => true, + } +} + fn response_err(id: &str, error: String) -> String { json!({ "id": id, "result": null, "error": error }).to_string() } @@ -1061,3 +2745,125 @@ fn percent_decode(raw: &str) -> Option { } String::from_utf8(out).ok() } + +#[cfg(test)] +mod raw_request_tests { + use super::RawRequest; + use nostr_sdk::prelude::Keys; + + #[test] + fn connect_params_carry_remote_pubkey_first_per_spec() { + // NIP-46 ("connect | [, , …]") + // 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] + fn parses_amber_style_connect_with_object_params() { + // Real signers (Amber) send requested_perms as a raw JSON object in + // params[1]; a strict Vec used to reject the whole request + // and silently drop the pairing handshake. + let raw = format!( + r#"{{"id":"1","method":"connect","params":["{}",{{"sign_event":[1,7],"nip04_encrypt":true}}]}}"#, + "a".repeat(64) + ); + let req: RawRequest = serde_json::from_str(&raw).expect("must parse"); + assert_eq!(req.method, "connect"); + assert_eq!(req.params.len(), 2); + assert_eq!(req.params[0], "a".repeat(64)); + assert!( + req.params[1].starts_with('{'), + "object coerced to JSON text" + ); + } + + #[test] + fn coerces_numeric_ids() { + // Some signers use numeric request ids; a strict String id used to + // reject the whole request and silently drop the handshake. + let req: RawRequest = + serde_json::from_str(r#"{"id":42,"method":"connect","params":[]}"#).unwrap(); + assert_eq!(req.id, "42"); + let req: RawRequest = + serde_json::from_str(r#"{"id":1789267093,"method":"connect","params":["aa","bb"]}"#) + .unwrap(); + assert_eq!(req.id, "1789267093"); + assert_eq!(req.method, "connect"); + } + + #[test] + fn missing_id_defaults_to_empty() { + let req: RawRequest = serde_json::from_str(r#"{"method":"connect"}"#).unwrap(); + assert_eq!(req.id, ""); + assert_eq!(req.method, "connect"); + } + + #[test] + fn unwraps_double_encoded_request() { + // A signer that JSON-stringifies the whole request object. + let inner = r#"{"id":"9","method":"connect","params":["aa"]}"#; + let raw = serde_json::Value::String(inner.to_string()).to_string(); + let req: RawRequest = serde_json::from_str(&raw).expect("must parse"); + assert_eq!(req.id, "9"); + assert_eq!(req.method, "connect"); + assert_eq!(req.params, vec!["aa".to_string()]); + } + + #[test] + fn accepts_object_shaped_params() { + // `params` given as a bare object rather than an array. + let req: RawRequest = + serde_json::from_str(r#"{"id":"x","method":"connect","params":{"sign_event":[0]}}"#) + .expect("must parse"); + assert_eq!(req.params.len(), 1); + assert!(req.params[0].starts_with('{')); + } + + #[test] + fn never_fails_on_valid_json() { + // Whatever a signer sends that is valid JSON must deserialize; the + // worst case is an empty method the caller logs and ignores. + for raw in [ + r#"[1,2,3]"#, + r#""just a string""#, + r#"42"#, + r#"null"#, + r#"{"no":"fields"}"#, + ] { + let req: RawRequest = serde_json::from_str(raw).expect("must not fail"); + assert!(raw.contains("fields") || req.method.is_empty() || req.method == "connect"); + } + } + + #[test] + fn parses_string_params_unchanged() { + let req: RawRequest = + serde_json::from_str(r#"{"id":"2","method":"get_public_key","params":[]}"#).unwrap(); + assert!(req.params.is_empty()); + let req: RawRequest = + serde_json::from_str(r#"{"id":"3","method":"nip44_decrypt","params":["aa","bb"]}"#) + .unwrap(); + assert_eq!(req.params, vec!["aa".to_string(), "bb".to_string()]); + } +} diff --git a/src/signer/types.rs b/src/signer/types.rs index 26c6672..1334e85 100644 --- a/src/signer/types.rs +++ b/src/signer/types.rs @@ -93,6 +93,11 @@ pub struct Nip46Status { pub connected_relays: Vec, pub error: Option, pub pending_approvals: Vec, + /// While pairing (client-initiated flow) this carries the + /// `nostrconnect://` URI to render as a QR code for the signer to scan. + /// `None` once paired or when not pairing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pairing_uri: Option, } /// A pending approval request from the signer. diff --git a/src/uploads.rs b/src/uploads.rs index a1ed3ac..e194583 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -1,21 +1,22 @@ +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; use nostr::nips::nip98::{HttpData, HttpMethod}; use nostr_sdk::prelude::*; -use crate::crypto::VaultKey; use crate::errors::{AppError, ErrorKind}; -use crate::profiles; +use crate::signer::Signing; -/// Sign a NIP-98 HTTP auth event for `url` with the active profile's key and -/// return the `Authorization` header value (`Nostr `). +/// Sign a NIP-98 HTTP auth event for `url` with the given [`Signing`] source +/// and return the `Authorization` header value (`Nostr `). /// /// This is what image hosts like nostr.build require before accepting an -/// upload. Like publishing, it needs an unlocked vault when the vault is -/// password-protected. +/// upload. It follows the same signer selection as publishing: an embedded +/// profile signs with the vault key, an external profile round-trips the +/// auth event through its connected NIP-46 signer. pub async fn nip98_authorization( - vault: &crate::vault::Vault, url: &str, method: &str, - key: Option<&VaultKey>, + signing: &Signing, ) -> Result { let http_method = match method.to_ascii_uppercase().as_str() { "GET" => HttpMethod::GET, @@ -33,112 +34,57 @@ pub async fn nip98_authorization( let parsed_url = Url::parse(url) .map_err(|e| AppError::config(format!("The upload URL is not valid: {e}")))?; - let secret_hex = profiles::resolve_active_secret_key(vault, key)?; - let secret_key = profiles::parse_secret_key(&secret_hex)?; - let keys = Keys::new(secret_key); - - let header = HttpData::new(parsed_url, http_method) - .to_authorization(&keys) + // Build the same event HttpData::to_authorization would build (kind + // 27235 with the u/method tags), but sign it through `Signing` so an + // external profile never needs a local secret. + let http_data = HttpData::new(parsed_url, http_method); + let pubkey = signing.pubkey().await.map_err(AppError::from)?; + let unsigned = IntoEventBuilder::into_event_builder(http_data).finalize_unsigned(pubkey); + let event = signing + .sign(unsigned) .await .map_err(|e| AppError::sign_failed(format!("Could not sign the upload request: {e}")))?; - Ok(header) + let encoded = B64.encode(event.as_json()); + Ok(format!("Nostr {encoded}")) } #[cfg(test)] mod tests { use super::*; - use crate::settings::Settings; - use crate::vault::Vault; - /// Settings with no relays so tests never touch the network. - fn offline_settings() -> Settings { - Settings { - relays: Vec::new(), - ..Default::default() - } - } - - fn vault_with_profile() -> Vault { - let mut vault = Vault::empty(); - crate::profiles::create_profile(&mut vault, "A".to_string(), None, &offline_settings()) - .unwrap(); - vault - } - - #[test] - fn missing_profile_errors() { - let vault = Vault::empty(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let err = runtime - .block_on(nip98_authorization( - &vault, - "https://nostr.build/api/v2/upload/files", - "POST", - None, - )) - .expect_err("no active profile must error"); - assert_eq!(err.kind(), ErrorKind::NoActiveProfile); + fn local_signing() -> Signing { + Signing::Local(Keys::generate()) } #[test] fn unsupported_method_errors() { - let vault = vault_with_profile(); + let signing = local_signing(); let runtime = tokio::runtime::Runtime::new().unwrap(); let err = runtime .block_on(nip98_authorization( - &vault, "https://example.com/upload", "DELETE", - None, + &signing, )) .expect_err("unsupported method must error"); assert_eq!(err.kind(), ErrorKind::Config); } #[test] - fn locked_encrypted_vault_errors() { - let mut vault = vault_with_profile(); - vault.crypto = Some(crate::vault::VaultCrypto { - kdf: crate::vault::KdfParams { - algorithm: "argon2id".to_string(), - salt: "c2FsdA==".to_string(), - m_cost: 1, - t_cost: 1, - p_cost: 1, - }, - verifier: "dmVyaWZpZXI=".to_string(), - }); - vault.profiles[0].secret_key = "encrypted-blob".to_string(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - let err = runtime - .block_on(nip98_authorization( - &vault, - "https://nostr.build/api/v2/upload/files", - "POST", - None, - )) - .expect_err("locked vault must error"); - assert_eq!(err.kind(), ErrorKind::VaultLocked); - } - - #[test] - fn signs_a_nip98_auth_header_for_the_active_profile() { - let vault = vault_with_profile(); + fn signs_a_nip98_auth_header() { + let signing = local_signing(); let runtime = tokio::runtime::Runtime::new().unwrap(); let header = runtime .block_on(nip98_authorization( - &vault, "https://nostr.build/api/v2/upload/files", "POST", - None, + &signing, )) - .expect("valid profile must sign"); + .expect("local signing must produce a header"); assert!(header.starts_with("Nostr "), "expected a Nostr auth header"); let encoded = header.trim_start_matches("Nostr ").trim(); - use base64::engine::general_purpose::STANDARD as B64; - use base64::Engine as _; let raw = B64 .decode(encoded) .expect("the header payload must be base64"); diff --git a/src/vault.rs b/src/vault.rs index 24d0702..0e4d907 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -120,6 +120,37 @@ pub struct Vault { /// handled; a password-protected vault encrypts them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub connection_secrets: Vec, + /// Our NIP-46 client secret keys, one per connection. + /// + /// The client keypair minted at pairing is not just a handshake nonce: + /// the signer (Amber) remembers it as our identity for the whole + /// connection, so re-dialing after an app restart MUST reuse the exact + /// same key or the signer answers a stranger and the session cannot be + /// reactivated without a fresh scan. Stored encrypted under the vault + /// key — exactly like [`Vault::connection_secrets`] — keyed by the same + /// [`crate::signer::VaultRef`], never inline on `Nip46Connection`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connection_client_keys: Vec, + /// Standing "always allow" grants for apps that use this machine as + /// their NIP-46 signer (bunker mode). Keyed by the *app's* pubkey and + /// the gated method it was allowed to run; a matching request skips the + /// approval prompt until revoked. Revoke by deleting the grant. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signer_grants: Vec, +} + +/// A standing permission for one connected NIP-46 app: "always allow" a +/// gated method instead of asking on every request (like Amber and other +/// signer apps do). Covers exactly one (app, method) pair. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SignerGrant { + /// The app's public key (hex) whose requests may skip the prompt. + pub app_pubkey: String, + /// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`, + /// or `nip44_decrypt`. + pub method: String, + /// Unix timestamp of when the user granted it. + pub created_at: u64, } /// An encrypted NIP-46 connection secret, keyed by its @@ -141,6 +172,23 @@ pub struct ConnectionSecret { pub secret: String, } +/// Our NIP-46 client secret key for one connection, keyed by its +/// [`crate::signer::VaultRef`] — the same keying as [`ConnectionSecret`]. +/// +/// The stored value is the 64-char hex secret key: plaintext when the vault +/// has no password, a base64 AES-256-GCM blob under the vault key when it +/// does. It is a credential: the signer recognizes our client pubkey for the +/// life of the connection, so this key is what makes reactivation-after- +/// restart possible without a fresh scan, and it must never be serialized +/// anywhere the UI or logs can see it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionClientKey { + /// The opaque reference (profile npub + remote signer pubkey). + pub ref_: crate::signer::VaultRef, + /// Our client secret key (hex) — plaintext or encrypted, per the vault. + pub secret_hex: String, +} + impl Vault { /// A fresh, empty vault. pub fn empty() -> Self { @@ -152,9 +200,42 @@ impl Vault { profiles: Vec::new(), nip46_connections: Vec::new(), connection_secrets: Vec::new(), + connection_client_keys: Vec::new(), + signer_grants: Vec::new(), } } + /// Whether `app_pubkey` may run gated `method` without a prompt. + pub fn has_signer_grant(&self, app_pubkey: &str, method: &str) -> bool { + self.signer_grants + .iter() + .any(|g| g.app_pubkey == app_pubkey && g.method == method) + } + + /// Record an "always allow" grant (idempotent). + pub fn grant_signer_method( + &mut self, + app_pubkey: &str, + method: &str, + ) -> Result<(), crate::errors::AppError> { + if !self.has_signer_grant(app_pubkey, method) { + self.signer_grants.push(SignerGrant { + app_pubkey: app_pubkey.to_string(), + method: method.to_string(), + created_at: crate::vault::unix_timestamp()?, + }); + } + Ok(()) + } + + /// Drop a standing grant; returns whether one was removed. + pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool { + let before = self.signer_grants.len(); + self.signer_grants + .retain(|g| !(g.app_pubkey == app_pubkey && g.method == method)); + self.signer_grants.len() != before + } + pub fn has_profiles(&self) -> bool { !self.profiles.is_empty() } @@ -344,6 +425,8 @@ pub fn parse_vault(content: &str) -> Result { profiles, nip46_connections: Vec::new(), connection_secrets: Vec::new(), + connection_client_keys: Vec::new(), + signer_grants: Vec::new(), }); } @@ -356,35 +439,28 @@ pub fn parse_vault(content: &str) -> Result { /// left untouched. Only profiles with the legacy `None` value (or /// missing the field entirely) are assigned `Embedded`. /// -/// Returns `true` if any profiles were migrated (i.e. the vault should -/// be re-saved). +/// Missing `signer_mode` fields are assigned `Embedded` by the serde +/// default during deserialization, and every vault at the current +/// `VAULT_VERSION` serialises `signer_mode` explicitly — so once the +/// version bump below has been saved, re-saving adds nothing. A change +/// is therefore reported only when the version actually moves, instead +/// of on every load (the old unconditional `changed = true` made +/// `App::load` rewrite the vault on each start). +/// +/// Returns `true` if the vault was migrated (i.e. it should be re-saved). pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool { - let mut changed = false; - for _profile in &mut vault.profiles { - // The serde default already handles missing fields during - // deserialization, but once loaded, profiles that were stored - // before signer_mode was introduced will have the default value. - // We write it explicitly so the on-disk format is canonical. - // - // After the first save, every profile will have an explicit - // signer_mode and this becomes a no-op. - // - // We cannot distinguish "user explicitly set Embedded" from - // "serde defaulted to Embedded", so we always write it — this is - // safe because Embedded is the correct default and the write is - // idempotent. - changed = true; - } - // Also ensure the nip46_connections vector exists (serde default - // handles this during deserialization, but we normalise here too). - if vault.version < VAULT_VERSION { - vault.version = VAULT_VERSION; - changed = true; - } + // Profiles need no per-field work: the serde default already filled + // any missing `signer_mode` at parse time and serialization at the + // current version writes it explicitly. + // // Legacy connections without profile_npub (None) are left as-is. // Ownership cannot be reliably inferred from active_profile, so these // connections remain unusable until the user re-creates them. - changed + if vault.version < VAULT_VERSION { + vault.version = VAULT_VERSION; + return true; + } + false } /// Store (or replace) a NIP-46 connection secret in the vault, keyed by an @@ -460,6 +536,76 @@ pub fn delete_connection_secret(vault: &mut Vault, ref_: &crate::signer::VaultRe vault.connection_secrets.len() != before } +/// Store (or replace) our NIP-46 client secret key for a connection. +/// +/// Encryption behavior mirrors [`store_connection_secret`]: encrypted under +/// the vault key when the vault is password-protected (fail-closed on a +/// locked vault), plaintext otherwise. Replacing in place keeps one key per +/// connection so reconnects never strand a stale secret. +pub fn store_connection_client_key( + vault: &mut Vault, + key: Option<&crate::crypto::VaultKey>, + ref_: &crate::signer::VaultRef, + secret_hex: &str, +) -> Result<(), AppError> { + let stored = match &vault.crypto { + Some(_) => { + let key = key.ok_or_else(AppError::vault_locked)?; + crate::crypto::encrypt_secret(key, secret_hex)? + } + None => secret_hex.to_string(), + }; + if let Some(entry) = vault + .connection_client_keys + .iter_mut() + .find(|c| c.ref_ == *ref_) + { + entry.secret_hex = stored; + } else { + vault.connection_client_keys.push(ConnectionClientKey { + ref_: ref_.clone(), + secret_hex: stored, + }); + } + Ok(()) +} + +/// Resolve (decrypt) the stored NIP-46 client secret key for a reference. +/// +/// Same contract as [`resolve_connection_secret`]: `Ok(None)` when nothing is +/// stored, fail-closed `Err(vault_locked)` when encrypted-but-locked, and a +/// [`Zeroizing`] plaintext on success. +pub fn resolve_connection_client_key( + vault: &Vault, + key: Option<&crate::crypto::VaultKey>, + ref_: &crate::signer::VaultRef, +) -> Result>, AppError> { + let entry = vault + .connection_client_keys + .iter() + .find(|c| c.ref_ == *ref_); + let Some(entry) = entry else { + return Ok(None); + }; + match &vault.crypto { + Some(_) => { + let key = key.ok_or_else(AppError::vault_locked)?; + let plain = crate::crypto::decrypt_secret(key, &entry.secret_hex)?; + Ok(Some(plain)) + } + None => Ok(Some(Zeroizing::new(entry.secret_hex.clone()))), + } +} + +/// Remove a stored NIP-46 client secret key (e.g. on disconnect/revoke). +/// +/// Returns `true` when an entry was removed. +pub fn delete_connection_client_key(vault: &mut Vault, ref_: &crate::signer::VaultRef) -> bool { + let before = vault.connection_client_keys.len(); + vault.connection_client_keys.retain(|c| c.ref_ != *ref_); + vault.connection_client_keys.len() != before +} + /// Persist the vault to the stable application-data location with /// restrictive permissions. pub fn save_vault(vault: &Vault) -> Result<(), AppError> { @@ -647,6 +793,29 @@ mod tests { use crate::errors::ErrorKind; use std::sync::atomic::{AtomicU32, Ordering}; + #[test] + fn signer_grants_roundtrip_and_revoke() { + let mut vault = Vault::empty(); + assert!(!vault.has_signer_grant("aa", "sign_event")); + + vault.grant_signer_method("aa", "sign_event").unwrap(); + vault.grant_signer_method("aa", "sign_event").unwrap(); // idempotent + vault.grant_signer_method("bb", "sign_event").unwrap(); + assert_eq!(vault.signer_grants.len(), 2); + assert!(vault.has_signer_grant("aa", "sign_event")); + assert!(!vault.has_signer_grant("aa", "nip04_decrypt")); + + let json = serde_json::to_string(&vault).unwrap(); + let mut loaded: Vault = serde_json::from_str(&json).unwrap(); + assert!(loaded.has_signer_grant("aa", "sign_event")); + assert!(loaded.has_signer_grant("bb", "sign_event")); + + assert!(loaded.revoke_signer_grant("aa", "sign_event")); + assert!(!loaded.revoke_signer_grant("aa", "sign_event")); + assert!(!loaded.has_signer_grant("aa", "sign_event")); + assert!(loaded.has_signer_grant("bb", "sign_event")); + } + static COUNTER: AtomicU32 = AtomicU32::new(0); fn temp_vault_path() -> PathBuf { @@ -852,11 +1021,14 @@ mod tests { }); let changed1 = migrate_vault_signer_modes(&mut vault); - assert!(changed1, "first migration should report change"); + // Vault::empty() is already at the current version with an + // explicit signer_mode, so migration must report no change — + // the old always-true return made App::load rewrite the vault + // on every start. + assert!(!changed1, "current-version vault should not report change"); - let _changed2 = migrate_vault_signer_modes(&mut vault); - // The function always returns true because it normalises the version. - // The important thing is that running it twice doesn't corrupt data. + let changed2 = migrate_vault_signer_modes(&mut vault); + assert!(!changed2, "re-running migration stays a no-op"); assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded); assert_eq!(vault.version, VAULT_VERSION); } @@ -1007,4 +1179,90 @@ mod tests { // Connection remains None - cannot infer ownership assert!(vault.nip46_connections[0].profile_npub.is_none()); } + + #[test] + fn connection_client_key_plaintext_roundtrip() { + let mut vault = Vault::empty(); + let ref_ = crate::signer::VaultRef::new(Some("npub1bob".to_string()), "aabb".to_string()); + + assert!(resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .is_none()); + + store_connection_client_key(&mut vault, None, &ref_, "00ff").unwrap(); + assert_eq!( + resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .unwrap() + .as_str(), + "00ff" + ); + + // Storing again replaces (one entry per ref). + store_connection_client_key(&mut vault, None, &ref_, "11ee").unwrap(); + assert_eq!(vault.connection_client_keys.len(), 1); + assert_eq!( + resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .unwrap() + .as_str(), + "11ee" + ); + + assert!(delete_connection_client_key(&mut vault, &ref_)); + assert!(!delete_connection_client_key(&mut vault, &ref_)); + assert!(resolve_connection_client_key(&vault, None, &ref_) + .unwrap() + .is_none()); + } + + #[test] + fn connection_client_key_encrypted_when_vault_locked() { + use crate::crypto; + + let mut vault = Vault::empty(); + let salt = crypto::generate_salt().unwrap(); + let key = crypto::derive_key("pw", &salt, 1024, 1, 1).unwrap(); + vault.crypto = Some(VaultCrypto { + kdf: crate::vault::KdfParams { + algorithm: "argon2id".to_string(), + m_cost: 1024, + t_cost: 1, + p_cost: 1, + salt: B64.encode(salt), + }, + verifier: crypto::make_verifier(&key).unwrap(), + }); + let ref_ = crate::signer::VaultRef::new(Some("npub1bob".to_string()), "aabb".to_string()); + + store_connection_client_key(&mut vault, Some(&key), &ref_, "deadbeef").unwrap(); + // The on-disk form must not carry the plaintext key. + let serialized = serde_json::to_string(&vault).unwrap(); + assert!(!serialized.contains("deadbeef")); + + // Locked vault: fail closed. + let err = resolve_connection_client_key(&vault, None, &ref_).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::VaultLocked); + + // Unlocked: exact roundtrip. + assert_eq!( + resolve_connection_client_key(&vault, Some(&key), &ref_) + .unwrap() + .unwrap() + .as_str(), + "deadbeef" + ); + } + + #[test] + fn connection_client_keys_absent_in_legacy_vault() { + // A vault JSON without the new field must still parse (serde default). + let json = r#"{ + "version": 2, + "profiles": [], + "nip46_connections": [] + }"#; + let vault: Vault = serde_json::from_str(json).unwrap(); + assert!(vault.connection_client_keys.is_empty()); + } } diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs new file mode 100644 index 0000000..86b281b --- /dev/null +++ b/tests/nip46_e2e.rs @@ -0,0 +1,1326 @@ +//! End-to-end NIP-46 client tests: the real `Nip46ClientSigner` runs against +//! a local relay and fake signers — a bunker:// Amber (per-connection comms +//! key, delayed human-approval ack) and a QR scanner that consumes a +//! client-minted `nostrconnect://` pairing token with secret verification. +//! Both reveal a real identity only via `get_public_key`. +//! +//! Exercises in one process: relay I/O, NIP-44 encryption, both handshake +//! directions, the deferred-identity flow, `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; + +/// Vault setup touches the process-global `XDG_DATA_HOME`; serialize it so +/// the two e2e tests in this binary cannot read each other's vault. +static VAULT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +// --------------------------------------------------------------------------- +// Minimal in-process nostr relay +// --------------------------------------------------------------------------- + +struct Session { + tx: mpsc::UnboundedSender, + subs: HashMap>, +} + +struct RelayState { + sessions: Vec, + events: Vec, +} + +/// 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> = 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::(); + 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::>(&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 = 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::(&ev.as_json()) + .unwrap_or_default() + ]) + .to_string(); + if let Some(sess) = s.sessions.get(session_idx) { + eprintln!( + "[relay] replay {} to new sub {}", + &ev.id.to_hex()[..16], + sub_id + ); + 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::(&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 { + 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::>(&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::(&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. 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; + 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" => { + let unsigned_json = req["params"].get(0).and_then(|v| v.as_str()); + match unsigned_json.and_then(|s| serde_json::from_str::(s).ok()) { + Some(mut v) => { + if v.get("pubkey").is_none() { + v["pubkey"] = json!(identity.public_key().to_hex()); + } + match serde_json::from_value::(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::(&out.as_json()).unwrap() + ]) + .to_string() + .into(), + )) + .await + .unwrap(); + } +} + +// --------------------------------------------------------------------------- +// The test +// --------------------------------------------------------------------------- + +#[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() { + // Isolated vault so the test never touches the real user vault. + // XDG_DATA_HOME is process-global and every test in this binary sets it, + // 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() + // Poison-tolerant on purpose: this lock only serializes the + // process-global XDG_DATA_HOME. A sibling test that panics while + // holding it must not cascade into PoisonError failures of every + // later test — one real failure should report as ONE failure. + .unwrap_or_else(|e| e.into_inner()); + let app = { + let tmp = std::env::temp_dir().join(format!("keynectr-e2e-{}", std::process::id())); + // data_dir() is $XDG_DATA_HOME/keynectr — the isolation vault must + // live there. Writing it one level too shallow left the app finding + // NO vault at the real path, which silently migrated the legacy + // repo vault (with the user's real keys!) into the test instead. + 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"))); + // Guard: if seeding ever misses the real data_dir path again, + // load_vault() silently migrates the legacy repo vault (real user + // keys) into the test. An isolated run must load an EMPTY vault. + assert!( + app.try_lock().unwrap().vault.profiles.is_empty(), + "e2e vault isolation failed: a non-empty vault was loaded — \ + the seeded vault is not where data_dir() looks" + ); + 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 +} + +// --------------------------------------------------------------------------- +// 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::>(&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::(&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::(s).ok()) { + Some(mut v) => { + if v.get("pubkey").is_none() { + v["pubkey"] = json!(identity.public_key().to_hex()); + } + match serde_json::from_value::(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::(&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() + // Poison-tolerant on purpose: this lock only serializes the + // process-global XDG_DATA_HOME. A sibling test that panics while + // holding it must not cascade into PoisonError failures of every + // later test — one real failure should report as ONE failure. + .unwrap_or_else(|e| e.into_inner()); + 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()); + // Register the handle on the App exactly like production's + // `ensure_nip46_signer` does: `App::signing_for` (used below for the + // kind-0 publish) resolves the live session through this handle. + // `Nip46ClientSigner::clone` shares the session state. + app.lock().await.nip46_signer = Some(std::sync::Arc::new(signer.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); + + // Kind-0 through the remote signer: the vault label becomes a signed + // network-visible profile (what other clients display as the name). + // Exercises the real GUI "Publish name" path — Signing::External with + // identity validation — against the strict signer. + // Point settings at the in-process relay FIRST: the default relay set is + // the real internet (damus + nostr.band, the latter a known-hanger), so + // leaving it made this assertion a network lottery — it failed whenever + // damus dawdled past the 6s send timeout. The QR test already does this; + // the strict test shipped without it. + { + let mut a = app.lock().await; + a.settings.relays = vec![keynectr::settings::RelayConfig::new(relay_url.clone())]; + a.save_settings().expect("save settings"); + } + let (settings, signing) = { + let guard = app.lock().await; + ( + guard.settings.clone(), + guard + .signing_for(&identity_npub) + .await + .expect("signing source for the paired profile"), + ) + }; + let report = keynectr::profiles::publish_metadata_signed( + &settings, + "Strict Amber", + None, + None, + &signing, + ) + .await + .expect("remote kind-0 publish"); + assert!( + !report.succeeded.is_empty(), + "at least one relay must accept the signed kind-0" + ); + + signer.disconnect().await.ok(); +} + +// --------------------------------------------------------------------------- +// QR pairing (client-initiated nostrconnect://): the fake signer plays the +// scanner role — it reads the pairing token the GUI would render, sends the +// `connect` request with the echoed secret, verifies the client's secret +// answer, then reveals its identity and signs like Amber. +// --------------------------------------------------------------------------- + +async fn run_fake_scanner( + relay_url: String, + client_pk: PublicKey, + expected_secret: String, + identity: Keys, + connect_shape: &'static str, +) { + let (mut ws, _) = tokio_tungstenite::connect_async(&relay_url) + .await + .expect("scanner connect"); + ws.send(Message::Text( + json!(["REQ", "scanner", {"kinds": [24133]}]) + .to_string() + .into(), + )) + .await + .unwrap(); + + let conversation = ConversationKey::derive(identity.secret_key(), &client_pk).unwrap(); + + // The scanned URI tells us who to contact and what secret to echo. + // Two shapes: + // - "request": {"id","method":"connect","params":[secret]} — what the + // e2e originally simulated; the client answers with the secret. + // - "response": {"id","result":secret} — what NIP-46 actually specifies + // for nostrconnect:// ("the _remote-signer_ … sends `connect` + // *response* event"), and what Amber sends. No answer expected. + let connect_msg = match connect_shape { + "response" => json!({ "id": "pair-1", "result": expected_secret.clone() }), + _ => json!({ + "id": "pair-1", + "method": "connect", + "params": [expected_secret.clone()], + }), + }; + let content = nip44_enc(&conversation, &connect_msg.to_string()); + let out = EventBuilder::new(Kind::NostrConnect, content) + .tags([Tag::parse(["p", client_pk.to_hex().as_str()]).unwrap()]) + .finalize(&identity) + .unwrap(); + ws.send(Message::Text( + json!([ + "EVENT", + serde_json::from_str::(&out.as_json()).unwrap() + ]) + .to_string() + .into(), + )) + .await + .unwrap(); + + while let Some(Ok(msg)) = ws.next().await { + let Message::Text(text) = msg else { continue }; + let Ok(arr) = serde_json::from_str::>(&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 == identity.public_key() { + continue; + } + let Some(plain) = nip44_dec(&conversation, &ev.content) else { + eprintln!( + "[scanner] event from {} not decryptable", + &ev.pubkey.to_hex()[..16] + ); + continue; + }; + eprintln!("[scanner] decrypted: {}", &plain[..plain.len().min(120)]); + let Ok(req) = serde_json::from_str::(&plain) else { + continue; + }; + // The client's answer to our connect must carry the secret back — + // this is the anti-spoofing check the scanner performs in Amber. + if req.get("id").and_then(|v| v.as_str()) == Some("pair-1") + && req.get("result").and_then(|v| v.as_str()) == Some(expected_secret.as_str()) + { + // Secret echoed correctly — the check an actual scanner performs + // before approving. Nothing further to do with the ack itself. + 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 { + "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::(s).ok()) { + Some(mut v) => { + if v.get("pubkey").is_none() { + v["pubkey"] = json!(identity.public_key().to_hex()); + } + match serde_json::from_value::(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", client_pk.to_hex().as_str()]).unwrap()]) + .finalize(&identity) + .unwrap(); + ws.send(Message::Text( + json!([ + "EVENT", + serde_json::from_str::(&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_qr_pairing_handshake_and_sign() { + run_qr_pairing("request").await; +} + +/// The shape NIP-46 actually specifies for a `nostrconnect://` scan — and +/// what Amber sends — is a connect *response* (`{"id","result":""}`), +/// not a `connect` request. Pairing must complete on that shape too. +#[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() { + 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) { + let relay_url = start_relay().await; + + // 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() + // Poison-tolerant on purpose: this lock only serializes the + // process-global XDG_DATA_HOME. A sibling test that panics while + // holding it must not cascade into PoisonError failures of every + // later test — one real failure should report as ONE failure. + .unwrap_or_else(|e| e.into_inner()); + let app = { + let tmp = std::env::temp_dir().join(format!( + "keynectr-e2e-pair-{}-{}", + std::process::id(), + connect_shape + )); + // Must be $XDG_DATA_HOME/keynectr/profiles_vault.json (see the + // sibling test above): the old shallow path let every e2e run + // migrate the real legacy repo vault into the test process. + 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"))); + // Same empty-vault guard as the sibling test above. + assert!( + app.try_lock().unwrap().vault.profiles.is_empty(), + "e2e vault isolation failed: a non-empty vault was loaded — \ + the seeded vault is not where data_dir() looks" + ); + app + }; + { + let mut a = app.lock().await; + a.settings.relays = vec![keynectr::settings::RelayConfig::new(relay_url.clone())]; + a.save_settings().expect("save settings"); + } + + let identity = Keys::generate(); + let signer = Nip46ClientSigner::new(app.clone()); + + // Start pairing: we get back the token the GUI renders as a QR. + let status = signer + .start_pairing("qr pairing test".to_string()) + .await + .expect("start pairing"); + assert!(!status.connected, "not connected until someone scans"); + let pairing_uri = status.pairing_uri.clone().expect("pairing URI present"); + assert!( + pairing_uri.starts_with("nostrconnect://"), + "pairing token must be a nostrconnect:// URI" + ); + + // The token must be a well-formed client-initiated URI: ephemeral + // authority key, our relay, and the anti-spoofing secret. + let parsed = nostr::nips::nip46::NostrConnectUri::parse(&pairing_uri) + .expect("pairing URI parses with the same parser real signers use"); + let nostr::nips::nip46::NostrConnectUri::Client { + public_key: client_pk, + secret, + relays, + .. + } = parsed + else { + panic!("pairing URI must be the client variant"); + }; + // The e2e relay set is loopback-only, and loopback sets skip the curated + // pairing-relay widening, so the token carries exactly our relay. + assert_eq!(relays.len(), 1); + assert!(!secret.is_empty()); + + // The scanner (Amber role) consumes the token. + tokio::spawn(run_fake_scanner( + relay_url.clone(), + client_pk, + secret.clone(), + identity.clone(), + connect_shape, + )); + + // Wait for scan -> secret echo -> identity adoption. + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + loop { + let status = signer.status().await; + if let Some(err) = &status.error { + panic!("pairing failed: {err}"); + } + if status.connected { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "pairing never completed; last status: {:?}", + signer.status().await + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // The QR token is single-use: consumed, so it no longer appears. + assert!( + signer.status().await.pairing_uri.is_none(), + "pairing URI must be dropped once scanned" + ); + + // Identity came from get_public_key, not from the URI authority key. + let resolved = SignerTrait::get_public_key(&signer) + .await + .expect("identity resolved"); + assert_eq!(resolved, identity.public_key()); + assert_ne!( + resolved, client_pk, + "ephemeral pairing key must never become identity" + ); + + // Sign through the paired signer. + let unsigned = UnsignedEvent::new( + identity.public_key(), + Timestamp::now(), + Kind::TextNote, + vec![], + "paired via QR".to_string(), + ); + let signed = SignerTrait::sign_event(&signer, unsigned.clone()) + .await + .expect("remote sign_event after pairing"); + assert_eq!(signed.pubkey, identity.public_key()); + assert_eq!(signed.content, "paired via QR"); + assert!(signed.verify_signature()); + + // Vault persistence: remote profile under the real identity, no secrets. + 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 by pairing"); + assert_eq!( + profile.signer_mode, + keynectr::vault::SignerMode::Nip46Client + ); + assert!( + profile.secret_key.trim().is_empty(), + "no secret material for remote profiles" + ); + drop(app_guard); + + signer.disconnect().await.ok(); +} + +// --------------------------------------------------------------------------- +// Session restore (re-dial without a scan): Amber remembers our CLIENT +// pubkey for the life of a connection, so a restart must reuse the exact +// keypair persisted at pairing, re-send `connect`, and refuse the session +// if the signer answers as a different account. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[allow(clippy::await_holding_lock)] +async fn nip46_session_restore_redials_and_refuses_wrong_identity() { + let _vault_guard = VAULT_ENV_LOCK + .lock() + // Poison-tolerant on purpose: this lock only serializes the + // process-global XDG_DATA_HOME. A sibling test that panics while + // holding it must not cascade into PoisonError failures of every + // later test — one real failure should report as ONE failure. + .unwrap_or_else(|e| e.into_inner()); + let app = { + let tmp = std::env::temp_dir().join(format!( + "keynectr-e2e-restore-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + 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 for restore test" + ); + app + }; + + 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(50), + )); + + // --- 1. Fresh pairing: the flow that must later NOT need repeating. + let signer = Nip46ClientSigner::new(app.clone()); + let uri = format!( + "bunker://{}?relay={}", + comms.public_key().to_hex(), + relay_url + ); + signer + .connect(&uri, "fake amber".to_string()) + .await + .expect("fresh connect"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let st = signer.status().await; + if let Some(err) = &st.error { + panic!("fresh pairing failed: {err}"); + } + if st.connected { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "fresh pairing never connected: {:?}", + signer.status().await + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Pairing must have persisted OUR client secret key, re-keyed under the + // identity-keyed VaultRef (that is what a re-dial resolves). + let identity_npub = identity.public_key().to_bech32().unwrap(); + let ref_id = + keynectr::signer::VaultRef::new(Some(identity_npub.clone()), comms.public_key().to_hex()); + let client_key_hex = { + let g = app.lock().await; + let ck = keynectr::vault::resolve_connection_client_key(&g.vault, None, &ref_id) + .expect("resolve client key") + .expect("client secret key must be persisted at pairing"); + ck.to_string() + }; + + // --- 2. Simulated app restart: a NEW signer instance over the same + // vault must re-dial the saved session with no scan and no bunker URI. + // (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() + .await + .expect("restore call"); + assert_eq!(restored, 1, "one saved session must be restorable"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let st = signer2.status().await; + if let Some(err) = &st.error { + panic!("restored session failed: {err}"); + } + if st.connected { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "restored session never connected: {:?}", + signer2.status().await + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + let resolved = SignerTrait::get_public_key(&signer2) + .await + .expect("identity on restored session"); + assert_eq!( + resolved, + identity.public_key(), + "restored session must bind the ORIGINAL identity" + ); + + // The restored session is fully usable: remote sign_event verifies. + let unsigned = UnsignedEvent::new( + identity.public_key(), + Timestamp::now(), + Kind::TextNote, + vec![], + "signed after restart".to_string(), + ); + let signed = SignerTrait::sign_event(&signer2, unsigned.clone()) + .await + .expect("sign through restored session"); + assert_eq!(signed.pubkey, identity.public_key()); + assert_eq!(signed.content, "signed after restart"); + assert_eq!(signed.id, unsigned.compute_id()); + assert!(signed.verify_signature()); + + // --- 3. Legacy skip: a connection with no stored client key (paired + // before key persistence existed) is NOT re-dialed — one fresh scan is + // required for those. + { + let mut g = app.lock().await; + keynectr::vault::delete_connection_client_key(&mut g.vault, &ref_id); + g.save_vault().unwrap(); + } + let signer3 = Nip46ClientSigner::new(app.clone()); + assert_eq!( + signer3 + .reactivate_saved_sessions() + .await + .expect("legacy restore call"), + 0, + "keyless legacy connection must be skipped" + ); + + // --- 4. Cross-account guard: put the client key under a DIFFERENT + // profile's ref (as if profile B reused this Amber connection) and move + // the connection row to that profile. The re-dial dials fine, but the + // fake Amber still answers as the ORIGINAL identity — the identity + // check must refuse the session outright, never adopt it. + let impostor = Keys::generate(); + let impostor_npub = impostor.public_key().to_bech32().unwrap(); + { + let mut g = app.lock().await; + let ref_b = keynectr::signer::VaultRef::new( + Some(impostor_npub.clone()), + comms.public_key().to_hex(), + ); + keynectr::vault::store_connection_client_key(&mut g.vault, None, &ref_b, &client_key_hex) + .unwrap(); + g.vault.nip46_connections[0].profile_npub = Some(impostor_npub.clone()); + g.save_vault().unwrap(); + } + let signer4 = Nip46ClientSigner::new(app.clone()); + assert_eq!( + signer4 + .reactivate_saved_sessions() + .await + .expect("cross-account restore call"), + 1, + "the re-dial itself must start; refusal happens in the handshake" + ); + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let st = signer4.status().await; + if let Some(err) = &st.error { + assert!( + err.contains("different account"), + "cross-account restore failed for the wrong reason: {err}" + ); + break; + } + assert!( + !st.connected, + "a restored session answering as the wrong account must NEVER connect" + ); + assert!( + tokio::time::Instant::now() < deadline, + "cross-account restore never failed: {:?}", + signer4.status().await + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +}