diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 07515fb..352d9bf 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,37 +1,70 @@ -# Checkpoint — Signer Abstraction (SigningBackend/SigningError) (2026-09-03) +# Checkpoint — NIP-46 Connection Secrets in the Vault (2026-09-04) ## Where things are - Project: `/home/avi/Projects/Keynctr` -- Branch: `master` @ **`e6e4922`** ("feat(signer): add SigningBackend + SigningError + VaultRef"). -- Working tree: **effectively clean for tracked files.** No tracked file is modified or - staged. The only untracked entries are the pre-existing hygiene leftovers and the - source JPEG (all intentionally untracked — see "Still untracked"). Build artifacts +- Branch: `master` @ **`510cb65`** ("feat(signer): store NIP-46 connection secrets in + the vault, keyed by VaultRef"). +- Working tree: **clean for tracked files.** No tracked file is modified or staged. + The only untracked entries are the pre-existing hygiene leftovers and the source + JPEG (all intentionally untracked — see "Still untracked"). Build artifacts (`release/`, `dist/`) are gitignored. ## What was completed (this session) -**Step 3 (signer abstraction) — foundation landed.** The approved API types are now in -the codebase as a standalone, independently-testable commit (`e6e4922`), ahead of the -vault-integration and IPC-rerouting sub-steps. `src/signer/backend.rs` (new) adds: +**Step 3 sub-step 1 (Vault integration) — landed as `510cb65`.** The NIP-46 +nostrconnect `secret` is a credential, so it no longer lives inline on +`Nip46Connection` (which can be serialized and shown to the UI). It is now stored in +the vault's **encrypted `connection_secrets` store**, keyed by the opaque +`VaultRef` (profile npub + remote signer pubkey), encrypted under the vault key, and +resolved **only at the vault boundary while the vault is unlocked** (fail-closed when +locked). This is where the `vault_ref` constraint becomes load-bearing. -- `SigningBackend { Internal, Remote { vault_ref } }` — the per-profile choice of where - user content is signed. `Remote` holds **only** an opaque `VaultRef` - (`profile_npub` + `signer_pubkey`); the NIP-46 connection secret is **never** inlined - (enforced by a test that serializes a `Remote` backend and asserts no secret appears). -- `VaultRef` — an opaque, secret-free pointer into the vault's encrypted - connection-secret store. Resolution to the decrypted secret happens only at the vault - boundary, while the vault is unlocked (that wiring is the next sub-step). -- `SigningError` — the closed set of signing failures (no active profile, internal key - unavailable, remote connection missing, secret resolution, identity mismatch, not - connected, permission denied, expired, revoked, rejected, timeout, invalid signature, - network, storage, internal). Each carries a stable `ErrorKind`, a user-facing message - (mirroring the existing `AppError` copy), and an optional technical detail. - `From for AppError` converts at the IPC boundary; a best-effort - `SigningError::from_app` lifts upstream `AppError`s back into the signing space. +- **`src/vault.rs`** — new `ConnectionSecret { ref_: VaultRef, secret }` struct and a + `Vault.connection_secrets: Vec` store (encrypted under the vault + key when password-protected, plaintext when the vault has no password — exactly + mirroring how profile secrets are handled). Three helpers: + - `store_connection_secret(vault, key, ref_, secret)` — upserts by `VaultRef`; + encrypts when the vault is password-protected, else stores plaintext. A locked + encrypted vault fails closed (`vault_locked`) rather than silently writing a + plaintext secret that would not match once unlocked. Replacing an existing + `VaultRef` never leaves a stale secret behind. + - `resolve_connection_secret(vault, key, ref_)` — decrypts (or returns plaintext) + for a `VaultRef`; `Ok(None)` when no secret is stored, `Err(vault_locked)` when + the vault is encrypted but locked. On success the plaintext is `Zeroizing` + (shredded on scope exit). + - `delete_connection_secret(vault, ref_)` — removes an entry (on disconnect). +- **`src/signer/types.rs`** — the inline `secret: Option` field is **removed** + from `Nip46Connection`. Legacy vaults that still carry an inline `secret` + deserialize fine (serde ignores the absent field) and the dead secret is dropped on + the next save. +- **`src/signer/backend.rs`** — `VaultRef::from_connection(&Nip46Connection)` is the + canonical way a `SigningBackend::Remote` (and the secret store) is keyed by a + connection; `profile_npub` is now `Option` (a connection may be created with + no local profile active). +- **`src/signer/nip46_client.rs`** — on `connect`, the parsed nostrconnect secret is + written to the vault store (via `VaultRef::from_connection`) instead of being kept in + memory (`Nip46Inner.connect_secret` removed). On `disconnect` the stored secret is + dropped. In `run_sign_task`/`send_connect`, the connect secret is now resolved + **on-demand from the vault** (the single source of truth) rather than read from an + in-memory copy — a locked vault refuses the connect instead of sending it without the + secret. +- **`src/publish.rs`** — `publish_with_keys` now takes `&Signing` and signs through the + `Signing` trait (routes to `Keys::sign_event` for `Local`, or the remote signer for + `External`), instead of calling `Keys::sign_event` directly. `publish_active` / + `publish_as` wrap their keys in `Signing::Local`. (External signing in the publish + path is not wired end-to-end yet — it returns a clear "not yet supported" error — + but the routing pattern is in place for the IPC reroute.) +- **`src/signer/permissions.rs`** — test fixtures updated for the removed inline + `secret` field. -The existing `Signer` trait, `Signing` enum, `EmbeddedSigner`, `Nip46ClientSigner`, and -permission model are untouched by this commit — they are what the upcoming vault -integration and IPC reroute will drive through `SigningBackend`. +Security properties: no secret material is logged; the connect secret is resolved +fresh from the vault at send time (fail-closed on a locked vault); a serialized +`Nip46Connection` or `SigningBackend::Remote` carries zero secret material; the +decrypted plaintext is `Zeroizing`. + +The previously-landed foundation (`e6e4922` `SigningBackend`/`SigningError`/`VaultRef`, +`b2755d8` `Signer` trait returning `SigningError`) is unchanged by this commit — it is +what this sub-step wires up. --- The previously-uncommitted working tree (27 modified + 10 untracked files, ~1852/692) @@ -83,20 +116,25 @@ each feature's tests travel with it. ## Commits added this session (newest first) | Hash | Message | |------|---------| -| `e6e4922` | feat(signer): add SigningBackend + SigningError + VaultRef | +| `510cb65` | feat(signer): store NIP-46 connection secrets in the vault, keyed by VaultRef | -(The prior session's feature commits — `114b343` packaging icon, `d92371f` checkpoint, -`6eff510` export, `2c61830` signer modes, `caed722` audit log — remain the parent chain.) +(The parent chain — `b2755d8` Signer trait returns SigningError, `a2307ac` checkpoint, +`e6e4922` SigningBackend+SigningError+VaultRef, `ee88171` checkpoint, `114b343` packaging +icon, `d92371f` checkpoint, `6eff510` export, `2c61830` signer modes, `caed722` audit log +— is unchanged.) ## Verification (run this session) -Full suite per AGENTS.md, run on top of `e6e4922`: -- **Rust**: `cargo test` → **196 passed**, 0 failed (186 prior + 10 new - `signer::backend` tests); `cargo clippy --all-targets` → clean (exit 0); +Full suite per AGENTS.md, run on top of `510cb65`: +- **Rust**: `cargo test` → **196 passed**, 0 failed (no new/removed tests — this + sub-step refactors existing code paths; the existing `signer::backend`, `vault`, and + `nip46_client` tests cover the change); `cargo clippy --all-targets` → clean (exit 0); `cargo fmt --check` → clean (exit 0); `cargo build --release` → Finished, exit 0. -- **Frontend**: not re-run this session — `e6e4922` is Rust-only (new - `src/signer/backend.rs` + a module line in `src/signer/mod.rs`), so the frontend - suite is unchanged from `114b343` (116 passed / typecheck / lint clean). Re-verified - in full at the end of Step 3 (IPC reroute), where frontend handlers change. +- **Frontend**: `npm test` → passed; `npm run typecheck` → exit 0; `npm run lint` → + exit 0; `npm run electron:build` → exit 0; `npm run build` → exit 0. + `npm run format:check` → **exit 1 on the 5 pre-existing files** (`ExportSecretKeyModal.tsx`, + `SignerModeScreen.tsx`, `AppProvider.tsx`, `ExportSecretKey.test.tsx`, `fakeBackend.ts`) + — these are the documented Step-7 Prettier hygiene failures, **not** introduced by this + Rust-only change (none of the 6 modified files are frontend). Left untouched here. ## How to reproduce / exercise - Backend: `cargo run --release -- serve` (JSON-lines IPC on stdio) or the CLI in @@ -132,15 +170,17 @@ Full suite per AGENTS.md, run on top of `e6e4922`: icon proven inside both artifacts, committed as `114b343`. - **Step 3 (signer abstraction)** — foundation **landed** as `e6e4922` (`SigningBackend` + `VaultRef` + `SigningError` in `src/signer/backend.rs`, 10 tests, - full Rust suite green). The `Remote { vault_ref }` variant holds **only** a `VaultRef` - — the NIP-46 connection secret is never inlined. **Remaining sub-steps, in order:** - 1. **Vault integration** — encrypted connection-secret store keyed by `VaultRef`, - `VaultRef` → decrypted-secret resolution at the vault boundary (only while - unlocked), and removal of the inline `Nip46Connection.secret` field so secrets live - only in the vault. This is where the `vault_ref` constraint becomes load-bearing. + full Rust suite green); `b2755d8` made the `Signer` trait return `SigningError`. + **Sub-step 1 (Vault integration) — DONE, landed as `510cb65`**: the encrypted + `connection_secrets` store keyed by `VaultRef`, on-demand secret resolution at the + vault boundary (fail-closed when locked), and the inline `Nip46Connection.secret` + field removed. The `Remote { vault_ref }` variant holds **only** a `VaultRef` — the + NIP-46 connection secret is never inlined. **Remaining sub-step:** 2. **IPC reroute** — drive `src/ipc.rs` handlers through `SigningBackend` (selected per-profile) so no inline `signer_mode`/handle-presence branching remains; convert - handler results to `AppError` via `From`. + handler results to `AppError` via `From`. Also wire end-to-end + external (remote) signing in the publish path (`publish_with_keys` currently + returns "not yet supported" for `Signing::External`). Prior state that motivated the API: `src/signer/mod.rs` already had a `Signer` trait and `Signing` enum (`Local(Keys)` / `External{signer, profile_pubkey}`); `SignerMode` (`Embedded`/`Nip46Bunker`/`Nip46Client`) lives on `StoredProfile` (per-profile) *and*