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
- 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<SigningError> 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<ConnectionSecret>` 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<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
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<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
and `Signing` enum (`Local(Keys)` / `External{signer, profile_pubkey}`); `SignerMode`
(`Embedded`/`Nip46Bunker`/`Nip46Client`) lives on `StoredProfile` (per-profile) *and*