docs: checkpoint signer modes + fail-closed key export (post-triage)

Re-point the checkpoint at HEAD 6eff510 and document the three-session
commit split (audit log -> signer modes -> fail-closed export), the real
file list, per-commit verification results, and the remaining
uncommitted packaging icon + hygiene leftovers.
This commit is contained in:
Avi 2026-09-03 09:53:58 -05:00
commit d92371fbc2

View file

@ -1,100 +1,120 @@
# Checkpoint — Embedded Signer & NIP-46 Client Modes (2026-09-01) # Checkpoint — Signer Modes + Fail-Closed Key Export (2026-09-03)
## Where things are ## Where things are
- Project: `/home/avi/Projects/Keynctr` - Project: `/home/avi/Projects/Keynctr`
- Git repo: `master` @ `b484bde` ("feat: add embedded signer and NIP-46 client signer modes"). - Branch: `master` @ **`6eff510`** ("feat: fail-closed ExportSecretKey with fresh auth and audit").
- Working tree: clean except the usual untracked items (`.directory`, `.opencode/`, - Working tree: **not clean** — see "Still uncommitted" below. The three feature
`.impeccable/`, `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`). commits of this session are committed; only the packaging icon, the old
checkpoint, and pre-existing hygiene leftovers remain uncommitted.
## What was completed ## What was completed (this session)
1. **Added unified Signer architecture** with a common `Signer` trait in `src/signer/mod.rs` The previously-uncommitted working tree (27 modified + 10 untracked files, ~1852/692)
that both embedded and NIP-46 client implementations share. The trait provides: was triaged into **three logical, independently-verifiable commits**. Order is
- `get_public_key()`, `sign_event()`, `get_signer_type()`, `is_available()` `a → c → b`: `ExportSecretKey` (b) reads the per-profile `signer_mode` field and the
- `request_approval()`, `disconnect()`, `revoke()`, `status_string()`, `detailed_status()` `external_signer_not_connected` error that the signer-mode commit (c) introduces, so
(c) had to land first. The only uncommitted *tests* were the export tests and the
signer-mode/permission tests, so "(d) tests" is not a separate commit — each feature's
tests travel with it.
2. **Embedded Signer mode** (`src/signer/embedded.rs`): 1. **Per-profile signer modes + persisted NIP-46 connections (c)** — three coexisting
- Keys stored locally in the encrypted vault (Argon2id + AES-256-GCM) signing modes (Embedded / Nip46Client / Nip46Bunker), a `signer_mode` field on every
- Zeroize memory protection for secret keys stored profile, a vault `nip46_connections` store (owner-scoped, with parsed
- Per-request user approval with 5-minute timeout and 20-request queue cap permissions, expiry, revocation), the expanded `Signer` trait (identity validation,
- Sensitive operations (kind 0, 3, 5, 6, 10000-10002, 30000-30015) flagged for extra confirmation `Signing` enum, permission surface), the NIP-46 permission model, and the redesigned
- Active profile binding with automatic updates on profile create/import/select Signer Mode screen with a nostr-tools-based client.
2. **Fail-closed key export (b)** — `export_secret_key` always re-authenticates, requires
a reason, refuses external-signer profiles, and writes the audit entry *before*
returning the key (fail-closed on audit failure). Frontend `ExportSecretKeyModal`
replaces the old reveal modal.
3. **Hash-chained audit log (a)** — SHA-256 hash-chained append-only log with atomic
append and end-to-end `verify_chain()`; the `sha2` dependency.
3. **NIP-46 Client Signer mode** (`src/signer/nip46_client.rs`): ### Security properties confirmed
- Connects to external signer (bunker) via `nostrconnect://` URI - No secret material is logged or returned except the single, authenticated, audited
- Supports both local (separate process) and remote signers over relays export. The export `reason` is logged by design; passwords and nsecs are not.
- NIP-44 v2 encryption for all communication - Export is **fail-closed**: a failed audit write prevents the key from being returned
- Connection state tracking (connecting/connected/error) with relay connection monitoring (`log.record(...)?` — the earlier `let _ =` that let a key out on audit failure is gone).
- Automatic approval timeout (5 min) and queue cap (20 pending) - External (Nip46Client) profiles cannot export a secret key — the key is not local.
- Connect secret echo verification per NIP-46 spec - Profile identity is resolved server-side (`profiles::find_stored_profile`), not trusted
- Graceful disconnect/revoke with connection cleanup from the client.
- NIP-46 permissions are deny-by-default and cannot be broadened on reconnect.
- Audit writes are serialized (single mutex across the read-compute-write-update cycle)
and the chain is tamper-evident from a genesis hash.
- Renderer never receives an nsec outside the intentional one-time export.
3. **Signer mode management**: ## Commits added this session (newest first)
- Users can choose "Embedded signer" or "NIP-46 signer" during account setup | Hash | Message |
- Switch modes anytime without changing public key (preserves `npub`) |------|---------|
- Clear UI showing active mode, connection status, and pending approvals | `6eff510` | feat: fail-closed ExportSecretKey with fresh auth and audit |
- Security notes explaining trade-offs between modes | `2c61830` | feat: add per-profile signer modes with persisted NIP-46 connections |
| `caed722` | feat: add hash-chained, append-only audit log |
4. **Frontend integration**: Parent of this session: `4038e2d` ("checkpoint: document embedded signer and NIP-46
- New `SignerModeScreen.tsx` for mode selection and status monitoring client signer modes").
- Updated `AppProvider` with `signerModeGet`, `signerModeSet`, `embeddedSignerStatus`,
`nip46Connect`, `nip46Disconnect`, `nip46Status`, and approval handlers
- New types: `SignerMode`, `ApprovalDetails`, `EmbeddedSignerStatus`, `Nip46SignerStatus`
- Sidebar navigation updated with "Signer Mode" entry
5. **IPC protocol extensions** (`src/ipc.rs`): ### Files touched by the three commits (30 files, +3921 / -611)
- `SignerModeGet`, `SignerModeSet` for mode management ```
- `EmbeddedSignerStatus`, `EmbeddedSignerApprove` Cargo.lock Cargo.toml frontend/electron/main.ts frontend/package.json
- `Nip46Connect`, `Nip46Disconnect`, `Nip46Status`, `Nip46Approve` frontend/package-lock.json frontend/src/components/ExportSecretKeyModal.tsx (new)
- Legacy bunker signer commands delegated to new NIP-46 client when in that mode frontend/src/components/ShowSecretKeyModal.tsx (deleted)
frontend/src/lib/api.ts frontend/src/lib/signer/SignerManager.ts (new)
frontend/src/lib/types.ts frontend/src/screens/ProfilesScreen.tsx
frontend/src/screens/SignerModeScreen.tsx frontend/src/state/AppProvider.tsx
frontend/src/styles.css frontend/src/test/apiMock.ts
frontend/src/test/ExportSecretKey.test.tsx (new) frontend/src/test/fakeBackend.ts
frontend/src/test/ShowSecretKey.test.tsx (deleted)
src/app.rs src/audit.rs (new) src/errors.rs src/ipc.rs src/lib.rs src/main.rs
src/profiles.rs src/signer/mod.rs src/signer/nip46_client.rs
src/signer/permissions.rs (new) src/signer/types.rs src/vault.rs
```
6. **Security hardening**: ## Verification (run this session, per commit)
- All secret keys encrypted at rest with Argon2id + AES-256-GCM Each commit was verified **in isolation** (checked out on top of its parent), not just
- Zeroize for in-memory key cleanup as the final tree:
- Approval timeouts and queue caps prevent DoS - `caed722` (audit): `cargo test --release` → **124 passed** (119 prior + 5 audit).
- Connection secrets verified on handshake - `2c61830` (signer modes): `cargo test --release` → **186 passed**; `cargo clippy
- No private keys in logs, crash reports, or network requests --all-targets` clean; `cargo fmt --check` clean; frontend `tsc --noEmit` clean;
- Keys never sent to server/relay/AI services `npx vitest run` → **110 passed**.
- `6eff510` (export): `cargo test --release` → **186 passed**; frontend `tsc --noEmit`
clean; `npx vitest run` → **116 passed** (110 + 6 export tests).
## Commits added in this session (newest first) ## How to reproduce / exercise
- `b484bde` feat: add embedded signer and NIP-46 client signer modes - Backend: `cargo run --release -- serve` (JSON-lines IPC on stdio) or the CLI in
`src/main.rs`.
- GUI: from `frontend/`, `npm run electron:build && electron .` (prod) or `npm run
start:dev` with `NOSTR_GUI_DEV_URL`.
- Exercise export: Profiles → a profile → Export secret key → enter the vault password
and a reason. An external (NIP-46 client) profile shows it cannot export.
- Exercise modes: Signer Mode screen → pick Embedded / NIP-46 client; connect a
`nostrconnect://` URI with a `perms=` parameter to grant scoped permissions.
## Verification commands run ## Still uncommitted (do NOT lose; do NOT commit the hygiene junk)
All green in this session, run after the changes: - **`frontend/build/icon.png` is deleted** (working tree) — the electron-builder Linux
icon. This is the Step-2 packaging fix: regenerate it from
`KeynectrAppIconPossibility02.jpeg` (or point electron-builder at the new asset),
verify `npm run dist`, then commit. **Not done in this session.**
- **`CHECKPOINT-encryption.md`** — this file, committed to reference the post-triage
HEAD (`6eff510`). It must be re-updated to reference the new HEAD once Step 2
(packaging) lands its own commit.
- Pre-existing untracked hygiene leftovers (out of scope, address in the hygiene pass):
`COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`, `.opencode/`, `.impeccable/`,
`.directory`. **`profiles_vault.json*` and `target/` remain correctly untracked and
uncommitted** (vault is gitignored).
- Rust: `cargo fmt --check` clean; `cargo test` — 119 passed; ## Deferred / next steps (unchanged, plus new)
`cargo clippy --all-targets` — clean, zero warnings; `cargo build --release` — success. - **Step 2 (packaging)**: restore `frontend/build/icon.png`, verify `npm run dist`.
- Frontend: `npm test` — 16 files / 110 passed; - Signer abstraction (Step 3): promote `src/signer` to the single `Signer` source of
`npm run typecheck` clean; `npm run lint` clean (only harmless ES-module warning); truth; introduce `SigningBackend { Internal{..}, Remote{..} }` per profile and route
`npm run format:check` clean; `npm run build` — success (Vite bundle built); every IPC handler through the trait (no inline mode branching).
`npm run electron:build` — success. - External-signer permissions (Step 4): wire `src/signer/permissions.rs` into the
approval modal so grants (kinds, relays, expiry, rate) are persisted AND enforced in
## How to resume / reproduce the UI, not just parsed.
- Build + run the GUI: `cargo build --release && cd frontend && npm run build && - Deferred security (Step 5): KDF upgrade to m=64 MiB / t=3 with vault-header versioning
npm run electron:build && npm start` (dev: `npm run dev` in one terminal + + backward-compatible migration; `--allow-env-secret` flag; remove or gate deprecated
`NOSTR_GUI_DEV_URL=http://localhost:5173 npm start` in a second, or `RevealSecretKey` IPC behind the same fail-closed path.
`npm run start:dev`). - Undo history (Step 6): resolve the `ProfileSummary`-loses-the-secret question.
- Choose signer mode: Open **Signer Mode** from sidebar → select "Embedded Signer" or "NIP-46 Remote Signer". - Hygiene (Step 7): Keynctr rename pass, delete legacy Python, migrate root
- For NIP-46 mode: In your Nostr app (Amber, Nostr Connect, etc.), choose "use a remote signer", `profiles_vault.json*` into `~/.local/share/keynectr`.
copy the `nostrconnect://` link, paste it in Keynctr's Signer Mode screen, and click Connect. - Open question carried forward: `migrate_vault_signer_modes` currently reports a change
- Switch modes anytime without changing your public key (same `npub`). on every load (always `changed = true`), so `App::load` re-saves the vault each start.
- Signing workflow: When a sensitive operation needs approval, a prompt appears with event kind, Harmless (idempotent) but wasteful; tighten to only report real changes.
content preview, and destination relays. Click Approve or Reject.
## Threat model summary
| Aspect | Embedded Signer | NIP-46 Client |
|--------|-----------------|---------------|
| **Key Location** | Local encrypted vault | Remote signer (never on this device) |
| **Compromise Impact** | Full key extraction if vault unlocked + malware | Attacker can *request* signatures, cannot extract key |
| **Phishing Resistance** | None (local UI spoofable) | None (NIP-46 doesn't prevent malicious requests) |
| **Device Theft** | Vault encrypted at rest; unlock needed | No key on device; connection revocable |
| **Malicious Relay** | N/A (local signing) | Relay sees only encrypted NIP-44 payloads |
| **Connection Leak** | N/A | Attacker can request signatures until revoked |
| **Replay Protection** | N/A | NIP-46 uses unique request IDs + timestamps |
## Outstanding / next-step items
- OS keyring integration (GNOME Keyring, KWallet, macOS Keychain, Windows Credential Manager)
for vault encryption key storage as optional enhancement
- Hardware wallet NIP-46 signer integration testing
- Connection URI rotation / periodic re-pairing for NIP-46
- Session binding to specific device/account where platform allows
- Comprehensive NIP-46 client test suite (mock signer, timeout/rejection scenarios)