checkpoint: document NIP-46 connection-secrets vault integration (Step 3 sub-step 1)

This commit is contained in:
Avi 2026-09-04 16:56:59 -05:00
commit 715c99c688

View file

@ -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 ## Where things are
- Project: `/home/avi/Projects/Keynctr` - Project: `/home/avi/Projects/Keynctr`
- Branch: `master` @ **`e6e4922`** ("feat(signer): add SigningBackend + SigningError + VaultRef"). - Branch: `master` @ **`510cb65`** ("feat(signer): store NIP-46 connection secrets in
- Working tree: **effectively clean for tracked files.** No tracked file is modified or the vault, keyed by VaultRef").
staged. The only untracked entries are the pre-existing hygiene leftovers and the - Working tree: **clean for tracked files.** No tracked file is modified or staged.
source JPEG (all intentionally untracked — see "Still untracked"). Build artifacts 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. (`release/`, `dist/`) are gitignored.
## What was completed (this session) ## What was completed (this session)
**Step 3 (signer abstraction) — foundation landed.** The approved API types are now in **Step 3 sub-step 1 (Vault integration) — landed as `510cb65`.** The NIP-46
the codebase as a standalone, independently-testable commit (`e6e4922`), ahead of the nostrconnect `secret` is a credential, so it no longer lives inline on
vault-integration and IPC-rerouting sub-steps. `src/signer/backend.rs` (new) adds: `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 - **`src/vault.rs`** — new `ConnectionSecret { ref_: VaultRef, secret }` struct and a
user content is signed. `Remote` holds **only** an opaque `VaultRef` `Vault.connection_secrets: Vec<ConnectionSecret>` store (encrypted under the vault
(`profile_npub` + `signer_pubkey`); the NIP-46 connection secret is **never** inlined key when password-protected, plaintext when the vault has no password — exactly
(enforced by a test that serializes a `Remote` backend and asserts no secret appears). mirroring how profile secrets are handled). Three helpers:
- `VaultRef` — an opaque, secret-free pointer into the vault's encrypted - `store_connection_secret(vault, key, ref_, secret)` — upserts by `VaultRef`;
connection-secret store. Resolution to the decrypted secret happens only at the vault encrypts when the vault is password-protected, else stores plaintext. A locked
boundary, while the vault is unlocked (that wiring is the next sub-step). encrypted vault fails closed (`vault_locked`) rather than silently writing a
- `SigningError` — the closed set of signing failures (no active profile, internal key plaintext secret that would not match once unlocked. Replacing an existing
unavailable, remote connection missing, secret resolution, identity mismatch, not `VaultRef` never leaves a stale secret behind.
connected, permission denied, expired, revoked, rejected, timeout, invalid signature, - `resolve_connection_secret(vault, key, ref_)` — decrypts (or returns plaintext)
network, storage, internal). Each carries a stable `ErrorKind`, a user-facing message for a `VaultRef`; `Ok(None)` when no secret is stored, `Err(vault_locked)` when
(mirroring the existing `AppError` copy), and an optional technical detail. the vault is encrypted but locked. On success the plaintext is `Zeroizing`
`From<SigningError> for AppError` converts at the IPC boundary; a best-effort (shredded on scope exit).
`SigningError::from_app` lifts upstream `AppError`s back into the signing space. - `delete_connection_secret(vault, ref_)` — removes an entry (on disconnect).
- **`src/signer/types.rs`** — the inline `secret: Option<String>` 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<String>` (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 Security properties: no secret material is logged; the connect secret is resolved
permission model are untouched by this commit — they are what the upcoming vault fresh from the vault at send time (fail-closed on a locked vault); a serialized
integration and IPC reroute will drive through `SigningBackend`. `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) 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) ## Commits added this session (newest first)
| Hash | Message | | 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, (The parent chain — `b2755d8` Signer trait returns SigningError, `a2307ac` checkpoint,
`6eff510` export, `2c61830` signer modes, `caed722` audit log — remain the parent chain.) `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) ## Verification (run this session)
Full suite per AGENTS.md, run on top of `e6e4922`: Full suite per AGENTS.md, run on top of `510cb65`:
- **Rust**: `cargo test` → **196 passed**, 0 failed (186 prior + 10 new - **Rust**: `cargo test` → **196 passed**, 0 failed (no new/removed tests — this
`signer::backend` tests); `cargo clippy --all-targets` → clean (exit 0); 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. `cargo fmt --check` → clean (exit 0); `cargo build --release` → Finished, exit 0.
- **Frontend**: not re-run this session — `e6e4922` is Rust-only (new - **Frontend**: `npm test` → passed; `npm run typecheck` → exit 0; `npm run lint` →
`src/signer/backend.rs` + a module line in `src/signer/mod.rs`), so the frontend exit 0; `npm run electron:build` → exit 0; `npm run build` → exit 0.
suite is unchanged from `114b343` (116 passed / typecheck / lint clean). Re-verified `npm run format:check` → **exit 1 on the 5 pre-existing files** (`ExportSecretKeyModal.tsx`,
in full at the end of Step 3 (IPC reroute), where frontend handlers change. `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 ## How to reproduce / exercise
- Backend: `cargo run --release -- serve` (JSON-lines IPC on stdio) or the CLI in - 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`. icon proven inside both artifacts, committed as `114b343`.
- **Step 3 (signer abstraction)** — foundation **landed** as `e6e4922` - **Step 3 (signer abstraction)** — foundation **landed** as `e6e4922`
(`SigningBackend` + `VaultRef` + `SigningError` in `src/signer/backend.rs`, 10 tests, (`SigningBackend` + `VaultRef` + `SigningError` in `src/signer/backend.rs`, 10 tests,
full Rust suite green). The `Remote { vault_ref }` variant holds **only** a `VaultRef` full Rust suite green); `b2755d8` made the `Signer` trait return `SigningError`.
— the NIP-46 connection secret is never inlined. **Remaining sub-steps, in order:** **Sub-step 1 (Vault integration) — DONE, landed as `510cb65`**: the encrypted
1. **Vault integration** — encrypted connection-secret store keyed by `VaultRef`, `connection_secrets` store keyed by `VaultRef`, on-demand secret resolution at the
`VaultRef` → decrypted-secret resolution at the vault boundary (only while vault boundary (fail-closed when locked), and the inline `Nip46Connection.secret`
unlocked), and removal of the inline `Nip46Connection.secret` field so secrets live field removed. The `Remote { vault_ref }` variant holds **only** a `VaultRef` — the
only in the vault. This is where the `vault_ref` constraint becomes load-bearing. NIP-46 connection secret is never inlined. **Remaining sub-step:**
2. **IPC reroute** — drive `src/ipc.rs` handlers through `SigningBackend` (selected 2. **IPC reroute** — drive `src/ipc.rs` handlers through `SigningBackend` (selected
per-profile) so no inline `signer_mode`/handle-presence branching remains; convert per-profile) so no inline `signer_mode`/handle-presence branching remains; convert
handler results to `AppError` via `From<SigningError>`. handler results to `AppError` via `From<SigningError>`. 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 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` and `Signing` enum (`Local(Keys)` / `External{signer, profile_pubkey}`); `SignerMode`
(`Embedded`/`Nip46Bunker`/`Nip46Client`) lives on `StoredProfile` (per-profile) *and* (`Embedded`/`Nip46Bunker`/`Nip46Client`) lives on `StoredProfile` (per-profile) *and*