diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index a7f2d34..2050b56 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,3 +1,74 @@ +# Checkpoint — External NIP-46 signing works end-to-end (2026-09-10) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`1af79d8`** ("feat(signer): end-to-end external NIP-46 signing in + publish and upload auth"). +- Working tree: clean for tracked files (untracked leftovers unchanged — see older + "Still untracked" sections). +- Verification: `cargo test` **200 passed / 0 failed**, `cargo clippy --all-targets` + clean, `cargo fmt --check` clean, `cargo build --release` green. + +## What was completed (this session) + +**Step 3 sub-step 2 (IPC reroute) — DONE, landed as `1af79d8`.** Publishing from an +external (NIP-46) profile now signs remotely instead of returning +"External signer not yet supported": + +- **`src/signer/nip46_client.rs`** — the outbound/client half of NIP-46: + `send_remote_request` encrypts a request (NIP-44) and publishes it to the signer's + relay; incoming payloads shaped like responses (`result`/`error`) are demultiplexed + to the waiting caller (a response with no registered id is ignored); 30 s + `REQUEST_TIMEOUT`; pending waiters are woken with errors on disconnect/failure so + callers never hang the full timeout. `Signer::sign_event` is real now: permission + check → remote `sign_event` → verify the returned event (a) is signed by the + connected remote identity, (b) matches the exact unsigned event requested, (c) has a + valid signature — then return it. **No local-key fallback anywhere.** + `audit_permission_denied` uses `try_lock` (audit is best-effort; blocking here would + deadlock the very request being denied while the IPC dispatcher holds the App lock). +- **`src/app.rs`** — `App::signing_for(npub)` / `App::signing_active()`: the single + place that maps a profile's `SignerMode` to a `Signing` source. + `Embedded` → `Signing::Local` with the vault-resolved key; `Nip46Client` → + `Signing::External` wrapping the live signer **only when present and connected**, + else `ExternalSignerNotConnected` (fail closed, never a silent local fallback); + `Nip46Bunker` (not wired) also fails closed. +- **`src/publish.rs`** — `publish_with_keys` builds the unsigned event from the + `Signing`'s own pubkey (external identities validate there before any relay work) + and signs via `Signing::sign`; new `publish_signed` entry point for IPC. The CLI's + `publish_active`/`publish_as` keep the local vault path. +- **`src/ipc.rs`** — `PublishNote` and `UploadAuth` route through + `app.signing_active()`; no handler branches on signer mode anymore. + `Nip46Connect`/`Nip46Disconnect` now clone the signer handle and **drop the App + guard before awaiting** `connect()`/`disconnect()` (they re-lock the App + internally — a latent deadlock, fixed). +- **`src/relays.rs`** — keyless relay pool: `open_pool_inner(Option, …)` so + external signing can publish/relay without local keys (no relay AUTH). +- **`src/uploads.rs`** — `nip98_authorization(url, method, &Signing)` — NIP-98 upload + auth events sign through the same `Signing` source, so uploads authenticate with + the remote signer for external profiles. +- **`src/profiles.rs`** — `store_remote_profile(vault, npub, label)`: connecting a + NIP-46 signer creates/refreshes a **secretless** `Nip46Client` profile row (empty + `secret_key`, made active) so publish has a selection; refuses to silently convert + an existing local profile into a remote one. `connect()` calls it and adopts the + remote npub as the signer's active profile. + +New tests (`src/app.rs`): embedded → `Signing::Local`; external profile with no live +signer → `ExternalSignerNotConnected` (fail closed); no active profile → +`NoActiveProfile`; `store_remote_profile` creates a secretless external profile and +refuses to clobber a local one. + +Security properties: remote-signed events are triple-verified (identity, content +match, signature) before publish; external profiles never touch a local key; the +connection secret stays vault-encrypted (Step 3 sub-step 1 unchanged). + +## Out of scope this session (deliberate) +- `publish_profile_metadata` (kind 0) still signs locally from the vault — a + synchronous key-based path; rerouting it is a separate follow-up. +- Dead `src/signer/nip46_external.rs` stub cleanup (untracked leftover). +- Step 4 (permissions UI), Step 5 (KDF upgrade), Step 7 (rename/hygiene). + +--- + # Checkpoint — Undo-delete restores working profiles (2026-09-10) ## Where things are