Compare commits

..

74 commits

Author SHA1 Message Date
Avi
704addc773 docs: checkpoint — auto-naming hardened (retry 4x), nos.lol 502 root-cause 2026-09-25 17:19:58 -05:00
Avi
bc736ff339 fix(nip46): retry the auto-name kind-0 fetch up to 4x, log each attempt
Live Sep 25: nos.lol — the only relay holding the account's kind-0 —
502'd EVERY connecting client (any UA, nostr-sdk and raw websockets
alike) for minutes at a time, then served the event within 2s. The
single-shot enrichment task gave up once and left the profile on the
seed label permanently. Now: 4 attempts, 20s pause between, 75s budget
each (> fetch_profile_metadata's 10s connect-wait + per-relay fetches),
with a pairing_trace line naming the outcome of every attempt so the
next occurrence is diagnosable from pairing-trace.log alone.

216 unit + 5 e2e green, clippy 0, fmt clean, release rebuilt.
2026-09-25 17:10:39 -05:00
Avi
b5c61deec6 fix(nip46): give the auto-naming kind-0 fetch a 30s budget, not 10s
fetch_profile_metadata itself waits up to 10s for the relay pool to
connect before it fetches at all, and the user's relay list includes
relay.nostr.band whose handshake hangs past that. The outer 10s timeout
therefore killed the enrichment task before the first REQ went out —
live Sep 25: nos.lol served the account's kind-0 ('web5osint') within
2s of an anonymous probe, yet the profile row kept the seed label.
30s outer > 10s connect-wait + per-relay fetches.
2026-09-25 16:39:18 -05:00
Avi
fc2fe93161 feat(ui): drop the pairing label step — profile names come from the account
Sep 25 feedback: the prefilled 'Amber' name had to be cleared letter by
letter before the QR would appear (the input fight), and naming should
be automatic anyway. One click on 'Sign in with a signer app (Amber)'
now goes straight to the QR with the seed label 'Amber'; the existing
adopt_identity background enrichment fetches the account's kind-0 after
the handshake and upgrades the profile row to the account's REAL
display name whenever it still carries our seed (a manual rename in
Profiles always wins and is never overwritten).

frontend: 125 tests green (pairing test asserts the one-click path and
the 'Amber' seed label), typecheck/lint/format/electron:build/build
green. Rust untouched — the auto-naming enrichment already shipped at
a76d8df.
2026-09-25 16:30:18 -05:00
Avi
e8d11701a3 docs(checkpoint): restore echo fix live-verified at 01ce5de (2026-09-25) 2026-09-25 16:21:06 -05:00
Avi
01ce5de417 fix(nip46): restored sessions no longer demand a connect secret re-echo
Live Amber sign-in (Sep 25) paired fine, but every restart died with
'the signer did not echo the connection secret': the restore re-sends
connect with the ORIGINAL pairing secret, and an already-approved
signer legitimately answers 'true' without re-echoing — NIP-46 reserves
the echo for proving possession during the INITIAL pairing, and Amber
proved it once. Requiring it on re-dial made session restore fail
100% against real Amber (the e2e missed it because its fake answered a
plain ack with no secret in play).

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

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

cargo test 216 unit + 5 e2e green; clippy --all-targets 0 warnings;
fmt clean; release rebuilt.
2026-09-25 16:19:19 -05:00
Avi
929d791240 docs(checkpoint): label step + vault prune at a809a67; live Amber sign-in confirmed (2026-09-25) 2026-09-25 16:02:27 -05:00
Avi
a809a67023 feat(ui): name the signer connection before the QR; prune stale vault rows
The pairing path minted its profile under the backend's 'Remote Signer'
default, so several test pairings left indistinguishable rows. The
'Sign in with a signer app (Amber)' button now shows a prefilled
('Amber') Connection name step first; the QR step starts only from
there and the typed name reaches nip46_pair_start as the profile label.
Enter submits directly (input focused, prefilled).

Also cleaned the live vault (not in git): dropped 2 stale 'Remote
Signer' connections and the secret-less 'Dev' remote stub tied to them,
plus 13 orphaned connection_secrets entries; kept the live Amber
pairing (npub1qn0w4a…, its connection + client key) and Testing123.

frontend: 125 tests passed (label step covered: prefill, typed label
reaches nip46_pair_start), typecheck/lint/format/electron:build/build
green. Rust untouched.
2026-09-25 16:01:57 -05:00
Avi
2ef2cd1181 docs(checkpoint): Add-profile Amber sign-in choice at 5b13162 (2026-09-25) 2026-09-25 15:35:39 -05:00
Avi
5b13162a36 feat(ui): Add profile now offers 'Sign in with a signer (Amber)' first
The Amber pairing QR existed but only behind the sidebar's Signer Mode,
so clicking 'Add profile' expectedly led to a local-key form and users
never found the signer flow. The modal now opens as a choice:

- 'Sign in with a signer app (Amber)' — mints the nostrconnect:// QR
  inline, polls signer status every 2s (same channel as Signer Mode),
  and flips to an 'Amber is now your signer!' confirmation once the
  handshake lands; cancelling mid-pairing aborts it cleanly.
- 'Create a new key on this computer' — the previous local-key form,
  unchanged, with a Back step.

frontend: 125 tests passed (CreateProfileModal suite rewritten to cover
choice, QR start, connected poll, cancel-abort; App.test updated for the
new dialog title), typecheck/lint/format:check/electron:build/build green.
2026-09-25 15:34:28 -05:00
Avi
224df38598 docs(checkpoint): e2e flake killed at a6a4e6f (2026-09-24 evening) 2026-09-24 18:27:17 -05:00
Avi
a6a4e6f485 test(nip46): kill the e2e flake — local relay for strict kind-0, poison-tolerant vault lock
The suite failed intermittently (1 in ~4 runs) with 3-5 simultaneous
failures. Two stacked causes:

1. REAL flake: nip46_bunker_connect_params_match_spec_against_strict_amber
   published its kind-0 through the DEFAULT relay set — the real internet
   (wss://relay.damus.io + the known-hanging relay.nostr.band) — so
   'at least one relay must accept the signed kind-0' was a network
   lottery against the 6s send timeout. The QR test already pins settings
   to the in-process relay; the strict test shipped without it. Now pinned
   too: zero network dependency, deterministic.

2. CASCADE: a panic while holding VAULT_ENV_LOCK poisoned the mutex, so
   every later test died on PoisonError and one flake reported as many.
   The lock only serializes process-global XDG_DATA_HOME, so all four
   sites now unwrap_or_else into_inner — a real failure reports as ONE.

10/10 consecutive green e2e runs (was ~1 in 4 failing); suite time now
uniform ~21.5s (was bimodal — the long tail was network waiting).
cargo test 216 unit + 5 e2e green; clippy 0 warnings; fmt clean.
2026-09-24 18:24:15 -05:00
Avi
aa3c514e96 docs(checkpoint): NIP-46 session restore at 0982dad (2026-09-24) 2026-09-24 18:08:48 -05:00
Avi
0982dad566 feat(nip46): restore saved signer sessions on startup/unlock — no fresh scan
Amber remembers our client pubkey for the life of a connection, so the
client secret key minted at pairing is now persisted in the vault
(encrypted like connection secrets, keyed by the same VaultRef, re-keyed
to the identity ref when the handshake resolves it). A restart re-dials
the saved session with the exact keypair, re-sends connect, and enforces
expected_identity in adopt_identity: a signer answering as a different
account is refused, never adopted. Restore hooks run at serve() for
unencrypted vaults and after UnlockVault; failures never block the GUI.
Legacy rows without a stored client key are skipped (one fresh scan
makes them restorable).
2026-09-24 18:07:12 -05:00
Avi
73bf17c3c8 docs(checkpoint): sign_event timeout leash at f53bc56 (2026-09-23 evening) 2026-09-23 20:33:11 -05:00
Avi
f53bc56497 fix(nip46): give sign_event the human-approval timeout leash
Live pairing (Sep 23) proved the signer round-trip works: connect,
get_public_key and the first sign_event all succeeded against real
Amber. Subsequent signs failed because the client half used the 30s
ordinary-RPC leash for sign_event, while every sign waits on a human
approving the prompt on the signer's device. The log shows a valid
signature arriving ~61s after publication, after the waiter had been
removed: 'stale/duplicate response: no waiter ... dropped' — the user
watched failures while Amber was signing correctly.

sign_event now uses a 120s SIGN_TIMEOUT (same leash as the connect
handshake); get_public_key keeps the 30s retry-cadence timeout.
2026-09-23 20:31:37 -05:00
Avi
8117b7913b docs(checkpoint): feed author resolution at a76d8df (2026-09-23) 2026-09-23 16:06:33 -05:00
Avi
a76d8dff4e feat(feed): resolve author names and pictures from kind-0 metadata 2026-09-23 16:06:19 -05:00
Avi
cd8b6711a4 docs(checkpoint): kind-0 via signer at 6f4dbb2 (2026-09-23) 2026-09-23 15:30:49 -05:00
Avi
6f4dbb2ca1 feat(nip46): publish kind-0 metadata through the connected signer 2026-09-23 15:30:35 -05:00
Avi
327bf0ffe0 docs(checkpoint): silent poll fix at 3d1c306, session-restore gap named (2026-09-23) 2026-09-23 15:09:51 -05:00
Avi
3d1c30662a fix(ui): silent background state poll - no refetch churn when vault unchanged 2026-09-23 15:09:37 -05:00
Avi
5714349ea3 docs(checkpoint): remote-profile rename at 6ebc364 (2026-09-23) 2026-09-23 14:44:29 -05:00
Avi
6ebc364fcf fix(profiles): label-only rename for secretless remote-signer profiles 2026-09-23 14:44:18 -05:00
Avi
ca62111092 docs(checkpoint): first live Amber pairing succeeded, notifications root cause (2026-09-23) 2026-09-23 14:30:21 -05:00
Avi
a8d112b368 fix(ui): poll vault state so background Amber pairing appears without reload 2026-09-23 14:30:07 -05:00
Avi
fb149763d5 docs(checkpoint): sync to e02417b - Amber-side silence, damus rejoins pairing set (2026-09-23) 2026-09-23 13:57:53 -05:00
Avi
e02417b18c fix(pairing): offer relay.damus.io first - Amber never receives on primal/nos.lol 2026-09-23 13:57:41 -05:00
Avi
5456835cde docs(checkpoint): sync to 8f055f7 - NIP-46 spec fix, handshake trace, visible errors (2026-09-23) 2026-09-23 13:02:34 -05:00
Avi
8f055f71bf fix(nip46): spec-correct connect params, full-handshake [NIP46] trace, visible handshake errors 2026-09-23 13:02:19 -05:00
Avi
2bc6321d58 fix(pairing): retry identity RPC; pairing set to proven relays only
The 09:44 live scan proved the failure with the new accepted_by trace:
our get_public_key was published and accepted, but Amber's own connect
echo was created one second LATER — Amber's subscription to our client
key opens milliseconds after we publish, so the first identity request is
already in the relays' past when its listener starts (relays never replay
history to a fresh subscription). The signer-side race is structural and
unfixable from our subscription ordering; adopt_identity now retries
get_public_key up to 4x with 3/8/15s backoff (non-timeout errors still
fail fast), so a later attempt lands while the signer is listening.

pairing_relays() narrowed to primal + nos.lol — the only two relays with
proven bidirectional ephemeral-24133 traffic in today's scans. damus.io
503'd reads and silently dropped events; snort persists nothing; user
settings switched to the two proven relays as well.
2026-09-23 10:06:11 -05:00
Avi
be51f67797 docs(checkpoint): point HEAD refs at 9cfc1cf 2026-09-23 09:43:04 -05:00
Avi
88815710ba docs(checkpoint): sync to 9cfc1cf — subscribe race fixed, RPC relay-accept trace added; nostr.band disabled in user settings (2026-09-23) 2026-09-23 09:42:35 -05:00
Avi
9cfc1cfeb1 chore(pairing): trace which relays accepted each identity RPC
The Sep 23 08:59 live scan died at get_public_key with the subscribe race
already fixed, and a post-hoc relay sweep found neither our request nor
Amber's connect reply on any healthy pairing relay — the session lived on
damus.io (503-flapping) and nostr.band (handshake-hanger, still enabled in
user settings and therefore merged into the pairing set). publish_payload
now returns the accepting-relay list and send_rpc traces it for live
sessions (rpc published: method=… accepted_by=…, NONE when no relay took
the event), so the next scan names the dead relay instead of guessing.
2026-09-23 09:33:06 -05:00
Avi
2e98e69ddf fix(pairing): subscribe to signer replies BEFORE the handshake publishes
The relay pushes events only to subscriptions that exist at delivery time.
Both connect flows spawned the handshake task before run_demux registered the
kind-24133 author subscription, so a fast signer reply (the live Sep 23 Amber
scan: clean connect + secret echo, then get_public_key) landed in the gap and
was silently dropped — the identity RPC timed out 30s later and the session
died with 'The signer would not reveal its public key' after the connection
was already stored, leaving the UI signed in with no profile.

Both run_sign_task (bunker flow) and run_paired (QR pairing) now subscribe
first via subscribe_to_signer and hand the stream + subscription to run_demux.
futures-util promoted from dev-dependency to runtime. cargo test 209+3e2e
green, clippy 0, fmt clean, release rebuilt.
2026-09-23 09:00:17 -05:00
Avi
13a66f28d8 feat(onboarding): first-run screen offers import-existing-account and external-signer paths
- Home first-run now has three entry points: create a new profile, I already
  have an account (opens ImportProfileModal), and Sign in with a signer
  (navigates to Signer Mode where Amber/NIP-46 pairing lives).
- Copy states the per-mode truth: local-vault keys vs remote signer where the
  private key never lives on this device.
- Tests updated to assert all three entry points; 116 frontend tests green.
2026-09-23 09:00:07 -05:00
Avi
963b740992 checkpoint: relay-set canary fix at c789cb4 (2026-09-22) 2026-09-22 20:40:46 -05:00
Avi
c789cb4784 fix(pairing): drop purplepag.es and nostr.band from the pairing relay set
Live write+readback canary (Sep 22): purplepag.es rejects kind 24133
outright ('blocked: kind 24133 is not allowed') and relay.nostr.band
hangs the WebSocket handshake. A signer (Amber) that picks a blocking
relay reports 'connected' while its connect event is silently discarded
— exactly the observed failure. The Sep 22 18:01 trace shows a pairing
window that opened, never saw an inbound 24133, and timed out; a
post-hoc relay sweep found zero 24133 events tagged to the ephemeral
key on any relay, consistent with Amber's write being rejected.

Replaced with nos.lol and relay.snort.social, both verified to accept
and serve ephemeral kind-24133. Added a regression test pinning the
blockers out of the set.
2026-09-22 20:39:30 -05:00
Avi
2e5938a3fa checkpoint: docs honesty fix at d4d87b8 (2026-09-22) 2026-09-22 17:47:06 -05:00
Avi
d4d87b85b6 docs: replace blanket 'keys never leave the machine' claim with per-mode truth
External NIP-46 signer mode is a stronger posture, not a caveat: the key
never arrives on this machine, so a compromised desktop cannot extract it.
The old claim only holds for embedded/bunker modes and undersold the
external-signer option. UI already ranked modes correctly; no code change.
2026-09-22 17:46:17 -05:00
Avi
e6ddc53261 checkpoint: sync to f6bf6a9 — pairing trace fully de-noised; live Amber scan still pending (2026-09-21) 2026-09-21 20:42:11 -05:00
Avi
f6bf6a979a fix(pairing): keep e2e traffic out of the live pairing trace + trace the handover
The trace log's "identity adopted - CONNECTED" line fired in the e2e
harness too (adopt_identity had no relay gate), so every test run
appended fake CONNECTED entries to the forensics log. Three such lines
landed there during today's cron verification and had to be manually
distinguished from a real Amber scan. Gate the line on live_relays()
(same loopback test the capture already uses) and add a "paired:
connection stored" trace line at the QR-pairing handover so a stall
between connect-echo and get_public_key is visible in the trace.
2026-09-21 20:39:08 -05:00
Avi
4e79d7ed00 checkpoint: sync to deeb4f9 — e2e vault-isolation bug fixed; live Amber scan still pending (2026-09-20) 2026-09-20 20:45:52 -05:00
Avi
deeb4f9e88 test(e2e): assert vault isolation actually isolated
After each e2e App::load(), assert the loaded vault is empty. An
isolated run that loads any profile can only mean load_vault() fell
through to legacy migration (real repo vault with user keys). The bug
fixed in the previous commit passed green for weeks precisely because
nothing checked this; the guard makes it fail loudly and immediately.
2026-09-20 20:44:10 -05:00
Avi
d52fa58354 test(e2e): isolate the e2e vault at the real data_dir path
Both e2e vault setups wrote the isolation vault to $XDG_DATA_HOME/
profiles_vault.json, but vault::data_dir() is $XDG_DATA_HOME/keynectr —
so the app never found the seeded vault and load_vault() fell through to
try_migrate_legacy_vault(), which picked up the legacy repo vault
(CARGO_MANIFEST_DIR/profiles_vault.json — the user's real profile with a
plaintext secret key) and migrated it into the test process.

Forensics: two legacy-vault backups appeared Sep 19 20:43:54 + 20:44:30,
exactly the cargo-test runs around the edd4e56 commit, proving every e2e
run was migrating the user's real legacy vault. The pairing-trace
"identity adopted" lines from that window were e2e sessions, not a live
Amber scan (no "pairing started" line, no new capture events — session-
level traces are not gated by live_capture).

Fix: seed the vault at tmp/keynectr/profiles_vault.json. Verified green
after the change: repo legacy vault byte-identical, backup count
unchanged, 3/3 e2e pass.
2026-09-20 20:42:08 -05:00
Avi
5f9c64fb82 checkpoint: sync to edd4e56 — accept NIP-46 connect *response* shape (root-cause fix) (2026-09-19) 2026-09-19 20:50:12 -05:00
Avi
edd4e565fb fix(pairing): accept the NIP-46 connect *response* shape Amber actually sends
For a client-initiated nostrconnect:// scan, NIP-46 says the signer
sends a connect RESPONSE event — {"id",…,"result":"<secret>"} — not a
connect request: the secret echo IS the handshake, nothing to answer.
run_pairing_task only recognized an inbound {"method":"connect"}
*request*; a bare {"result":…} fell through as 'not a NIP-46 request'
(method empty) or 'pre-handshake ignored', so Amber's approval was
silently dropped and no profile row was ever created.

Now the pairing loop verifies the echoed secret (or 'ack') directly and
proceeds to identity adoption; a signer-sent error fails fast with the
signer's message. Wrong-secret responses are ignored as spoofing, same
as before. The legacy request shape keeps working.

e2e: run_fake_scanner takes a connect_shape; new
nip46_qr_pairing_connect_response_shape pins the Amber shape end-to-end
(fails on the old parser, green on the new one).
2026-09-19 20:45:27 -05:00
Avi
a28d76e7d0 checkpoint: sync to 21c522b — durable pairing trace log, forensics hygiene (2026-09-18) 2026-09-18 21:03:08 -05:00
Avi
21c522ba99 chore(pairing): durable trace log + keep e2e loopback traffic out of forensics files
The Sep 16 forensics file pairing-capture.jsonl turned out to be
polluted: the e2e harness pairs over loopback relays but its fake
scanner events were appended to the same file (lines 6-7 from today's
test runs). Now loopback pairings skip both the capture file and the
new pairing-trace.log.

pairing-trace.log records the full pairing outcome at every decision
point (started, inbound, decrypt-fail, not-a-request, pre-handshake,
connect answered, identity adopted, session failed) with timestamps,
because backend stderr only reaches the Electron console and /tmp logs
get cleaned — a failed live handshake previously left no durable trace.

Release binary rebuilt at this commit.
2026-09-18 21:00:31 -05:00
Avi
e97d8a75f0 checkpoint: sync to 188b2eb — lenient NIP-46 payload parse, real decrypt-error logging (2026-09-16) 2026-09-16 21:12:13 -05:00
Avi
188b2eb61d fix(pairing): never reject a decrypted NIP-46 payload on shape
RawRequest still dropped real Amber connect requests after aedde8f:
tonight's re-scan (20:11) hit the lenient-params build and the payload
parsed no better. The strict derive rejected non-string ids, missing
ids, object-shaped params, and double-encoded request strings. Replace
the derived Deserialize with a coercion-based one: any valid JSON
deserializes, every scalar becomes text, a JSON-string-wrapped object
is unwrapped, and only truly non-JSON reaches the log path — now
dumping the exact payload plus the real decrypt error (HMAC vs padding
vs wrong key) instead of a generic 'wrong conversation key'.
2026-09-16 21:08:34 -05:00
Avi
aedde8ffb8 fix(pairing): accept non-string NIP-46 params (Amber sends requested_perms as a JSON object)
RawRequest used a strict Vec<String>, so Amber's connect request —
whose params[1] is the requested_perms grant object — failed to parse
and was silently dropped. The signer saw a live session; we saw
nothing. Params now coerce non-string values to their JSON text, with
regression tests for both shapes.
2026-09-12 21:38:46 -05:00
Avi
759b5ddfc9 chore(pairing): capture raw inbound pairing events for handshake debugging 2026-09-12 21:33:15 -05:00
Avi
5aa122d92d chore(pairing): 5-min pairing window + inbound-event wiretap logging 2026-09-12 21:26:07 -05:00
Avi
049219b0f1 docs(checkpoint): sync to f59c2b1 pairing-relay widening 2026-09-12 21:09:06 -05:00
Avi
f59c2b1b45 fix(pairing): widen pairing relay set so signer-chosen relays are covered
Amber picks whichever relay it likes from the nostrconnect:// URI and
some relays drop ephemeral kind-24133 traffic, so pairing could appear
successful on the signer while nothing reached us. The pairing set is
now the user's relays UNION a curated fallback of well-known relays;
loopback-only sets (e2e harness) skip widening to keep tests isolated.
2026-09-12 21:07:27 -05:00
Avi
937fcc67cb checkpoint: sync to 22d7c01 — QR pairing, identity adoption, always-allow grants, hygiene (2026-09-12) 2026-09-12 17:56:08 -05:00
Avi
22d7c01193 chore(hygiene): ignore editor artifacts; prettier SignerScreen
- .gitignore now covers .directory, .opencode/, .impeccable/.
- SignerScreen.tsx reformatted (format:check was failing since 81b082f).
- Dead untracked stub src/signer/nip46_external.rs deleted from disk
  (superseded by nip46_client.rs, never declared in signer/mod.rs).
2026-09-12 17:55:32 -05:00
Avi
81b082f238 feat(signer): always-allow grants for external signer requests
Apps asking Keynctr to sign (NIP-46) can now be granted standing
permission per (peer pubkey, method). Approvals gained an 'Always
allow' option; existing grants are listed with a Revoke button on the
Signer screen and persist in the encrypted vault.
2026-09-12 17:00:41 -05:00
Avi
286bbcaa04 fix(signer): connect immediately after identity; fetch kind-0 metadata in background
The adopt step blocked on a best-effort relay metadata fetch before
flipping the session to Connected, so after Amber approved, the UI sat
looking dead for many seconds (the 'Amber said yes but nothing changed'
bug). Connected now flips as soon as the identity is verified and
persisted; the real display name / picture / nip05 land asynchronously
via a background task that never overrides a user-chosen label.
2026-09-12 15:29:53 -05:00
Avi
3d5302fbf3 feat(signer): adopt real display name/picture for paired NIP-46 identities
A paired profile was stored under the generic pairing label (or a bare
npub). adopt_identity now does a best-effort, 3s-capped kind-0 metadata
lookup for the learned identity and stores display_name/name plus
picture/nip05 on the profile row, falling back to the pairing label when
relays are unavailable.
2026-09-12 09:47:34 -05:00
Avi
9cfab4bce6 chore(signer): log pairing start and session failures to stderr
Pairing failures were invisible: fail() wrote nothing and the only
success-path trace was the URI. Backend stderr is captured by Electron,
so a stalled handshake is now diagnosable from /tmp logs.
2026-09-12 05:48:40 -05:00
Avi
dc58f3889f fix(ipc): lazily initialize the NIP-46 client signer handle
App startup defaults signer_mode to Nip46Client but only SignerModeSet
builds the handle, so a fresh backend answered every nip46_* request
('Show QR' included) with 'not initialized' until the user re-saved the
mode. All nip46_* handlers now ensure the handle exists first.
2026-09-12 05:09:14 -05:00
Avi
c5004ebb26 fix(electron): allow nip46_pair_start through the renderer method allowlist
The new QR pairing IPC method was rejected by the main-process allowlist
before reaching the Rust backend ('That operation is not permitted').
2026-09-12 05:01:46 -05:00
Avi
38499d4506 feat(signer): QR pairing — client-initiated nostrconnect:// flow for Amber
Keynctr is the NIP-46 client; Amber is the scanner. Amber hands out no
link — it scans one — so the signer screen now mints a pairing token:

- start_pairing(): ephemeral key + secret, nostrconnect:// token via
  NostrConnectUri::client_with_secret, status().pairing_uri for the GUI
- run_pairing_task(): listens for the signer's connect request, echoes
  the secret (anti-spoofing), persists the connection row + secret,
  then adopts identity via get_public_key and hands to the demux loop
- pairing subscription is closed at handoff so the demux loop owns the
  conversation (relay could otherwise deliver signer replies under the
  stale pairing sub id where nobody routes them)
- IPC: nip46_pair_start; status carries pairing_uri
- SignerModeScreen: 'Show QR' button, QR render (qrcode) of the token,
  copy-link fallback, cancel; paste-link flow unchanged
- e2e: fake QR scanner consumes the real pairing token end-to-end
  (scan -> secret echo -> identity -> sign -> vault persistence)
2026-09-12 04:47:20 -05:00
Avi
764406e5a9 checkpoint: NIP-46 e2e test + bunker:// frontend support (85756df) 2026-09-12 02:41:11 -05:00
Avi
85756df081 feat(signer): accept bunker:// URIs, async signer permissions, NIP-46 e2e test
- SignerManager/SignerModeScreen parse both nostrconnect:// and bunker://
  (Amber presents bunker://; signer pubkey extracted before '@')
- Signer permission surface made async (permissions, can_*, is_connection_valid)
- tests/nip46_e2e.rs: full client handshake against fake Amber over a local
  relay — NIP-44 round-trip, get_public_key identity, signed-event verification,
  vault persistence asserting no secret material for remote profiles
- prettier formatting of touched frontend files
2026-09-12 02:40:40 -05:00
Avi
84f11e615d checkpoint: vault-load rewrite fix (c096705) + Amber verify as next step 2026-09-11 15:13:16 -05:00
Avi
c09670530c fix(vault): stop rewriting the vault on every load; clippy cleanup
- migrate_vault_signer_modes now reports a change only when the vault
  version actually moves. The unconditional 'changed = true' made
  App::load re-save the vault on every start (harmless, idempotent,
  but wasteful). Per-profile signer_mode normalisation was already a
  no-op: the serde default fills missing fields at parse time and the
  current version serialises it explicitly.
- idempotency test tightened to assert changed == false for a
  current-version vault (previously ducked the question).
- nip46_client.rs: drop clone-on-Copy in get_public_key (clippy).
2026-09-11 15:11:00 -05:00
Avi
f917e5ecfd fix(signer): Amber-compatible handshake — bunker:// URIs, deferred identity, ack wait
- parse_connect_uri accepts bunker:// as well as nostrconnect://
- URI authority key is no longer treated as identity (Amber mints a
  per-connection comms key); real identity learned via get_public_key
  after the connect ack, then persisted (profile row + secret re-key)
- connect ack awaited in a spawned handshake task with a 120s human
  approval window; session stays Connecting (all signing fails closed)
  until identity is verified
- absent perms= no longer locally denies signing; enforcement is
  delegated to the signer's approval UI
- send_rpc honours its timeout parameter
2026-09-11 11:08:09 -05:00
Avi
6e5d80ba0b checkpoint: external NIP-46 signing end-to-end (Step 3 sub-step 2 done) 2026-09-10 21:55:09 -05:00
Avi
1af79d81cd feat(signer): end-to-end external NIP-46 signing in publish and upload auth
Step 3 sub-step 2 (IPC reroute) — the publish path now actually signs
remotely instead of returning 'not yet supported':

- src/signer/nip46_client.rs: outbound NIP-46 request half — send
  sign_event over the encrypted channel, demux responses to waiting
  callers, 30s timeout, waiters woken on disconnect/fail. Signer::sign_event
  verifies the returned event matches the requested unsigned event, is
  signed by the connected identity, and carries a valid signature; no
  local fallback. Permission-denied audit uses try_lock so a denied
  in-flight sign cannot deadlock the dispatcher.
- src/app.rs: App::signing_for / signing_active — one place that maps a
  profile's SignerMode to a Signing source. Embedded -> Local(vault key);
  Nip46Client -> External(live signer) only when connected, otherwise
  ExternalSignerNotConnected; Nip46Bunker fails closed.
- src/publish.rs: publish_with_keys builds the unsigned event from the
  Signing's own pubkey and signs via Signing::sign; publish_signed entry
  point for IPC (CLI keeps publish_active local path).
- src/ipc.rs: PublishNote and UploadAuth route through signing_active.
  Nip46Connect/Nip46Disconnect drop the App guard before awaiting
  connect()/disconnect() (they re-lock internally — latent deadlock).
- src/relays.rs: keyless relay pool (open_pool_inner(Option<Keys>)) so
  external signing publishes without local keys.
- src/uploads.rs: nip98_authorization takes a Signing source, so upload
  auth signs remotely for external profiles too.
- src/profiles.rs: store_remote_profile — connecting a NIP-46 signer
  creates/refreshes a secretless Nip46Client profile row; refuses to
  silently convert an existing local profile.

Tests: signing selection (local, fail-closed external, no profile),
store_remote_profile create + no-clobber. 200 tests pass; clippy clean;
fmt clean; release build green.
2026-09-10 21:54:32 -05:00
Avi
8780ef3fbb checkpoint: document undo-delete secret-preserving fix (2026-09-10) 2026-09-10 12:50:24 -05:00
Avi
d101b8e236 fix(undo): restore full profile with secret key on undo-delete 2026-09-10 12:50:07 -05:00
45 changed files with 7379 additions and 585 deletions

5
.gitignore vendored
View file

@ -6,6 +6,11 @@ profiles_vault.json
profiles_vault.json.backup-* profiles_vault.json.backup-*
*.json.tmp *.json.tmp
# Editor / tool artifacts
.directory
.opencode/
.impeccable/
# Frontend # Frontend
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/

File diff suppressed because it is too large Load diff

3
Cargo.lock generated
View file

@ -1169,6 +1169,7 @@ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
"base64", "base64",
"futures-util",
"getrandom 0.2.17", "getrandom 0.2.17",
"hex", "hex",
"keyring", "keyring",
@ -1179,6 +1180,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"tokio", "tokio",
"tokio-tungstenite",
"uuid", "uuid",
"zeroize", "zeroize",
] ]
@ -1281,6 +1283,7 @@ version = "0.45.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a" checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a"
dependencies = [ dependencies = [
"aes 0.8.4",
"base64", "base64",
"bech32", "bech32",
"bip39", "bip39",

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
nostr = { version = "0.45", features = ["nip44", "nip98"] } nostr = { version = "0.45", features = ["nip44", "nip46", "nip98"] }
nostr-sdk = "0.45" nostr-sdk = "0.45"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@ -20,3 +20,11 @@ rpassword = "7"
sha2 = "0.10" sha2 = "0.10"
async-trait = "0.1" async-trait = "0.1"
keyring = "4.2" 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"

View file

@ -109,7 +109,7 @@ components:
**Creative North Star: "Vault & Atelier"** **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. 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.

View file

@ -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. 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 ## 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 ## 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 ## 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. 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. 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 ## 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. 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. 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. 4. **One core, two doors** — GUI and CLI remain interchangeable via the same Rust engine; no feature lives only in one surface without justification.

View file

@ -1,7 +1,8 @@
# Nostr Feed Manager # Nostr Feed Manager
> A friendly Linux desktop app for managing Nostr profiles, publishing notes, and acting as a > 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)]() [![Version](https://img.shields.io/badge/version-0.1.0-blue)]()
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) [![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 (`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 **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. 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 - **In-memory key only.** You unlock once per session; the derived key lives only in memory and
never written to disk. 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 - **Approve-before-any-signing.** The NIP-46 remote signer will not sign, encrypt, or decrypt
until you explicitly approve each request. until you explicitly approve each request.
- **Least-privileged storage.** Files are written with directories `0700` and files `0600`. - **Least-privileged storage.** Files are written with directories `0700` and files `0600`.

View file

@ -524,12 +524,15 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'signer_disconnect', 'signer_disconnect',
'signer_status', 'signer_status',
'signer_approve', 'signer_approve',
'signer_grants_list',
'signer_grant_revoke',
// New signer modes (default: nip46_client most secure) // New signer modes (default: nip46_client most secure)
'signer_mode_get', 'signer_mode_get',
'signer_mode_set', 'signer_mode_set',
'embedded_signer_status', 'embedded_signer_status',
'embedded_signer_approve', 'embedded_signer_approve',
'nip46_connect', 'nip46_connect',
'nip46_pair_start',
'nip46_disconnect', 'nip46_disconnect',
'nip46_status', 'nip46_status',
'nip46_approve', 'nip46_approve',

View file

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"nostr-tools": "^2.25.1", "nostr-tools": "^2.25.1",
"qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@ -19,6 +20,7 @@
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
@ -1537,6 +1539,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/react": {
"version": "18.3.31", "version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
@ -2028,7 +2040,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -2038,7 +2049,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@ -2516,6 +2526,15 @@
"node": ">=6" "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": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -2608,7 +2627,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -2621,7 +2639,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": { "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": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2898,6 +2924,12 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/dir-compare": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@ -3187,7 +3219,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
@ -3761,7 +3792,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@ -4218,7 +4248,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -5250,6 +5279,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -5280,7 +5318,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -5394,6 +5431,15 @@
"node": ">=10.4.0" "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": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@ -5601,6 +5647,141 @@
"node": ">=16.0.0" "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": { "node_modules/quick-lru": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@ -5693,7 +5874,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -5709,6 +5889,12 @@
"node": ">=0.10.0" "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": { "node_modules/resedit": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@ -5936,6 +6122,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -6063,7 +6255,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@ -6078,7 +6269,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@ -6764,6 +6954,12 @@
"node": ">= 8" "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View file

@ -28,6 +28,7 @@
}, },
"dependencies": { "dependencies": {
"nostr-tools": "^2.25.1", "nostr-tools": "^2.25.1",
"qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@ -38,6 +39,7 @@
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",

View file

@ -14,6 +14,7 @@ import { SignerScreen } from './screens/SignerScreen';
import { SignerModeScreen } from './screens/SignerModeScreen'; import { SignerModeScreen } from './screens/SignerModeScreen';
import { SettingsScreen } from './screens/SettingsScreen'; import { SettingsScreen } from './screens/SettingsScreen';
import { CreateProfileModal } from './screens/CreateProfileModal'; import { CreateProfileModal } from './screens/CreateProfileModal';
import { ImportProfileModal } from './screens/ImportProfileModal';
import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
import type { Screen } from './lib/navigation'; import type { Screen } from './lib/navigation';
@ -21,6 +22,7 @@ function Shell() {
const { state, loading, bootstrapError } = useApp(); const { state, loading, bootstrapError } = useApp();
const [screen, setScreen] = useState<Screen>('home'); const [screen, setScreen] = useState<Screen>('home');
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false); const [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme); useThemeSync(state?.settings.theme);
@ -68,7 +70,11 @@ function Shell() {
</div> </div>
)} )}
{screen === 'home' && ( {screen === 'home' && (
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} /> <HomeScreen
onNavigate={setScreen}
onCreateProfile={() => setCreateOpen(true)}
onImportProfile={() => setImportOpen(true)}
/>
)} )}
{screen === 'feed' && <FeedScreen onNavigate={setScreen} />} {screen === 'feed' && <FeedScreen onNavigate={setScreen} />}
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />} {screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
@ -79,6 +85,7 @@ function Shell() {
{screen === 'settings' && <SettingsScreen />} {screen === 'settings' && <SettingsScreen />}
</main> </main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} /> <UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
</div> </div>
); );

View file

@ -89,9 +89,13 @@ export function ExportSecretKeyModal({ open, onClose, profile }: ExportSecretKey
} else if (code === 'profile_not_found') { } else if (code === 'profile_not_found') {
setFatal({ message: 'That profile is not stored on this computer.' }); setFatal({ message: 'That profile is not stored on this computer.' });
setPhase('error'); 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({ 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'); setPhase('error');
} else { } else {

View file

@ -12,6 +12,7 @@ import type {
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
SignerGrant,
SignerMode, SignerMode,
SignerStatus, SignerStatus,
UpdateApplyReport, UpdateApplyReport,
@ -118,17 +119,26 @@ export const api = {
// NIP-46 client signer // NIP-46 client signer
nip46Connect: (uri: string, label: string) => nip46Connect: (uri: string, label: string) =>
call<Nip46SignerStatus>('nip46_connect', { uri, label }), call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'), nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'), nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean) => nip46Approve: (id: string, approved: boolean, always = false) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }), call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility) // Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }), signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'), signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'), signerStatus: () => call<SignerStatus>('signer_status'),
signerApprove: (id: string, approved: boolean) => signerApprove: (id: string, approved: boolean, always = false) =>
call<SignerStatus>('signer_approve', { id, approved }), call<SignerStatus>('signer_approve', { id, approved, always }),
// Standing "always allow" grants for apps using us as their signer.
signerGrantsList: () => call<SignerGrant[]>('signer_grants_list'),
signerGrantRevoke: (appPubkey: string, grantMethod: string) =>
call<{ removed: boolean }>('signer_grant_revoke', {
app_pubkey: appPubkey,
grant_method: grantMethod,
}),
deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }), deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }),
undoDelete: () => call<AppState>('undo_delete'), undoDelete: () => call<AppState>('undo_delete'),

View file

@ -322,14 +322,21 @@ export class SignerManager {
// ==================== CLIENT MODE (connect TO external signer) ==================== // ==================== 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 { parseExternalSignerURI(uri: string): ExternalSignerConnection {
if (!uri.startsWith('nostrconnect://')) { const isBunker = uri.startsWith('bunker://');
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://'); 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 withoutScheme = uri.slice(isBunker ? 'bunker://'.length : 'nostrconnect://'.length);
const signerPubkey = authority; const [authorityRaw, queryString] = withoutScheme.split('?');
// bunker://<key>@<primary-relay>?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 params = new URLSearchParams(queryString || '');
const relays = params.getAll('relay'); const relays = params.getAll('relay');
const secret = params.get('secret') || undefined; const secret = params.get('secret') || undefined;

View file

@ -29,6 +29,16 @@ export interface PendingApproval {
details?: ApprovalDetails; 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. */ /** Non-secret snapshot of the NIP-46 remote signer for display. */
export interface SignerStatus { export interface SignerStatus {
phase: SignerPhase; phase: SignerPhase;
@ -63,6 +73,8 @@ export interface Nip46SignerStatus {
connected_relays: string[]; connected_relays: string[];
error?: string; error?: string;
pending_approvals: PendingApproval[]; pending_approvals: PendingApproval[];
/** nostrconnect:// pairing token while a QR pairing is in flight. */
pairing_uri?: string;
} }
/** Union of all signer statuses. */ /** Union of all signer statuses. */
@ -130,6 +142,10 @@ export interface FeedItem {
author: string; author: string;
/** Bech32 `npub` of the author, for display. */ /** Bech32 `npub` of the author, for display. */
author_npub: string; 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; content: string;
/** Unix timestamp the note was created. */ /** Unix timestamp the note was created. */
created_at: number; created_at: number;

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
import QRCode from 'qrcode';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
import { shortenNpub } from '../lib/format'; import { shortenNpub } from '../lib/format';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@ -11,14 +12,15 @@ interface CreateProfileModalProps {
onClose: () => void; onClose: () => void;
} }
type Phase = 'form' | 'creating' | 'success'; type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile } = useApp(); const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [phase, setPhase] = useState<Phase>('form'); const [phase, setPhase] = useState<Phase>('choice');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [createdNpub, setCreatedNpub] = useState<string | null>(null); const [createdNpub, setCreatedNpub] = useState<string | null>(null);
const [pairingQr, setPairingQr] = useState<string | null>(null);
const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`); const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const shorten = state?.settings.shorten_npub ?? true; const shorten = state?.settings.shorten_npub ?? true;
@ -26,15 +28,88 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setLabel(''); setLabel('');
setPhase('form'); setPhase('choice');
setError(null); setError(null);
setCreatedNpub(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()); const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
} }
return undefined; 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<string | null>(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'; const canSubmit = label.trim().length > 0 && phase !== 'creating';
@ -50,13 +125,157 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
setCreatedNpub(summary.npub); setCreatedNpub(summary.npub);
setPhase('success'); setPhase('success');
} catch (err) { } catch (err) {
setPhase('form'); setPhase('local');
setError(err instanceof Error ? err.message : String(err)); 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 ( return (
<Modal open={open} title="Create a Nostr profile" onClose={onClose}> <Modal open={open} title="Add a profile" onClose={handleClose}>
<div className="create-explainer">
<p>How do you want this profile to sign?</p>
</div>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
<div style={{ display: 'grid', gap: 12 }}>
<Button variant="primary" onClick={() => void startPairing()}>
<Icon name="key" size={16} />
Sign in with a signer app (Amber)
</Button>
<p className="hint" style={{ marginTop: -4 }}>
Show a QR code to Amber on your phone — your keys stay on the phone, and every signature
is approved there.
</p>
<Button variant="secondary" onClick={() => setPhase('local')}>
<Icon name="shield" size={16} />
Create a new key on this computer
</Button>
<p className="hint" style={{ marginTop: -4 }}>
A brand-new local identity whose private key lives in this app&apos;s vault.
</p>
</div>
</Modal>
);
}
if (phase === 'pairing') {
return (
<Modal open={open} title="Sign in with Amber" onClose={handleClose}>
<div className="signer-pairing">
<p>
Scan this code with <strong>Amber</strong> (or any NIP-46 signer) and approve the
connection.
</p>
{pairingQr ? (
<img
src={pairingQr}
alt="Pairing QR code"
style={{
width: 260,
height: 260,
imageRendering: 'pixelated',
borderRadius: 8,
display: 'block',
margin: '12px auto',
background: '#fff',
padding: 8,
}}
/>
) : (
<p className="hint" style={{ textAlign: 'center' }}>
Preparing the pairing code…
</p>
)}
<p className="hint" style={{ textAlign: 'center' }}>
Waiting for the signer to scan… the profile appears automatically once approved. The
code expires after a few minutes.
</p>
{error && <ErrorText>{error}</ErrorText>}
<div className="modal-actions">
<Button variant="ghost" onClick={() => void cancelPairing()}>
Cancel pairing
</Button>
</div>
{livePairingUri && (
<details style={{ marginTop: 12 }}>
<summary className="hint">Or copy the pairing link</summary>
<code className="mono" style={{ wordBreak: 'break-all', fontSize: 11 }}>
{livePairingUri}
</code>
</details>
)}
</div>
</Modal>
);
}
if (phase === 'paired') {
return (
<Modal open={open} title="Signer connected" onClose={onClose}>
<div className="create-success">
<div className="create-success-icon" aria-hidden="true">
<Icon name="check" size={26} />
</div>
<h3>Amber is now your signer!</h3>
<p>
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.
</p>
<div className="modal-actions">
<Button variant="primary" onClick={onClose}>
Done
</Button>
</div>
</div>
</Modal>
);
}
return (
<Modal
open={open}
title={phase === 'local' || phase === 'creating' ? 'Create a Nostr profile' : 'Add a profile'}
onClose={handleClose}
>
{phase === 'success' && createdNpub ? ( {phase === 'success' && createdNpub ? (
<div className="create-success"> <div className="create-success">
<div className="create-success-icon" aria-hidden="true"> <div className="create-success-icon" aria-hidden="true">
@ -115,8 +334,12 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={phase === 'creating'}> <Button
Cancel variant="ghost"
onClick={() => setPhase('choice')}
disabled={phase === 'creating'}
>
Back
</Button> </Button>
<Button <Button
variant="primary" variant="primary"

View file

@ -219,12 +219,20 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
<ul className="feed-list"> <ul className="feed-list">
{items.map((item) => ( {items.map((item) => (
<li key={item.id} className="feed-item"> <li key={item.id} className="feed-item">
<Avatar npub={item.author_npub} label={shortenNpub(item.author_npub, shorten)} /> <Avatar
npub={item.author_npub}
label={item.author_name ?? shortenNpub(item.author_npub, shorten)}
picture={item.author_picture ?? null}
/>
<div className="feed-item-body"> <div className="feed-item-body">
<div className="feed-item-meta"> <div className="feed-item-meta">
{item.author_name ? (
<span title={item.author_npub}>{item.author_name}</span>
) : (
<span className="mono" title={item.author_npub}> <span className="mono" title={item.author_npub}>
{shortenNpub(item.author_npub, shorten)} {shortenNpub(item.author_npub, shorten)}
</span> </span>
)}
<span className="feed-item-time">{formatDate(item.created_at)}</span> <span className="feed-item-time">{formatDate(item.created_at)}</span>
{item.relays.length > 1 && ( {item.relays.length > 1 && (
<Badge tone="neutral">{item.relays.length} relays</Badge> <Badge tone="neutral">{item.relays.length} relays</Badge>

View file

@ -15,9 +15,10 @@ import { useApp } from '../state/AppProvider';
interface HomeScreenProps { interface HomeScreenProps {
onNavigate: (screen: Screen) => void; onNavigate: (screen: Screen) => void;
onCreateProfile: () => void; onCreateProfile: () => void;
onImportProfile: () => void;
} }
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: HomeScreenProps) {
const { state, selectProfile } = useApp(); const { state, selectProfile } = useApp();
const { publications, fullyPublished, loading, error } = useProfilePublications(); const { publications, fullyPublished, loading, error } = useProfilePublications();
const [selecting, setSelecting] = useState<string | null>(null); const [selecting, setSelecting] = useState<string | null>(null);
@ -43,16 +44,27 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
title="Welcome to Keynctr" title="Welcome to Keynctr"
description={ description={
<span> <span>
You haven't created a profile yet. A Nostr profile is your identity on the public A Nostr profile is your identity on the public Nostr network — a <code>npub</code>{' '}
Nostr network — a <code>npub</code> address you can share, plus a private key kept address you can share. You can create a new one here (its private key is generated
safely on this computer. Create your first profile to start publishing notes. and kept in this computer&rsquo;s encrypted vault), import an account you already
have, or connect an external signer like Amber so the private key never lives on
this device.
</span> </span>
} }
action={ action={
<div className="onboarding-actions">
<Button variant="primary" onClick={onCreateProfile}> <Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} /> <Icon name="plus" size={18} />
Create your first profile Create a new profile
</Button> </Button>
<Button variant="secondary" onClick={onImportProfile}>
<Icon name="key" size={18} />I already have an account
</Button>
<Button variant="ghost" onClick={() => onNavigate('signer-mode')}>
<Icon name="server" size={18} />
Sign in with a signer (Amber, NIP-46)
</Button>
</div>
} }
/> />
<FirstRunGuide /> <FirstRunGuide />

View file

@ -576,7 +576,9 @@ function RenameModal({
npub: target.npub, npub: target.npub,
perform: () => renameProfile(target.npub, trimmed), perform: () => renameProfile(target.npub, trimmed),
successMessage: (report) => successMessage: (report) =>
report.failed.length === 0 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}" 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.`, : `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
onSaved, onSaved,

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { Alert } from '../components/Alert'; import { Alert } from '../components/Alert';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@ -14,6 +15,7 @@ export function SignerModeScreen() {
embeddedSignerStatus, embeddedSignerStatus,
nip46Status, nip46Status,
nip46Connect, nip46Connect,
nip46PairStart,
nip46Disconnect, nip46Disconnect,
nip46Approve, nip46Approve,
embeddedSignerApprove, embeddedSignerApprove,
@ -30,14 +32,15 @@ export function SignerModeScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pairingQr, setPairingQr] = useState<string | null>(null);
const [pairError, setPairError] = useState<string | null>(null);
// Single source of truth: backend state (defaults to most secure) // Single source of truth: backend state (defaults to most secure)
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode; 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 isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available;
const refreshStatus = useCallback(async () => { const refreshStatus = useCallback(async () => {
try { try {
// Mode comes from AppProvider state, just refresh signer statuses // Mode comes from AppProvider state, just refresh signer statuses
@ -53,14 +56,24 @@ export function SignerModeScreen() {
const status = await embeddedSignerStatus(); const status = await embeddedSignerStatus();
setEmbeddedStatus(status); setEmbeddedStatus(status);
} catch { } catch {
setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any); setEmbeddedStatus({
type: 'embedded',
available: false,
pending_count: 0,
pending: [],
} as any);
} }
} else { } else {
try { try {
const status = await nip46Status(); const status = await nip46Status();
setNip46StatusState(status); setNip46StatusState(status);
} catch { } catch {
setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any); setNip46StatusState({
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
} as any);
} }
} }
} catch (err) { } catch (err) {
@ -96,12 +109,18 @@ export function SignerModeScreen() {
await refresh(); await refresh();
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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.'); setError('No keypair found: Please import a key first.');
} else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) { } else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) {
setError('Vault locked: Please unlock to switch modes.'); setError('Vault locked: Please unlock to switch modes.');
} else if (msg.includes('ACTIVE_SESSION') || msg.toLowerCase().includes('active session')) { } 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 { } else {
setError(msg || 'That operation is not permitted.'); setError(msg || 'That operation is not permitted.');
} }
@ -112,8 +131,12 @@ export function SignerModeScreen() {
const handleNip46Connect = useCallback(async () => { const handleNip46Connect = useCallback(async () => {
const trimmed = uri.trim(); const trimmed = uri.trim();
if (!trimmed.startsWith('nostrconnect://')) { // Amber and self-hosted bunkers show a bunker:// link; Nostr Connect
setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.'); // 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; return;
} }
setError(null); setError(null);
@ -129,6 +152,58 @@ export function SignerModeScreen() {
} }
}, [uri, label, nip46Connect]); }, [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 () => { const handleNip46Disconnect = useCallback(async () => {
setError(null); setError(null);
try { try {
@ -153,10 +228,10 @@ export function SignerModeScreen() {
); );
const handleNip46Approve = useCallback( const handleNip46Approve = useCallback(
async (id: string, approved: boolean) => { async (id: string, approved: boolean, always = false) => {
setError(null); setError(null);
try { try {
const status = await nip46Approve(id, approved); const status = await nip46Approve(id, approved, always);
setNip46StatusState(status); setNip46StatusState(status);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@ -183,12 +258,16 @@ export function SignerModeScreen() {
return isEmbeddedActive ? ( return isEmbeddedActive ? (
<Badge tone="success">Embedded (Least Secure)</Badge> <Badge tone="success">Embedded (Least Secure)</Badge>
) : ( ) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>Embedded {vaultLocked ? '(Vault Locked)' : ''}</Badge> <Badge tone={vaultLocked ? 'warning' : 'neutral'}>
Embedded {vaultLocked ? '(Vault Locked)' : ''}
</Badge>
); );
}; };
const handleImportKey = useCallback(async () => { 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; if (nsec === null) return;
setError(null); setError(null);
try { try {
@ -237,11 +316,15 @@ export function SignerModeScreen() {
<div className="status-grid"> <div className="status-grid">
<div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}> <div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}>
<span className="status-label">Keypair</span> <span className="status-label">Keypair</span>
<span className="status-value">{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}</span> <span className="status-value">
{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}
</span>
</div> </div>
<div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}> <div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}>
<span className="status-label">Vault</span> <span className="status-label">Vault</span>
<span className="status-value">{vaultLocked ? 'Locked' : 'Unlocked / No password'}</span> <span className="status-value">
{vaultLocked ? 'Locked' : 'Unlocked / No password'}
</span>
</div> </div>
<div className="status-item"> <div className="status-item">
<span className="status-label">Current Mode</span> <span className="status-label">Current Mode</span>
@ -249,12 +332,20 @@ export function SignerModeScreen() {
</div> </div>
</div> </div>
{!hasProfile && ( {!hasProfile && (
<Button variant="primary" onClick={() => void handleImportKey()} style={{ marginTop: 12 }}> <Button
variant="primary"
onClick={() => void handleImportKey()}
style={{ marginTop: 12 }}
>
<Icon name="key" size={16} /> Import / Generate Key <Icon name="key" size={16} /> Import / Generate Key
</Button> </Button>
)} )}
{hasProfile && vaultLocked && ( {hasProfile && vaultLocked && (
<Button variant="secondary" onClick={() => void handleUnlockVault()} style={{ marginTop: 12 }}> <Button
variant="secondary"
onClick={() => void handleUnlockVault()}
style={{ marginTop: 12 }}
>
<Icon name="shield" size={16} /> Unlock Vault <Icon name="shield" size={16} /> Unlock Vault
</Button> </Button>
)} )}
@ -269,7 +360,9 @@ export function SignerModeScreen() {
<div className="card-body"> <div className="card-body">
<div className="mode-options"> <div className="mode-options">
{/* 1. Most Secure */} {/* 1. Most Secure */}
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}> <label
className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}
>
<input <input
type="radio" type="radio"
name="signer-mode" name="signer-mode"
@ -282,9 +375,9 @@ export function SignerModeScreen() {
<div className="mode-option-content"> <div className="mode-option-content">
<h3>NIP-46 Client (Connect to External Signer)</h3> <h3>NIP-46 Client (Connect to External Signer)</h3>
<p className="security-desc"> <p className="security-desc">
<strong>Most Secure:</strong> Private key never touches this device. Connects to an <strong>Most Secure:</strong> Private key never touches this device. Connects to
external signer (Amber, Nostr Connect, hardware wallet). Every signing request is an external signer (Amber, Nostr Connect, hardware wallet). Every signing
approved on the external device. request is approved on the external device.
</p> </p>
<ul className="mode-features"> <ul className="mode-features">
<li>✓ Private key NEVER on this device</li> <li>✓ Private key NEVER on this device</li>
@ -292,12 +385,16 @@ export function SignerModeScreen() {
<li>✓ Every request approved externally</li> <li>✓ Every request approved externally</li>
<li>✓ Key cannot be extracted if this app is compromised</li> <li>✓ Key cannot be extracted if this app is compromised</li>
</ul> </ul>
{mode === 'nip46_client' && isNip46Active && <span className="mode-badge active">Connected</span>} {mode === 'nip46_client' && isNip46Active && (
<span className="mode-badge active">Connected</span>
)}
</div> </div>
</label> </label>
{/* 2. Moderately Secure */} {/* 2. Moderately Secure */}
<label className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}> <label
className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}
>
<input <input
type="radio" type="radio"
name="signer-mode" name="signer-mode"
@ -310,8 +407,8 @@ export function SignerModeScreen() {
<div className="mode-option-content"> <div className="mode-option-content">
<h3>NIP-46 Bunker (This App Signs)</h3> <h3>NIP-46 Bunker (This App Signs)</h3>
<p className="security-desc"> <p className="security-desc">
<strong>Moderately Secure:</strong> This app acts as a signer for other clients. Key <strong>Moderately Secure:</strong> This app acts as a signer for other clients.
stays in this app&apos;s encrypted vault; other clients connect via{' '} Key stays in this app&apos;s encrypted vault; other clients connect via{' '}
<code>nostrconnect://</code>. <code>nostrconnect://</code>.
</p> </p>
<ul className="mode-features"> <ul className="mode-features">
@ -320,12 +417,16 @@ export function SignerModeScreen() {
<li>✓ Works with Amber, Nostr Connect, etc.</li> <li>✓ Works with Amber, Nostr Connect, etc.</li>
<li>⚠ Key in memory when vault unlocked</li> <li>⚠ Key in memory when vault unlocked</li>
</ul> </ul>
{mode === 'nip46_bunker' && isNip46Active && <span className="mode-badge active">Running</span>} {mode === 'nip46_bunker' && isNip46Active && (
<span className="mode-badge active">Running</span>
)}
</div> </div>
</label> </label>
{/* 3. Least Secure */} {/* 3. Least Secure */}
<label className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}> <label
className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}
>
<input <input
type="radio" type="radio"
name="signer-mode" name="signer-mode"
@ -338,8 +439,8 @@ export function SignerModeScreen() {
<div className="mode-option-content"> <div className="mode-option-content">
<h3>Embedded Signer (Local Keys)</h3> <h3>Embedded Signer (Local Keys)</h3>
<p className="security-desc"> <p className="security-desc">
<strong>Least Secure:</strong> Keys stored locally, signing on this device. Convenient <strong>Least Secure:</strong> Keys stored locally, signing on this device.
but key exists in memory when vault unlocked. Convenient but key exists in memory when vault unlocked.
</p> </p>
<ul className="mode-features"> <ul className="mode-features">
<li>✓ Keys never leave this device</li> <li>✓ Keys never leave this device</li>
@ -347,14 +448,18 @@ export function SignerModeScreen() {
<li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li> <li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li>
<li>⚠ Vulnerable to device compromise</li> <li>⚠ Vulnerable to device compromise</li>
</ul> </ul>
{mode === 'embedded' && isEmbeddedActive && <span className="mode-badge active">Active</span>} {mode === 'embedded' && isEmbeddedActive && (
<span className="mode-badge active">Active</span>
)}
</div> </div>
</label> </label>
</div> </div>
{mode === 'nip46_bunker' && !canSwitchToBunker && ( {mode === 'nip46_bunker' && !canSwitchToBunker && (
<Alert tone="warning" title="Cannot enable bunker"> <Alert tone="warning" title="Cannot enable bunker">
{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.'}
</Alert> </Alert>
)} )}
{mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && ( {mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
@ -364,7 +469,8 @@ export function SignerModeScreen() {
)} )}
{!hasProfile && mode !== 'nip46_client' && ( {!hasProfile && mode !== 'nip46_client' && (
<Alert tone="warning" title="No keypair"> <Alert tone="warning" title="No keypair">
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.)
</Alert> </Alert>
)} )}
{error && <ErrorText>{error}</ErrorText>} {error && <ErrorText>{error}</ErrorText>}
@ -379,12 +485,14 @@ export function SignerModeScreen() {
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge> <Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
</header> </header>
<div className="card-body"> <div className="card-body">
{(embeddedStatus!.pending).map((req, idx) => ( {embeddedStatus!.pending.map((req, idx) => (
<div key={req.id} className="signer-pending-item"> <div key={req.id} className="signer-pending-item">
<div className="signer-pending-info"> <div className="signer-pending-info">
<code className="mono">{req.method}</code> <code className="mono">{req.method}</code>
<p>{req.summary}</p> <p>{req.summary}</p>
{req.details?.is_sensitive && <span className="sensitive-badge">Sensitive</span>} {req.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive</span>
)}
</div> </div>
<div className="settings-inline"> <div className="settings-inline">
<Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}> <Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
@ -404,16 +512,24 @@ export function SignerModeScreen() {
{(mode === 'nip46_client' || mode === 'nip46_bunker') && ( {(mode === 'nip46_client' || mode === 'nip46_bunker') && (
<section className="card"> <section className="card">
<header className="card-header"> <header className="card-header">
<h2>{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}</h2> <h2>
{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}
</h2>
</header> </header>
<div className="card-body"> <div className="card-body">
{isNip46Active && nip46StatusState ? ( {isNip46Active && nip46StatusState ? (
<div className="signer-actions"> <div className="signer-actions">
<p className="hint"> <p className="hint">
Connected to <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code> via{' '} Connected to{' '}
{(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code>{' '}
via {nip46StatusState.connected_relays?.length ?? 0} of{' '}
{nip46StatusState.relays?.length ?? 0} relays
</p> </p>
{nip46StatusState.error && <Alert tone="error" title="Connection error">{nip46StatusState.error}</Alert>} {nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}
</Alert>
)}
<Button variant="danger" onClick={() => void handleNip46Disconnect()}> <Button variant="danger" onClick={() => void handleNip46Disconnect()}>
<Icon name="trash" size={16} /> Disconnect <Icon name="trash" size={16} /> Disconnect
</Button> </Button>
@ -427,10 +543,22 @@ export function SignerModeScreen() {
<p>{r.summary}</p> <p>{r.summary}</p>
</div> </div>
<div className="settings-inline"> <div className="settings-inline">
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}> <Button
variant="primary"
onClick={() => void handleNip46Approve(r.id, true)}
>
Approve Approve
</Button> </Button>
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}> <Button
variant="secondary"
onClick={() => void handleNip46Approve(r.id, true, true)}
>
Always allow
</Button>
<Button
variant="danger"
onClick={() => void handleNip46Approve(r.id, false)}
>
Reject Reject
</Button> </Button>
</div> </div>
@ -439,12 +567,59 @@ export function SignerModeScreen() {
</div> </div>
)} )}
</div> </div>
) : pairingUri ? (
<div className="signer-pairing">
<p>
Scan this code with <strong>Amber</strong> (or any NIP-46 signer) to connect.
</p>
{pairingQr && (
<img
src={pairingQr}
alt="Pairing QR code"
style={{
width: 260,
height: 260,
imageRendering: 'pixelated',
borderRadius: 8,
display: 'block',
margin: '12px auto',
background: '#fff',
padding: 8,
}}
/>
)}
<p className="hint" style={{ textAlign: 'center' }}>
Waiting for the signer to scan… the connection appears automatically once
approved. The code expires after a few minutes.
</p>
{nip46StatusState?.error && (
<Alert tone="error" title="Pairing failed">
{nip46StatusState.error}
</Alert>
)}
{pairError && <ErrorText>{pairError}</ErrorText>}
<div className="settings-inline" style={{ justifyContent: 'center' }}>
<Button variant="ghost" onClick={() => void handlePairCancel()}>
Cancel pairing
</Button>
</div>
<details style={{ marginTop: 12 }}>
<summary className="hint">Or copy the pairing link</summary>
<code className="mono" style={{ wordBreak: 'break-all', fontSize: 11 }}>
{pairingUri}
</code>
</details>
</div>
) : ( ) : (
<div> <div>
<div className="field"> <div className="field">
<input <input
type="text" type="text"
placeholder={mode === 'nip46_client' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'} placeholder={
mode === 'nip46_client'
? 'bunker://… or nostrconnect://… (from Amber / Nostr Connect)'
: 'nostrconnect://…'
}
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
autoComplete="off" autoComplete="off"
@ -452,17 +627,51 @@ export function SignerModeScreen() {
/> />
<p className="hint"> <p className="hint">
{mode === 'nip46_client' {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.'} : 'Share this with client apps that want to connect to this bunker.'}
</p> </p>
</div> </div>
<div className="field"> <div className="field">
<label>Label</label> <label>Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Remote Signer" /> <input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Remote Signer"
/>
</div> </div>
{error && <ErrorText>{error}</ErrorText>} {error && <ErrorText>{error}</ErrorText>}
{pairError && <ErrorText>{pairError}</ErrorText>}
{/* A failed handshake must never look like an idle form:
surface the backend's error so a timeout is visible. */}
{nip46StatusState?.error && (
<Alert tone="error" title="Connection failed">
{nip46StatusState.error}
</Alert>
)}
{/* A sent-but-unapproved connection request is in flight:
say so instead of showing a blank form. */}
{!nip46StatusState?.error && (nip46StatusState?.relays?.length ?? 0) > 0 && (
<p className="hint">
Connection request sent — approve it in Amber. This updates automatically; it
can take up to a couple of minutes on a slow network.
</p>
)}
<div className="settings-inline"> <div className="settings-inline">
<Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}> {mode === 'nip46_client' && (
<Button
variant="primary"
loading={connecting}
onClick={() => void handlePairStart()}
>
<Icon name="key" size={16} /> Show QR
</Button>
)}
<Button
variant={mode === 'nip46_client' ? 'ghost' : 'primary'}
loading={connecting}
disabled={!uri.trim()}
onClick={() => void handleNip46Connect()}
>
<Icon name="key" size={16} /> Connect <Icon name="key" size={16} /> Connect
</Button> </Button>
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}> <Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
@ -482,15 +691,16 @@ export function SignerModeScreen() {
<div className="card-body"> <div className="card-body">
<ul className="security-notes"> <ul className="security-notes">
<li> <li>
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External <strong>NIP-46 Client (Most Secure):</strong> Private key never on this device.
signer (hardware wallet / Amber) holds key. External signer (hardware wallet / Amber) holds key.
</li> </li>
<li> <li>
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve each <strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve
remote request. each remote request.
</li> </li>
<li> <li>
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when unlocked. <strong>Embedded (Least Secure):</strong> Local signing, key in memory when
unlocked.
</li> </li>
</ul> </ul>
</div> </div>

View file

@ -5,7 +5,7 @@ import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { shortHexId } from '../lib/format'; import { shortHexId } from '../lib/format';
import type { SignerStatus } from '../lib/types'; import type { SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
const EMPTY_STATUS: SignerStatus = { const EMPTY_STATUS: SignerStatus = {
@ -18,8 +18,17 @@ const EMPTY_STATUS: SignerStatus = {
}; };
export function SignerScreen() { export function SignerScreen() {
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp(); const {
state,
signerConnect,
signerDisconnect,
signerStatus,
signerApprove,
signerGrantsList,
signerGrantRevoke,
} = useApp();
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS); const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
const [grants, setGrants] = useState<SignerGrant[]>([]);
const [uri, setUri] = useState(''); const [uri, setUri] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
@ -28,6 +37,7 @@ export function SignerScreen() {
const refresh = async () => { const refresh = async () => {
try { try {
setStatus(await signerStatus()); setStatus(await signerStatus());
setGrants(await signerGrantsList());
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@ -82,10 +92,21 @@ export function SignerScreen() {
} }
}; };
const onApprove = async (id: string, approved: boolean) => { const onApprove = async (id: string, approved: boolean, always = false) => {
setError(null); setError(null);
try { try {
setStatus(await signerApprove(id, approved)); setStatus(await signerApprove(id, approved, always));
setGrants(await signerGrantsList());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onRevokeGrant = async (grant: SignerGrant) => {
setError(null);
try {
await signerGrantRevoke(grant.app_pubkey, grant.method);
setGrants(await signerGrantsList());
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
@ -200,6 +221,13 @@ export function SignerScreen() {
<Icon name="check" size={16} /> <Icon name="check" size={16} />
Approve Approve
</Button> </Button>
<Button
variant="secondary"
onClick={() => void onApprove(request.id, true, true)}
>
<Icon name="check" size={16} />
Always allow
</Button>
<Button variant="danger" onClick={() => void onApprove(request.id, false)}> <Button variant="danger" onClick={() => void onApprove(request.id, false)}>
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />
Reject Reject
@ -211,6 +239,33 @@ export function SignerScreen() {
</section> </section>
)} )}
{grants.length > 0 && (
<section className="card">
<header className="card-header">
<h2>Always-allow permissions</h2>
<Badge>{grants.length}</Badge>
</header>
<div className="card-body signer-pending">
<p className="hint">
These requests run without asking. Revoke one to go back to approving it every time.
</p>
{grants.map((grant) => (
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{grant.method}</code>
<p>for {shortHexId(grant.app_pubkey)}</p>
</div>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onRevokeGrant(grant)}>
Revoke
</Button>
</div>
</div>
))}
</div>
</section>
)}
<section className="card"> <section className="card">
<header className="card-header"> <header className="card-header">
<h2>Connect a Nostr app</h2> <h2>Connect a Nostr app</h2>

View file

@ -21,6 +21,7 @@ import type {
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
SignerGrant,
SignerMode, SignerMode,
SignerStatus, SignerStatus,
Theme, Theme,
@ -79,14 +80,17 @@ interface AppContextValue {
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>; embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
// NIP-46 client signer // NIP-46 client signer
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>; nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>; nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>; nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>; nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated) // Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>; signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>; signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>; signerStatus: () => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>; signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>;
signerGrantsList: () => Promise<SignerGrant[]>;
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
deleteProfile: (npub: string) => Promise<AppState>; deleteProfile: (npub: string) => Promise<AppState>;
undoDelete: () => Promise<AppState>; undoDelete: () => Promise<AppState>;
clearLastDeleted: () => void; clearLastDeleted: () => void;
@ -103,7 +107,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const fresh = await api.getState(); 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(() => { 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( const createProfile = useCallback(
async (label: string): Promise<ProfileSummary> => { async (label: string): Promise<ProfileSummary> => {
const result = await api.createProfile(label, state?.settings); const result = await api.createProfile(label, state?.settings);
@ -264,8 +290,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
); );
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []); const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46Status = useCallback(() => api.nip46Status(), []); const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback( 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], [applyState],
); );
const exportSecretKey = useCallback( const exportSecretKey = useCallback(
(npub: string, password: string, reason: string) => (npub: string, password: string, reason: string) => api.exportSecretKey(npub, password, reason),
api.exportSecretKey(npub, password, reason),
[], [],
); );
const pickImages = useCallback(() => api.pickImages(), []); const pickImages = useCallback(() => api.pickImages(), []);
@ -351,6 +383,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
embeddedSignerStatus, embeddedSignerStatus,
embeddedSignerApprove, embeddedSignerApprove,
nip46Connect, nip46Connect,
nip46PairStart,
nip46Disconnect, nip46Disconnect,
nip46Status, nip46Status,
nip46Approve, nip46Approve,
@ -358,6 +391,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerDisconnect, signerDisconnect,
signerStatus, signerStatus,
signerApprove, signerApprove,
signerGrantsList,
signerGrantRevoke,
deleteProfile, deleteProfile,
undoDelete, undoDelete,
publishProfileMetadata, publishProfileMetadata,
@ -408,6 +443,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
embeddedSignerStatus, embeddedSignerStatus,
embeddedSignerApprove, embeddedSignerApprove,
nip46Connect, nip46Connect,
nip46PairStart,
nip46Disconnect, nip46Disconnect,
nip46Status, nip46Status,
nip46Approve, nip46Approve,
@ -415,6 +451,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerDisconnect, signerDisconnect,
signerStatus, signerStatus,
signerApprove, signerApprove,
signerGrantsList,
signerGrantRevoke,
copyText, copyText,
], ],
); );

View file

@ -1402,6 +1402,18 @@ select {
margin-top: 8px; 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 Home
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */

View file

@ -16,13 +16,13 @@ describe('App', () => {
render(<App />); render(<App />);
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); 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(); expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Create your first profile/i })); await user.click(screen.getByRole('button', { name: /Create a new profile/i }));
expect( expect(await screen.findByRole('dialog', { name: 'Add a profile' })).toBeInTheDocument();
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
).toBeInTheDocument();
}); });
it('renders the main screen after loading with an existing profile', async () => { it('renders the main screen after loading with an existing profile', async () => {

View file

@ -5,14 +5,39 @@ import { renderWithApp } from './render';
import { makeEmptyState } from './apiMock'; import { makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend'; import { createFakeBackend, installFakeBackend } from './fakeBackend';
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
}));
async function startPairingFlow(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
}
describe('CreateProfileModal', () => { 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(<CreateProfileModal open onClose={vi.fn()} />);
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()); const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend); installFakeBackend(backend);
const onClose = vi.fn(); const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />); renderWithApp(<CreateProfileModal open onClose={onClose} />);
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'); const input = screen.getByLabelText('Profile name');
await userEvent.setup().type(input, 'Sam'); await userEvent.setup().type(input, 'Sam');
@ -36,20 +61,85 @@ describe('CreateProfileModal', () => {
installFakeBackend(backend); installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />); renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
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(); 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()); const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend); installFakeBackend(backend);
const onClose = vi.fn(); const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />); renderWithApp(<CreateProfileModal open onClose={onClose} />);
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().type(screen.getByLabelText('Profile name'), 'Sam');
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' })); await userEvent.setup().click(screen.getByRole('button', { name: 'Back' }));
// Back returns to the choice step; nothing was created yet.
expect(onClose).toHaveBeenCalled();
expect(backend.state.profiles).toHaveLength(0); 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(<CreateProfileModal open onClose={vi.fn()} />);
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(<CreateProfileModal open onClose={vi.fn()} />);
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(<CreateProfileModal open onClose={vi.fn()} />);
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();
}); });
}); });

View file

@ -146,7 +146,9 @@ describe('exporting a secret key', () => {
// Reopen — fields should be empty // Reopen — fields should be empty
const dialog2 = await openExport(user); const dialog2 = await openExport(user);
expect((within(dialog2).getByLabelText('Vault password') as HTMLInputElement).value).toBe(''); 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 () => { 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 user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => { await waitFor(() => {
expect( expect(within(dialog).getByText(/not stored on this computer/)).toBeInTheDocument();
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 user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => { await waitFor(() => {
expect( expect(within(dialog).getByText(/external signer/i)).toBeInTheDocument();
within(dialog).getByText(/external signer/i),
).toBeInTheDocument();
}); });
}); });

View file

@ -22,6 +22,22 @@ describe('FeedScreen', () => {
expect(screen.getByText('2 relays')).toBeInTheDocument(); 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(<FeedScreen onNavigate={vi.fn()} />);
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 () => { it('disable relays shows an empty state that can navigate to relays', async () => {
const settings = { const settings = {
theme: 'light' as const, theme: 'light' as const,

View file

@ -7,13 +7,18 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend';
function renderHome( function renderHome(
backend: ReturnType<typeof createFakeBackend>, backend: ReturnType<typeof createFakeBackend>,
overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {}, overrides: {
onNavigate?: () => void;
onCreateProfile?: () => void;
onImportProfile?: () => void;
} = {},
) { ) {
installFakeBackend(backend); installFakeBackend(backend);
return { return {
user: userEvent.setup(), user: userEvent.setup(),
onNavigate: overrides.onNavigate ?? vi.fn(), onNavigate: overrides.onNavigate ?? vi.fn(),
onCreateProfile: overrides.onCreateProfile ?? 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 () => { it('shows the active profile, a shortened npub, and a compose button', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
const { onNavigate } = renderHome(backend); const { onNavigate } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
// The active profile appears in the profile list with its shortened npub. // The active profile appears in the profile list with its shortened npub.
const profileList = await screen.findByRole('listbox'); const profileList = await screen.findByRole('listbox');
@ -36,7 +43,9 @@ describe('HomeScreen', () => {
it('copies the complete npub when the copy button is clicked', async () => { it('copies the complete npub when the copy button is clicked', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
renderHome(backend); renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
// The active profile row carries the "Selected" badge; find Alice via the profile list. // The active profile row carries the "Selected" badge; find Alice via the profile list.
const profileList = await screen.findByRole('listbox'); 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 () => { it('shows the first-run state and guides the user to create a profile', async () => {
const backend = createFakeBackend(makeEmptyState()); const backend = createFakeBackend(makeEmptyState());
const { onCreateProfile } = renderHome(backend); const { onCreateProfile, onImportProfile } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />); renderWithApp(
<HomeScreen
onNavigate={vi.fn()}
onCreateProfile={onCreateProfile}
onImportProfile={onImportProfile}
/>,
);
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); 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 await userEvent
.setup() .setup()
.click(screen.getByRole('button', { name: /Create your first profile/i })); .click(screen.getByRole('button', { name: /I already have an account/i }));
expect(onCreateProfile).toHaveBeenCalled(); expect(onImportProfile).toHaveBeenCalled();
}); });
it('selects a profile when its row is clicked (not just the Select button)', async () => { it('selects a profile when its row is clicked (not just the Select button)', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
renderHome(backend); renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement; const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement;
// Clicking the name (not the Select button) should select the profile. // 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; const aliceRow = screen.getByText('Alice').closest('.home-profile-row') as HTMLElement;
expect(within(aliceRow).getByRole('button', { name: 'Select' })).toBeInTheDocument(); 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(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
// 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();
}
});
}); });

View file

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

View file

@ -2,10 +2,12 @@ import type {
AppState, AppState,
BackendResponse, BackendResponse,
FeedItem, FeedItem,
Nip46SignerStatus,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
Settings, Settings,
SignerGrant,
SignerStatus, SignerStatus,
UpdateApplyReport, UpdateApplyReport,
UpdateCheckReport, UpdateCheckReport,
@ -41,6 +43,11 @@ export interface FakeBackend {
/** Current NIP-46 signer status. */ /** Current NIP-46 signer status. */
signer: SignerStatus; signer: SignerStatus;
setSigner: (next: SignerStatus) => void; setSigner: (next: SignerStatus) => void;
/** NIP-46 client handshake status backing the nip46_* methods. */
nip46: Nip46SignerStatus;
setNip46: (next: Nip46SignerStatus) => void;
/** Standing "always allow" grants returned by signer_grants_list. */
signerGrants: SignerGrant[];
/** Notes returned by `feed_get`. */ /** Notes returned by `feed_get`. */
feedItems: FeedItem[]; feedItems: FeedItem[];
/** Notes returned by `feed_get` with `contacts_only: true`. */ /** Notes returned by `feed_get` with `contacts_only: true`. */
@ -106,6 +113,17 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
setSigner(next) { setSigner(next) {
backend.signer = next; backend.signer = next;
}, },
nip46: {
type: 'nip46',
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
},
setNip46(next) {
backend.nip46 = next;
},
signerGrants: [],
feedItems: [ feedItems: [
{ {
id: 'note1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', id: 'note1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
@ -175,7 +193,41 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
switch (method) { switch (method) {
case 'init': case 'init':
case 'get_state': 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': { case 'create_profile': {
const label = String(params.label ?? ''); const label = String(params.label ?? '');
@ -344,14 +396,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'signer_approve': { case 'signer_approve': {
const id = String(params.id ?? ''); const id = String(params.id ?? '');
const entry = backend.signer.pending.find((request) => request.id === id);
const next: SignerStatus = { const next: SignerStatus = {
...backend.signer, ...backend.signer,
pending: backend.signer.pending.filter((request) => request.id !== id), pending: backend.signer.pending.filter((request) => request.id !== id),
}; };
backend.setSigner(next); 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; 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': { case 'relay_add': {
const url = String(params.url); const url = String(params.url);
const nextSettings: Settings = { const nextSettings: Settings = {
@ -445,10 +519,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
// Check profile exists first // Check profile exists first
const profile = state.profiles.find((p) => p.npub === npub); const profile = state.profiles.find((p) => p.npub === npub);
if (!profile) { if (!profile) {
throw Object.assign( throw Object.assign(new Error('That profile is not stored on this computer.'), {
new Error('That profile is not stored on this computer.'), code: 'profile_not_found',
{ code: 'profile_not_found' }, });
);
} }
// External signer profiles cannot export secret keys // External signer profiles cannot export secret keys
@ -461,24 +534,19 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
if (state.encrypted_storage) { if (state.encrypted_storage) {
if (!password) { if (!password) {
throw Object.assign( throw Object.assign(new Error('Password required to export secret key.'), {
new Error('Password required to export secret key.'), code: 'wrong_password',
{ code: 'wrong_password' }, });
);
} }
// Fake password check: accept "test" or "password" // Fake password check: accept "test" or "password"
if (password !== 'test' && password !== 'password') { if (password !== 'test' && password !== 'password') {
throw Object.assign( throw Object.assign(new Error('Wrong password.'), { code: 'wrong_password' });
new Error('Wrong password.'),
{ code: 'wrong_password' },
);
} }
} }
if (!reason) { if (!reason) {
throw Object.assign( throw Object.assign(new Error('A reason is required for key export.'), {
new Error('A reason is required for key export.'), code: 'config',
{ code: 'config' }, });
);
} }
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64); const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
return { hex, nsec: `nsec1${npub.slice(5)}` }; return { hex, nsec: `nsec1${npub.slice(5)}` };

View file

@ -23,7 +23,9 @@ function makeItem(overrides: Partial<FeedItem> & { id: string; relays: string[]
function renderHome(backend: ReturnType<typeof createFakeBackend>) { function renderHome(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend); installFakeBackend(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />); renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
} }
async function waitForData() { async function waitForData() {

View file

@ -9,9 +9,12 @@ use crate::crypto::{self, VaultKey};
use crate::errors::AppError; use crate::errors::AppError;
use crate::profiles::{self, ProfileSummary}; use crate::profiles::{self, ProfileSummary};
use crate::settings::Settings; use crate::settings::Settings;
use crate::signer::Signer as SignerTrait;
use crate::signer::Signing;
use crate::vault::{ use crate::vault::{
self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto, self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto,
}; };
use nostr_sdk::prelude::{Keys, PublicKey};
/// Minimum password length accepted when encrypting the vault. /// Minimum password length accepted when encrypting the vault.
pub const MIN_PASSWORD_LEN: usize = 8; pub const MIN_PASSWORD_LEN: usize = 8;
@ -22,8 +25,10 @@ pub struct App {
pub settings: Settings, pub settings: Settings,
/// Derived vault key, present only while the encrypted vault is unlocked. /// Derived vault key, present only while the encrypted vault is unlocked.
unlock_key: Option<VaultKey>, unlock_key: Option<VaultKey>,
/// Stack of deleted profiles for undo functionality. /// Stack of deleted profiles for undo functionality. Holds the full
pub undo_history: Vec<ProfileSummary>, /// 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<crate::profiles::DeletedProfile>,
/// The most recent publish report, persisted across restarts. /// The most recent publish report, persisted across restarts.
pub last_publish: Option<StoredPublishReport>, pub last_publish: Option<StoredPublishReport>,
/// Active signer mode. /// Active signer mode.
@ -60,7 +65,8 @@ pub struct AppStateView {
pub active_profile: Option<ProfileSummary>, pub active_profile: Option<ProfileSummary>,
pub profiles: Vec<ProfileSummary>, pub profiles: Vec<ProfileSummary>,
pub settings: Settings, 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")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub undo_history: Vec<ProfileSummary>, pub undo_history: Vec<ProfileSummary>,
/// The most recent publish report, persisted across restarts. /// The most recent publish report, persisted across restarts.
@ -113,6 +119,56 @@ impl App {
self.vault.is_encrypted() && self.unlock_key.is_none() 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<Signing, AppError> {
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<Signing, AppError> {
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. /// Verify a password and keep the derived key in memory for the session.
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> { pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
let crypto = self let crypto = self
@ -215,36 +271,45 @@ impl App {
Ok(revealed) 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. /// Returns the restored profile summary, or an error if there is no undo history.
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> { pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
if self.undo_history.is_empty() { let Some(deleted) = self.undo_history.pop() else {
return Err(AppError::config("No profile deletions to undo.")); return Err(AppError::config("No profile deletions to undo."));
} };
let restored = self.undo_history.pop().unwrap(); let restored = deleted.stored.clone();
// Re-add the profile to the vault // Re-add the profile to the vault unless it is somehow already there.
if !self if !self
.vault .vault
.profiles .profiles
.iter() .iter()
.any(|p| p.public_key == restored.npub) .any(|p| p.public_key == restored.public_key)
{ {
let stored = StoredProfile { // 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());
}
let is_active = self.vault.active_profile.as_deref() == Some(restored.public_key.as_str());
Ok(ProfileSummary {
label: restored.label.clone(), label: restored.label.clone(),
public_key: restored.npub.clone(), npub: restored.public_key.clone(),
secret_key: "".to_string(),
created_at: restored.created_at, created_at: restored.created_at,
is_active,
picture: restored.picture.clone(), picture: restored.picture.clone(),
nip05: restored.nip05.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());
}
}
Ok(restored)
} }
/// Protect the vault with `new_password`, re-encrypting every stored key. /// Protect the vault with `new_password`, re-encrypting every stored key.
@ -361,7 +426,11 @@ impl App {
active_profile: profiles::active_summary(&self.vault), active_profile: profiles::active_summary(&self.vault),
profiles: profiles::summaries(&self.vault), profiles: profiles::summaries(&self.vault),
settings: self.settings.clone(), 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(), last_publish: self.last_publish.clone(),
signer_mode: self.signer_mode, 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] #[test]
fn set_password_encrypts_every_secret() { fn set_password_encrypts_every_secret() {
let mut app = sample_app(); let mut app = sample_app();
@ -622,4 +755,32 @@ mod tests {
assert_eq!(view.profiles.len(), 2); assert_eq!(view.profiles.len(), 2);
assert!(view.profiles.iter().all(|p| p.npub.starts_with("npub1"))); 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());
}
} }

View file

@ -750,10 +750,21 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
Ok(request) => request, Ok(request) => request,
Err(_) => continue, Err(_) => continue,
}; };
// Key-using methods wait for an explicit user approval before they run; // Key-using methods wait for an explicit user approval before they
// everything else is answered immediately. // 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) { let response = if requires_approval(&request.method) {
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 gated_response(&signer, &keys, &request).await
}
} else { } else {
handle_request(&signer, &keys, &uri, &request) handle_request(&signer, &keys, &uri, &request)
}; };

View file

@ -44,6 +44,11 @@ pub struct FeedItem {
pub author: String, pub author: String,
/// Bech32 `npub` of the author, for display. /// Bech32 `npub` of the author, for display.
pub author_npub: String, 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<String>,
/// Author picture URL from their latest kind-0, when one was found.
pub author_picture: Option<String>,
pub content: String, pub content: String,
/// Unix timestamp the note was created. /// Unix timestamp the note was created.
pub created_at: u64, pub created_at: u64,
@ -181,7 +186,96 @@ async fn aggregate_for(
} }
client.disconnect().await; 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<PublicKey> = {
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<I>(items: &mut [FeedItem], events: I)
where
I: Iterator<Item = Event>,
{
use std::collections::HashMap;
let mut best: HashMap<String, &Event> = HashMap::new();
let mut order: Vec<Event> = 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::<Metadata>(&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. /// URLs of every enabled relay.
@ -256,6 +350,8 @@ impl FeedItem {
id, id,
author: event.pubkey.to_hex(), author: event.pubkey.to_hex(),
author_npub, author_npub,
author_name: None,
author_picture: None,
content: event.content.trim().to_string(), content: event.content.trim().to_string(),
created_at: event.created_at.as_secs(), created_at: event.created_at.as_secs(),
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(), relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
@ -300,6 +396,8 @@ mod tests {
id, id,
author: "a".into(), author: "a".into(),
author_npub: "npub1a".into(), author_npub: "npub1a".into(),
author_name: None,
author_picture: None,
content: "c".into(), content: "c".into(),
created_at: created, created_at: created,
relays: vec![], relays: vec![],
@ -335,6 +433,68 @@ mod tests {
assert!(builder.items.is_empty()); 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] #[tokio::test]
async fn limit_stops_collection_when_full() { async fn limit_stops_collection_when_full() {
let mut builder = FeedBuilder::new(2, None); let mut builder = FeedBuilder::new(2, None);

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::app::App; use crate::app::{App, Nip46ClientSignerHandle};
use crate::errors::AppError; use crate::errors::AppError;
use crate::feed; use crate::feed;
use crate::profiles; use crate::profiles;
@ -165,14 +165,24 @@ pub enum Request {
uri: String, uri: String,
label: 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. /// Disconnect from the NIP-46 signer.
Nip46Disconnect, Nip46Disconnect,
/// Get NIP-46 connection status. /// Get NIP-46 connection status.
Nip46Status, 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 { Nip46Approve {
id: String, id: String,
approved: bool, approved: bool,
#[serde(default)]
always: bool,
}, },
/// ===== LEGACY NIP-46 BUNKER (server mode) ===== /// ===== LEGACY NIP-46 BUNKER (server mode) =====
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). /// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
@ -190,6 +200,18 @@ pub enum Request {
id: String, id: String,
/// `true` to run the request, `false` to reject it. /// `true` to run the request, `false` to reject it.
approved: bool, 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 { DeleteProfile {
npub: String, 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 // 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. // both see the same vault (including its unlock key) without racing writes.
let app = Arc::new(Mutex::new(App::load()?)); 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 stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
let stdin = tokio::io::stdin(); let stdin = tokio::io::stdin();
@ -331,6 +378,46 @@ fn error_code(err: &AppError) -> String {
.unwrap_or_else(|_| "error".to_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<Mutex<App>>) -> Option<Nip46ClientSignerHandle> {
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::<Vec<_>>(),
})
}
/// Main request dispatcher. /// Main request dispatcher.
async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> { async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
match request { match request {
@ -396,44 +483,58 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
// NIP-46 client signer // NIP-46 client signer
Request::Nip46Connect { uri, label } => { Request::Nip46Connect { uri, label } => {
let guard = app.lock().await; // Take the signer handle (initializing it if needed), then drop
if let Some(signer) = &guard.nip46_signer { // 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?; let status = signer.connect(&uri, label).await?;
Ok(json!(status)) Ok(json!(status))
} else {
Err(AppError::config(
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
))
} }
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 => { Request::Nip46Disconnect => {
let guard = app.lock().await; let Some(signer) = ensure_nip46_signer(app).await else {
if let Some(signer) = &guard.nip46_signer { return Err(AppError::config("NIP-46 signer not initialized"));
};
signer.disconnect().await?; signer.disconnect().await?;
let status = signer.status().await; let status = signer.status().await;
Ok(json!(status)) Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
} }
Request::Nip46Status => { Request::Nip46Status => {
let guard = app.lock().await; let Some(signer) = ensure_nip46_signer(app).await else {
if let Some(signer) = &guard.nip46_signer { return Ok(json!({ "connected": false, "error": "Not initialized" }));
};
let status = signer.status().await; let status = signer.status().await;
Ok(json!(status)) Ok(json!(status))
} else {
Ok(json!({ "connected": false, "error": "Not initialized" }))
} }
} Request::Nip46Approve {
Request::Nip46Approve { id, approved } => { id,
let guard = app.lock().await; approved,
if let Some(signer) = &guard.nip46_signer { always,
signer.respond_to_approval(&id, approved).await?; } => {
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; let status = signer.status().await;
Ok(json!(status)) Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
} }
// Legacy NIP-46 bunker (server mode) // Legacy NIP-46 bunker (server mode)
@ -442,7 +543,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?; let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
return Ok(json!(status)); return Ok(nip46_status_as_bunker_json(&status));
} }
} }
Err(AppError::config( Err(AppError::config(
@ -455,7 +556,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?; signer.disconnect().await?;
let status = signer.status().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")) Err(AppError::config("Not in NIP-46 client mode"))
@ -465,22 +566,43 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
let status = signer.status().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")) Err(AppError::config("Not in NIP-46 client mode"))
} }
Request::SignerApprove { id, approved } => { Request::SignerApprove {
id,
approved,
always,
} => {
let guard = app.lock().await; let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { 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; 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")) 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) // Network-only requests (no shared state lock)
Request::RelayTest { url } => { Request::RelayTest { url } => {
@ -605,9 +727,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
} }
Request::PublishProfileMetadata { npub } => { Request::PublishProfileMetadata { npub } => {
let key = app.vault_key().copied(); // Route through the profile's Signing source, exactly like
let report = // PublishNote: an embedded profile signs locally, a paired
profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?; // 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)) Ok(json!(report))
} }
@ -646,9 +782,17 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
} }
Request::PublishNote { content } => { Request::PublishNote { content } => {
let report = // Signer selection lives in App::signing_for: an embedded profile
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key()) // signs with the vault key; an external (NIP-46) profile's note
.await?; // 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 { let stored = crate::vault::StoredPublishReport {
event_id: report.event_id.clone(), event_id: report.event_id.clone(),
succeeded: report.succeeded.clone(), succeeded: report.succeeded.clone(),
@ -710,6 +854,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
if let Some(npub) = &app.vault.active_profile { if let Some(npub) = &app.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await; signer.set_active_profile(Some(npub.clone())).await;
} }
// The vault key is now in memory: the stored NIP-46
// client keys decrypt, so a saved signer session can be
// re-dialed without a fresh scan (spec: session restore).
if let Err(e) = signer.reactivate_saved_sessions().await {
eprintln!("[NIP46] session restore after unlock failed: {e}");
}
} }
} }
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
@ -765,13 +915,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
} }
Request::UploadAuth { url, http_method } => { Request::UploadAuth { url, http_method } => {
let authorization = crate::uploads::nip98_authorization( let signing = app.signing_active().await?;
&app.vault, let authorization =
&url, crate::uploads::nip98_authorization(&url, &http_method, &signing).await?;
&http_method,
app.vault_key(),
)
.await?;
Ok(json!({ "authorization": authorization })) Ok(json!({ "authorization": authorization }))
} }
@ -793,9 +939,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!(app.settings)) Ok(json!(app.settings))
} }
Request::DeleteProfile { npub } => { Request::DeleteProfile { npub } => {
let deleted = profiles::delete_profile(&mut app.vault, &npub)?; let deleted = profiles::delete_profile_record(&mut app.vault, &npub)?;
app.save_vault()?; app.save_vault()?;
app.undo_history.push(deleted.clone()); app.undo_history.push(deleted);
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
} }
Request::UndoDelete => { Request::UndoDelete => {

View file

@ -5,11 +5,11 @@ use keynectr::app::App;
use keynectr::bunker::Signer; use keynectr::bunker::Signer;
use keynectr::errors::{AppError, ErrorKind}; use keynectr::errors::{AppError, ErrorKind};
use keynectr::ipc; use keynectr::ipc;
use keynectr::profiles::{self, ProfileSummary}; use keynectr::profiles;
use keynectr::publish; use keynectr::publish;
use keynectr::relays; use keynectr::relays;
use keynectr::settings::Theme; use keynectr::settings::Theme;
use keynectr::vault::{self, StoredProfile, Vault}; use keynectr::vault::{self, Vault};
const USAGE: &str = "\ const USAGE: &str = "\
keynectr <command> [args...] keynectr <command> [args...]
@ -629,26 +629,13 @@ fn cli_info() -> Result<String, AppError> {
} }
/// Delete a profile by npub, moving it to the undo stack. /// Delete a profile by npub, moving it to the undo stack.
/// Returns the deleted profile summary, or an error if not found. /// Returns the full deleted record so the CLI can report it and push the
fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> { /// same entry the IPC path uses.
let pos = vault fn delete_profile_direct(
.profiles vault: &mut Vault,
.iter() npub: &str,
.position(|p| p.public_key == npub) ) -> Result<profiles::DeletedProfile, AppError> {
.ok_or_else(|| AppError::profile_not_found(npub))?; profiles::delete_profile_record(vault, 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,
})
} }
fn cli_delete_profile(args: &[String]) -> Result<String, AppError> { fn cli_delete_profile(args: &[String]) -> Result<String, AppError> {
@ -657,37 +644,22 @@ fn cli_delete_profile(args: &[String]) -> Result<String, AppError> {
} }
let npub = args[2].clone(); let npub = args[2].clone();
let mut app = App::load()?; 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)?; let deleted = delete_profile_direct(&mut app.vault, &npub)?;
app.save_vault()?; app.save_vault()?;
// Add to undo history // Add to undo history (full record: undo restores a working profile).
app.undo_history.push(deleted.clone()); app.undo_history.push(deleted);
Ok(format!( Ok(format!(
"Profile '{}' deleted (npub: {}). Use 'undo-delete' to restore.", "Profile '{label}' deleted (npub: {npub}). Use 'undo-delete' to restore."
profiles::profile_label(&app.vault, &npub).unwrap_or(&npub),
npub
)) ))
} }
fn cli_undo_delete() -> Result<String, AppError> { fn cli_undo_delete() -> Result<String, AppError> {
let mut app = App::load()?; let mut app = App::load()?;
if app.undo_history.is_empty() { let restored = app.undo_delete()?;
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());
}
app.save_vault()?; app.save_vault()?;
Ok(format!( Ok(format!(
"Profile '{}' restored from undo stack.", "Profile '{}' restored from undo stack.",

View file

@ -9,7 +9,7 @@ use crate::errors::AppError;
use crate::publish::RelayFailure; use crate::publish::RelayFailure;
use crate::relays; use crate::relays;
use crate::settings::Settings; 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. /// A safe view of a profile that contains no secret key material.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@ -190,11 +190,68 @@ pub struct MetadataPublishReport {
pub failed: Vec<RelayFailure>, pub failed: Vec<RelayFailure>,
} }
/// 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<String>,
nip05: Option<String>,
signing: &crate::signer::Signing,
) -> Result<MetadataPublishReport, AppError> {
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 /// Publish a profile's stored label (and picture, when set) as kind 0 metadata
/// so external clients (Iris, Yakihonne, ...) display its name. Returns a /// so external clients (Iris, Yakihonne, ...) display its name. Returns a
/// per-relay report. /// per-relay report.
/// ///
/// `key` must be the unlocked vault key when the vault is password-protected. /// `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( pub fn publish_profile_metadata(
vault: &Vault, vault: &Vault,
npub: &str, npub: &str,
@ -298,6 +355,39 @@ pub fn rename_profile(
return Err(AppError::config("The profile name cannot be empty.")); 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 // Resolve and sign before mutating so a locked vault or bad key changes
// nothing on disk. // nothing on disk.
let secret_hex = resolve_secret_key(vault, npub, key)?; 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)) .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<ProfileSummary, AppError> {
// 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> { fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
vault vault
.profiles .profiles
@ -509,7 +651,7 @@ fn publish_metadata_blocking(
/// Best-effort lookup of the account's latest kind-0 metadata. Import must /// Best-effort lookup of the account's latest kind-0 metadata. Import must
/// still succeed when relays are unavailable, so lookup failures are ignored. /// still succeed when relays are unavailable, so lookup failures are ignored.
fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option<Metadata> { pub fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option<Metadata> {
if relay_urls.is_empty() { if relay_urls.is_empty() {
return None; return None;
} }
@ -745,9 +887,19 @@ pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}"))) SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}")))
} }
/// Delete a profile by npub, returning the deleted profile for undo. /// A profile removed from the vault together with everything needed to put it
/// The vault must not be encrypted, or the key must be provided. /// back: the safe summary for the UI *and* the full `StoredProfile` including
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> { /// 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<DeletedProfile, AppError> {
let pos = vault let pos = vault
.profiles .profiles
.iter() .iter()
@ -757,14 +909,22 @@ pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, A
if vault.active_profile.as_deref() == Some(npub) { if vault.active_profile.as_deref() == Some(npub) {
vault.active_profile = None; vault.active_profile = None;
} }
Ok(ProfileSummary { let summary = ProfileSummary {
label: stored.label, label: stored.label.clone(),
npub: stored.public_key, npub: stored.public_key.clone(),
created_at: stored.created_at, created_at: stored.created_at,
is_active: false, is_active: false,
picture: stored.picture, picture: stored.picture.clone(),
nip05: stored.nip05, nip05: stored.nip05.clone(),
}) };
Ok(DeletedProfile { summary, stored })
}
/// Delete a profile by npub, returning only the safe summary. The secret key
/// is still recoverable in the returned value's vault removal only via
/// [`delete_profile_record`]; prefer that wherever an undo entry is kept.
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
delete_profile_record(vault, npub).map(|deleted| deleted.summary)
} }
#[cfg(test)] #[cfg(test)]
@ -1160,6 +1320,34 @@ mod tests {
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); 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] #[test]
fn set_nip05_stores_identifier_and_skips_publish_without_relays() { fn set_nip05_stores_identifier_and_skips_publish_without_relays() {
let mut vault = Vault::empty(); let mut vault = Vault::empty();

View file

@ -48,6 +48,9 @@ impl PublishReport {
/// Publish a text note with the active profile. /// Publish a text note with the active profile.
/// ///
/// `key` must be the unlocked vault key when the vault is password-protected. /// `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( pub async fn publish_active(
vault: &Vault, vault: &Vault,
settings: &Settings, settings: &Settings,
@ -61,6 +64,17 @@ pub async fn publish_active(
publish_with_keys(settings, content, &signing).await 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<PublishReport, AppError> {
validate_content(content)?;
publish_with_keys(settings, content, signing).await
}
/// Publish a text note as a specific profile (used by the CLI). /// 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. /// `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()); return Err(AppError::no_enabled_relays());
} }
// Extract &Keys from Signing::Local for EventBuilder operations. // The pubkey comes from the Signing itself. For an external signer this
// Currently Signing::Local is used from publish_active/publish_as, // performs identity validation first: a signer that does not control the
// but the pattern supports External signers in the future. // active profile's key fails here, before any event is built.
let keys = match signing { let pubkey = signing.pubkey().await.map_err(AppError::from)?;
Signing::Local(k) => k,
Signing::External {
signer: _,
profile_pubkey: _,
} => {
return Err(AppError::sign_failed(
"External signer not yet supported in publish_with_keys",
));
}
};
// 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 builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
let unsigned = builder let unsigned = builder.finalize_unsigned(pubkey);
.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 signed = signing let signed = signing
.sign(unsigned.into()) .sign(unsigned)
.await .await
.map_err(|e| AppError::sign_failed(format!("{e}")))?; .map_err(|e| AppError::sign_failed(format!("{e}")))?;
@ -199,7 +199,13 @@ async fn publish_with_keys(
.to_bech32() .to_bech32()
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?; .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; let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &signed, "note").await;
if succeeded.is_empty() { if succeeded.is_empty() {

View file

@ -14,6 +14,43 @@ pub fn default_relays() -> Vec<RelayConfig> {
] ]
} }
/// 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<String> {
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. /// Validate that a string is a well-formed relay URL.
pub fn validate_url(raw: &str) -> Result<(), AppError> { pub fn validate_url(raw: &str) -> Result<(), AppError> {
let cleaned = raw.trim().trim_end_matches('/'); let cleaned = raw.trim().trim_end_matches('/');
@ -78,12 +115,36 @@ pub(crate) async fn open_pool(
keys: Keys, keys: Keys,
relay_urls: &[String], relay_urls: &[String],
wait: Option<Duration>, wait: Option<Duration>,
) -> Result<Client, AppError> {
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<Duration>,
) -> Result<Client, AppError> {
open_pool_inner(None, relay_urls, wait).await
}
async fn open_pool_inner(
keys: Option<Keys>,
relay_urls: &[String],
wait: Option<Duration>,
) -> Result<Client, AppError> { ) -> Result<Client, AppError> {
// The authenticator answers NIP-42 AUTH challenges automatically on every // The authenticator answers NIP-42 AUTH challenges automatically on every
// path that opens a client (nostr-sdk >= 0.45 has no implicit signer). // path that opens a client (nostr-sdk >= 0.45 has no implicit signer).
let client = Client::builder() let builder = match keys {
.authenticator(SignerAuthenticator::new(keys)) Some(keys) => Client::builder().authenticator(SignerAuthenticator::new(keys)),
.build(); None => Client::builder(),
};
let client = builder.build();
for url in relay_urls { for url in relay_urls {
client client
.add_relay(url.as_str()) .add_relay(url.as_str())
@ -156,6 +217,25 @@ mod tests {
assert!(relays.iter().all(|r| r.enabled)); 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] #[test]
fn validate_url_accepts_wss_and_ws() { fn validate_url_accepts_wss_and_ws() {
assert!(validate_url("wss://relay.example.com").is_ok()); assert!(validate_url("wss://relay.example.com").is_ok());

View file

@ -79,7 +79,7 @@ pub trait Signer: Send + Sync {
/// ///
/// Returns an owned value because the NIP-46 client must lock an async /// Returns an owned value because the NIP-46 client must lock an async
/// mutex internally. /// mutex internally.
fn permissions(&self) -> Option<permissions::Nip46Permissions> { async fn permissions(&self) -> Option<permissions::Nip46Permissions> {
None None
} }
@ -88,40 +88,40 @@ pub trait Signer: Send + Sync {
/// The default implementation returns `true` when there are no /// The default implementation returns `true` when there are no
/// permissions (local signers) and `false` when permissions exist but /// permissions (local signers) and `false` when permissions exist but
/// do not allow the operation. /// do not allow the operation.
fn can_sign_event(&self, kind: u16) -> bool { async fn can_sign_event(&self, kind: u16) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_sign_event_kind_allowed(kind), Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
None => true, None => true,
} }
} }
/// Whether `nip44_encrypt` is permitted. /// Whether `nip44_encrypt` is permitted.
fn can_encrypt(&self) -> bool { async fn can_encrypt(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_encrypt_allowed(), Some(ref perms) => perms.is_encrypt_allowed(),
None => true, None => true,
} }
} }
/// Whether `nip44_decrypt` is permitted. /// Whether `nip44_decrypt` is permitted.
fn can_decrypt(&self) -> bool { async fn can_decrypt(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_decrypt_allowed(), Some(ref perms) => perms.is_decrypt_allowed(),
None => true, None => true,
} }
} }
/// Whether `get_public_key` is permitted. /// Whether `get_public_key` is permitted.
fn can_get_public_key(&self) -> bool { async fn can_get_public_key(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_get_public_key_allowed(), Some(ref perms) => perms.is_get_public_key_allowed(),
None => true, None => true,
} }
} }
/// Whether `get_relays` is permitted. /// Whether `get_relays` is permitted.
fn can_get_relays(&self) -> bool { async fn can_get_relays(&self) -> bool {
match self.permissions() { match self.permissions().await {
Some(ref perms) => perms.is_get_relays_allowed(), Some(ref perms) => perms.is_get_relays_allowed(),
None => true, None => true,
} }
@ -130,7 +130,7 @@ pub trait Signer: Send + Sync {
/// Whether the connection is currently valid (not expired, not revoked). /// Whether the connection is currently valid (not expired, not revoked).
/// ///
/// Local signers always return `true`. /// Local signers always return `true`.
fn is_connection_valid(&self) -> bool { async fn is_connection_valid(&self) -> bool {
true true
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -93,6 +93,11 @@ pub struct Nip46Status {
pub connected_relays: Vec<String>, pub connected_relays: Vec<String>,
pub error: Option<String>, pub error: Option<String>,
pub pending_approvals: Vec<PendingApproval>, pub pending_approvals: Vec<PendingApproval>,
/// 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<String>,
} }
/// A pending approval request from the signer. /// A pending approval request from the signer.

View file

@ -1,21 +1,22 @@
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::nips::nip98::{HttpData, HttpMethod}; use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::crypto::VaultKey;
use crate::errors::{AppError, ErrorKind}; 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 /// Sign a NIP-98 HTTP auth event for `url` with the given [`Signing`] source
/// return the `Authorization` header value (`Nostr <base64>`). /// and return the `Authorization` header value (`Nostr <base64>`).
/// ///
/// This is what image hosts like nostr.build require before accepting an /// This is what image hosts like nostr.build require before accepting an
/// upload. Like publishing, it needs an unlocked vault when the vault is /// upload. It follows the same signer selection as publishing: an embedded
/// password-protected. /// 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( pub async fn nip98_authorization(
vault: &crate::vault::Vault,
url: &str, url: &str,
method: &str, method: &str,
key: Option<&VaultKey>, signing: &Signing,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let http_method = match method.to_ascii_uppercase().as_str() { let http_method = match method.to_ascii_uppercase().as_str() {
"GET" => HttpMethod::GET, "GET" => HttpMethod::GET,
@ -33,112 +34,57 @@ pub async fn nip98_authorization(
let parsed_url = Url::parse(url) let parsed_url = Url::parse(url)
.map_err(|e| AppError::config(format!("The upload URL is not valid: {e}")))?; .map_err(|e| AppError::config(format!("The upload URL is not valid: {e}")))?;
let secret_hex = profiles::resolve_active_secret_key(vault, key)?; // Build the same event HttpData::to_authorization would build (kind
let secret_key = profiles::parse_secret_key(&secret_hex)?; // 27235 with the u/method tags), but sign it through `Signing` so an
let keys = Keys::new(secret_key); // external profile never needs a local secret.
let http_data = HttpData::new(parsed_url, http_method);
let header = HttpData::new(parsed_url, http_method) let pubkey = signing.pubkey().await.map_err(AppError::from)?;
.to_authorization(&keys) let unsigned = IntoEventBuilder::into_event_builder(http_data).finalize_unsigned(pubkey);
let event = signing
.sign(unsigned)
.await .await
.map_err(|e| AppError::sign_failed(format!("Could not sign the upload request: {e}")))?; .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::settings::Settings;
use crate::vault::Vault;
/// Settings with no relays so tests never touch the network. fn local_signing() -> Signing {
fn offline_settings() -> Settings { Signing::Local(Keys::generate())
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);
} }
#[test] #[test]
fn unsupported_method_errors() { fn unsupported_method_errors() {
let vault = vault_with_profile(); let signing = local_signing();
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime let err = runtime
.block_on(nip98_authorization( .block_on(nip98_authorization(
&vault,
"https://example.com/upload", "https://example.com/upload",
"DELETE", "DELETE",
None, &signing,
)) ))
.expect_err("unsupported method must error"); .expect_err("unsupported method must error");
assert_eq!(err.kind(), ErrorKind::Config); assert_eq!(err.kind(), ErrorKind::Config);
} }
#[test] #[test]
fn locked_encrypted_vault_errors() { fn signs_a_nip98_auth_header() {
let mut vault = vault_with_profile(); let signing = local_signing();
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();
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let header = runtime let header = runtime
.block_on(nip98_authorization( .block_on(nip98_authorization(
&vault,
"https://nostr.build/api/v2/upload/files", "https://nostr.build/api/v2/upload/files",
"POST", "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"); assert!(header.starts_with("Nostr "), "expected a Nostr auth header");
let encoded = header.trim_start_matches("Nostr ").trim(); let encoded = header.trim_start_matches("Nostr ").trim();
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
let raw = B64 let raw = B64
.decode(encoded) .decode(encoded)
.expect("the header payload must be base64"); .expect("the header payload must be base64");

View file

@ -120,6 +120,37 @@ pub struct Vault {
/// handled; a password-protected vault encrypts them. /// handled; a password-protected vault encrypts them.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connection_secrets: Vec<ConnectionSecret>, pub connection_secrets: Vec<ConnectionSecret>,
/// 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<ConnectionClientKey>,
/// 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<SignerGrant>,
}
/// 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 /// An encrypted NIP-46 connection secret, keyed by its
@ -141,6 +172,23 @@ pub struct ConnectionSecret {
pub secret: String, 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 { impl Vault {
/// A fresh, empty vault. /// A fresh, empty vault.
pub fn empty() -> Self { pub fn empty() -> Self {
@ -152,9 +200,42 @@ impl Vault {
profiles: Vec::new(), profiles: Vec::new(),
nip46_connections: Vec::new(), nip46_connections: Vec::new(),
connection_secrets: 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 { pub fn has_profiles(&self) -> bool {
!self.profiles.is_empty() !self.profiles.is_empty()
} }
@ -344,6 +425,8 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
profiles, profiles,
nip46_connections: Vec::new(), nip46_connections: Vec::new(),
connection_secrets: 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<Vault, AppError> {
/// left untouched. Only profiles with the legacy `None` value (or /// left untouched. Only profiles with the legacy `None` value (or
/// missing the field entirely) are assigned `Embedded`. /// missing the field entirely) are assigned `Embedded`.
/// ///
/// Returns `true` if any profiles were migrated (i.e. the vault should /// Missing `signer_mode` fields are assigned `Embedded` by the serde
/// be re-saved). /// 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 { pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool {
let mut changed = false; // Profiles need no per-field work: the serde default already filled
for _profile in &mut vault.profiles { // any missing `signer_mode` at parse time and serialization at the
// The serde default already handles missing fields during // current version writes it explicitly.
// 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;
}
// Legacy connections without profile_npub (None) are left as-is. // Legacy connections without profile_npub (None) are left as-is.
// Ownership cannot be reliably inferred from active_profile, so these // Ownership cannot be reliably inferred from active_profile, so these
// connections remain unusable until the user re-creates them. // 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 /// 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 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<Option<Zeroizing<String>>, 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 /// Persist the vault to the stable application-data location with
/// restrictive permissions. /// restrictive permissions.
pub fn save_vault(vault: &Vault) -> Result<(), AppError> { pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
@ -647,6 +793,29 @@ mod tests {
use crate::errors::ErrorKind; use crate::errors::ErrorKind;
use std::sync::atomic::{AtomicU32, Ordering}; 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); static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_vault_path() -> PathBuf { fn temp_vault_path() -> PathBuf {
@ -852,11 +1021,14 @@ mod tests {
}); });
let changed1 = migrate_vault_signer_modes(&mut vault); 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); let changed2 = migrate_vault_signer_modes(&mut vault);
// The function always returns true because it normalises the version. assert!(!changed2, "re-running migration stays a no-op");
// The important thing is that running it twice doesn't corrupt data.
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded); assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
assert_eq!(vault.version, VAULT_VERSION); assert_eq!(vault.version, VAULT_VERSION);
} }
@ -1007,4 +1179,90 @@ mod tests {
// Connection remains None - cannot infer ownership // Connection remains None - cannot infer ownership
assert!(vault.nip46_connections[0].profile_npub.is_none()); 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());
}
} }

1326
tests/nip46_e2e.rs Normal file

File diff suppressed because it is too large Load diff