diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index 3aeed92..19294a0 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -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 - Project: `/home/avi/Projects/Keynctr` -- Git repo: `master` @ `b484bde` ("feat: add embedded signer and NIP-46 client signer modes"). -- Working tree: clean except the usual untracked items (`.directory`, `.opencode/`, - `.impeccable/`, `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`). +- Branch: `master` @ **`6eff510`** ("feat: fail-closed ExportSecretKey with fresh auth and audit"). +- Working tree: **not clean** — see "Still uncommitted" below. The three feature + commits of this session are committed; only the packaging icon, the old + checkpoint, and pre-existing hygiene leftovers remain uncommitted. -## What was completed -1. **Added unified Signer architecture** with a common `Signer` trait in `src/signer/mod.rs` - that both embedded and NIP-46 client implementations share. The trait provides: - - `get_public_key()`, `sign_event()`, `get_signer_type()`, `is_available()` - - `request_approval()`, `disconnect()`, `revoke()`, `status_string()`, `detailed_status()` +## What was completed (this session) +The previously-uncommitted working tree (27 modified + 10 untracked files, ~1852/692) +was triaged into **three logical, independently-verifiable commits**. Order is +`a → c → b`: `ExportSecretKey` (b) reads the per-profile `signer_mode` field and the +`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`): - - Keys stored locally in the encrypted vault (Argon2id + AES-256-GCM) - - Zeroize memory protection for secret keys - - Per-request user approval with 5-minute timeout and 20-request queue cap - - Sensitive operations (kind 0, 3, 5, 6, 10000-10002, 30000-30015) flagged for extra confirmation - - Active profile binding with automatic updates on profile create/import/select +1. **Per-profile signer modes + persisted NIP-46 connections (c)** — three coexisting + signing modes (Embedded / Nip46Client / Nip46Bunker), a `signer_mode` field on every + stored profile, a vault `nip46_connections` store (owner-scoped, with parsed + permissions, expiry, revocation), the expanded `Signer` trait (identity validation, + `Signing` enum, permission surface), the NIP-46 permission model, and the redesigned + 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`): - - Connects to external signer (bunker) via `nostrconnect://` URI - - Supports both local (separate process) and remote signers over relays - - NIP-44 v2 encryption for all communication - - Connection state tracking (connecting/connected/error) with relay connection monitoring - - Automatic approval timeout (5 min) and queue cap (20 pending) - - Connect secret echo verification per NIP-46 spec - - Graceful disconnect/revoke with connection cleanup +### Security properties confirmed +- No secret material is logged or returned except the single, authenticated, audited + export. The export `reason` is logged by design; passwords and nsecs are not. +- Export is **fail-closed**: a failed audit write prevents the key from being returned + (`log.record(...)?` — the earlier `let _ =` that let a key out on audit failure is gone). +- External (Nip46Client) profiles cannot export a secret key — the key is not local. +- Profile identity is resolved server-side (`profiles::find_stored_profile`), not trusted + 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**: - - Users can choose "Embedded signer" or "NIP-46 signer" during account setup - - Switch modes anytime without changing public key (preserves `npub`) - - Clear UI showing active mode, connection status, and pending approvals - - Security notes explaining trade-offs between modes +## Commits added this session (newest first) +| Hash | Message | +|------|---------| +| `6eff510` | feat: fail-closed ExportSecretKey with fresh auth and audit | +| `2c61830` | feat: add per-profile signer modes with persisted NIP-46 connections | +| `caed722` | feat: add hash-chained, append-only audit log | -4. **Frontend integration**: - - New `SignerModeScreen.tsx` for mode selection and status monitoring - - 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 +Parent of this session: `4038e2d` ("checkpoint: document embedded signer and NIP-46 +client signer modes"). -5. **IPC protocol extensions** (`src/ipc.rs`): - - `SignerModeGet`, `SignerModeSet` for mode management - - `EmbeddedSignerStatus`, `EmbeddedSignerApprove` - - `Nip46Connect`, `Nip46Disconnect`, `Nip46Status`, `Nip46Approve` - - Legacy bunker signer commands delegated to new NIP-46 client when in that mode +### Files touched by the three commits (30 files, +3921 / -611) +``` +Cargo.lock Cargo.toml frontend/electron/main.ts frontend/package.json +frontend/package-lock.json frontend/src/components/ExportSecretKeyModal.tsx (new) +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**: - - All secret keys encrypted at rest with Argon2id + AES-256-GCM - - Zeroize for in-memory key cleanup - - Approval timeouts and queue caps prevent DoS - - Connection secrets verified on handshake - - No private keys in logs, crash reports, or network requests - - Keys never sent to server/relay/AI services +## Verification (run this session, per commit) +Each commit was verified **in isolation** (checked out on top of its parent), not just +as the final tree: +- `caed722` (audit): `cargo test --release` → **124 passed** (119 prior + 5 audit). +- `2c61830` (signer modes): `cargo test --release` → **186 passed**; `cargo clippy + --all-targets` clean; `cargo fmt --check` clean; frontend `tsc --noEmit` clean; + `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) -- `b484bde` feat: add embedded signer and NIP-46 client signer modes +## How to reproduce / exercise +- 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 -All green in this session, run after the changes: +## Still uncommitted (do NOT lose; do NOT commit the hygiene junk) +- **`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; - `cargo clippy --all-targets` — clean, zero warnings; `cargo build --release` — success. -- Frontend: `npm test` — 16 files / 110 passed; - `npm run typecheck` clean; `npm run lint` clean (only harmless ES-module warning); - `npm run format:check` clean; `npm run build` — success (Vite bundle built); - `npm run electron:build` — success. - -## How to resume / reproduce -- Build + run the GUI: `cargo build --release && cd frontend && npm run build && - npm run electron:build && npm start` (dev: `npm run dev` in one terminal + - `NOSTR_GUI_DEV_URL=http://localhost:5173 npm start` in a second, or - `npm run start:dev`). -- Choose signer mode: Open **Signer Mode** from sidebar → select "Embedded Signer" or "NIP-46 Remote Signer". -- For NIP-46 mode: In your Nostr app (Amber, Nostr Connect, etc.), choose "use a remote signer", - copy the `nostrconnect://` link, paste it in Keynctr's Signer Mode screen, and click Connect. -- Switch modes anytime without changing your public key (same `npub`). -- Signing workflow: When a sensitive operation needs approval, a prompt appears with event kind, - 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) \ No newline at end of file +## Deferred / next steps (unchanged, plus new) +- **Step 2 (packaging)**: restore `frontend/build/icon.png`, verify `npm run dist`. +- Signer abstraction (Step 3): promote `src/signer` to the single `Signer` source of + truth; introduce `SigningBackend { Internal{..}, Remote{..} }` per profile and route + every IPC handler through the trait (no inline mode branching). +- 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 + the UI, not just parsed. +- Deferred security (Step 5): KDF upgrade to m=64 MiB / t=3 with vault-header versioning + + backward-compatible migration; `--allow-env-secret` flag; remove or gate deprecated + `RevealSecretKey` IPC behind the same fail-closed path. +- Undo history (Step 6): resolve the `ProfileSummary`-loses-the-secret question. +- Hygiene (Step 7): Keynctr rename pass, delete legacy Python, migrate root + `profiles_vault.json*` into `~/.local/share/keynectr`. +- Open question carried forward: `migrate_vault_signer_modes` currently reports a change + on every load (always `changed = true`), so `App::load` re-saves the vault each start. + Harmless (idempotent) but wasteful; tighten to only report real changes.