diff --git a/CHECKPOINT-display-and-icons.md b/CHECKPOINT-display-and-icons.md new file mode 100644 index 0000000..ac1f133 --- /dev/null +++ b/CHECKPOINT-display-and-icons.md @@ -0,0 +1,103 @@ +# Checkpoint — Linux display compatibility + icon alpha fixes (2026-09-09) + +## Where things are +- Project: `/home/avi/Projects/Keynctr` +- Branch: `master` @ **`d580139`** ("fix(icons): true alpha channel, no white matte or + white tile") on top of **`0814a53`** ("fix(linux): work on X11, Wayland, and Hyprland"). +- Working tree: **clean for tracked files.** Untracked leftovers are the pre-existing + hygiene entries (`.directory`, `.impeccable/`, `.opencode/`, `COSMIC_THEME.md`, + `src/publish.rs.bak`, `src/signer/nip46_external.rs`) plus `deferred/SignerConnectionPanel.tsx.wip` + (a broken WIP component, moved out of the build — see below). Original white-background + icons are preserved under `deferred/original-icons/` (tracked). + +## What was completed (this session) + +### 1. Linux display-server compatibility — `0814a53` + +The app did not start on a friend's Wayland machine. Root causes found and fixed: + +- **No explicit platform choice.** Hyprland (and any Wayland session with XWayland) + exports both `$DISPLAY` and `$WAYLAND_DISPLAY`, so Electron must be told which + backend to use before Chromium initializes. `frontend/electron/main.ts` now sets + `ozone-platform` (wayland/x11) from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY` at module + load, with `KEYNCTR_FORCE_X11=1` / `KEYNCTR_FORCE_WAYLAND=1` overrides. +- **GPU process crashes (SIGSEGV in `eglCreateWindowSurface`, Mesa `libGLESv2`).** + Reproduced on this box (Intel Iris Xe, Hyprland, mesa 26.2.1): with hardware GL the + GPU helper died repeatedly and the window never appeared. Fix: **software rendering + by default on Linux** (`app.disableHardwareAcceleration()`); hardware GL is opt-in + via `KEYNCTR_ENABLE_GPU=1`. +- **Startup watchdog + bounded relaunch ladder.** A marker file + (`/keynctr-startup.json`, stamped clean on deliberate quit) records each launch; + if the previous process died before its window proved itself (painted and survived + 8 s), the next launch advances one rung: detected platform → other platform → GPU + opt-in → other+GPU, then stops with an error dialog listing the escape hatches. + AppImage-safe relaunch via `$APPIMAGE`. No infinite cascades. +- **Sandbox pre-flight.** Packaged builds check for a non-setuid `chrome-sandbox` + combined with blocked unprivileged user namespaces (Ubuntu 24.04 AppArmor knob, + `unprivileged_userns_clone`) and fall back to `--no-sandbox` instead of dying + silently. Root also gets `--no-sandbox` as Chromium requires. +- Window now uses `show: false` + `ready-to-show` (always shown, even with the + watchdog disabled). + +### 2. Icon white-fringe fix — `d580139` + +The source icons were grayscale (mode **L**, no alpha at all): a black bird on a flat +white field, which rendered as a white box/halo on every non-white surface (window +icon, taskbar, sidebar, launchers). + +- `frontend/public/icon.png` and `frontend/src/assets/logo.png` regenerated as + **RGBA**: alpha = ink coverage of the original artwork; RGB forced to 0 everywhere, + so no white matte can bleed through semi-transparent edge pixels (verified: 0 pixels + with RGB > 200 at alpha < 20). Artwork bbox/shape unchanged (IoU 1.0 vs originals). +- `frontend/src/styles.css`: `.sidebar-logo` dropped its `background: #fff` white tile, + `border-radius`, and `object-fit: cover`; the artwork now composites directly with + `contain`. Dark-theme `invert(1)` kept (ink artwork must flip on dark sidebars). +- Originals preserved: `deferred/original-icons/icon-public-512-white.png`, + `deferred/original-icons/logo-sidebar-338-white.png`. + +### 3. Build hygiene (uncommitted by design? no — landed with the fixes) + +- `frontend/src/components/signer/SignerConnectionPanel.tsx` was an untracked, + non-compiling WIP (broken `useCallback` closures, APIs that don't exist on + `SignerManager`, dependency on uninstalled `react-router-dom`) that blocked + `npm run typecheck`. Moved intact to `deferred/SignerConnectionPanel.tsx.wip` + (untracked) — nothing deleted; it needs a rewrite against the real hooks before + returning. + +## Verification (all run this session) + +- Rust: `cargo fmt --check` clean, `cargo clippy --all-targets` clean, `cargo test` + green, `cargo build --release` succeeded. +- Frontend: `npm run electron:build`, `npm run typecheck`, `npm run lint` clean; + `npm test` **116/116 passed**; `npx prettier --check electron/main.ts` and + `src/styles.css` clean; `npm run build` succeeded. (5 pre-existing Prettier warnings + in untouched files — `ExportSecretKeyModal.tsx`, `SignerModeScreen.tsx`, + `AppProvider.tsx`, `ExportSecretKey.test.tsx`, `fakeBackend.ts` — predate this + session and were left alone.) +- **On-device (Hyprland/Wayland, this machine):** app launched under a clean systemd + user scope: Keynctr window mapped (`class: keynectr`), watchdog marker cleared + (= config proven), **no new Electron core dumps** after 19:40 while multiple + software-render launches ran. `grim` screenshot + visual inspection confirmed the + sidebar bird sits directly on the sidebar with **no white tile, border, or halo**. +- Icon proof: checkerboard composite of the new `public/icon.png` shows clean + anti-aliased edges into transparency, no white fringe. + +## How to run / reproduce + +- GUI: `cd frontend && npm start` (or the packaged AppImage/deb once rebuilt via + `npm run dist`). +- Escape hatches: `KEYNCTR_FORCE_X11=1`, `KEYNCTR_FORCE_WAYLAND=1`, + `KEYNCTR_ENABLE_GPU=1`, `KEYNCTR_DISABLE_GPU=1`, `KEYNCTR_NO_RELAUNCH=1`. +- CLI: `cargo run --release -- serve` (JSON-lines IPC) as before. + +## Outstanding / next steps + +- **Repackage for the friend:** `npm run dist` (AppImage + deb) with the new icon and + display fixes; the old `frontend/release/` artifacts predate both commits. +- `deferred/SignerConnectionPanel.tsx.wip`: rewrite against the real `useSignerManager` + API (+ either add `react-router-dom` or drop the import) before reinstating. +- Consider a taskbar-visible test on a pure-X11 session and on GNOME Wayland for the + friend matrix (only Hyprland/Wayland was verifiable here). +- Step 3 sub-step 2 (IPC reroute) is untouched and remains the next signer milestone. +- The user's crash-reporter still holds old core dumps from pre-fix launches + (`coredumpctl rm` clears them). diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index ffbb89c..352d9bf 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,54 +1,203 @@ -# Checkpoint — Release packages with profile metadata fix (2026-09-01) +# Checkpoint — NIP-46 Connection Secrets in the Vault (2026-09-04) ## Where things are - Project: `/home/avi/Projects/Keynctr` -- Git repo: `master` @ `3107508` ("fix: query imported metadata by relay"). -- Working tree: intended profile metadata fix is committed; unrelated UI/branding changes remain uncommitted and untracked. +- 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 -1. **Cosmic branding update.** The Cosmic theme now uses Gold `#F3B407` and Light Blue `#87E6FB`; Cosmic’s dark sidebar presents the logo in white while retaining black-on-white artwork elsewhere. The visible brand is `SOLARPUNK SUMMIT` with `KITCHEN 484`. -2. **Fixed broken profile delete/undo** (previous). `src/ipc.rs` missing `DeleteProfile`/`UndoDelete`; added handlers returning `state_view`; exposed `profiles::delete_profile` outside tests; `api.ts`/`AppProvider` now `call` via `applyState`; `fakeBackend` delete/undo. -2. **Themed auto-dismiss undo bar.** Replaced permanent white bar with `var(--primary-soft)` + `var(--primary)` link `Undo and restore profile`, 5s `useEffect` watching `lastDeleted`, shown in both empty/populated states. -3. **Impeccable themes (light + dark).** `src/settings.rs`: `Theme::Impeccable` + `ImpeccableDark` (serde `impeccable-dark`); `types.ts` union extended; `styles.css` added `:root[data-theme='impeccable']` (oklch 97% lacquer light, kinpaku gold `oklch(77% .13 82)`, Alumni Sans 300) and `impeccable-dark` (oklch 15% lacquer-deep, champagne text), editorial refinements (uppercase labels, 8/3px radii, nav left-border active, card offset bar); `SettingsScreen.tsx` adds both options with live `var(--*)` swatches. -4. **Unified ProfileEditModal.** `frontend/src/components/ProfileEditModal.tsx` — Paper Lift modal (`var(--surface)`/`var(--border)`/`16px`/`0 12px 40px`), 3 tabs (Name/Picture/NIP-05) sharing `renameProfile`/`setProfilePicture`/`setNip05`; `ProfilesScreen.tsx` wires `Edit profile` (secondary) alongside legacy ghosts; `DESIGN.md` + `PRODUCT.md` + `.impeccable/design.json` from `impeccable document` (Vault & Atelier, warm ivory/charcoal/coral, 9 primitives). -5. **Linux installers.** Electron Builder now produces both AppImage and Debian targets. Generated artifacts are `frontend/release/SOLARPUNK SUMMIT-0.1.0.AppImage` and `frontend/release/keynectr_0.1.0_amd64.deb`. -6. **Keynctr branding.** Replaced the visible `SOLARPUNK SUMMIT` / `KITCHEN 484` labels with `Keynctr` in the window, page title, sidebar, home screen, settings, and tests. Rebuilt artifacts: `frontend/release/Keynctr-0.1.0.AppImage` and `frontend/release/keynectr_0.1.0_amd64.deb`. +## What was completed (this session) -## Commits added in this session (newest first) -- `3107508` fix: query imported metadata by relay -- `da33652` fix: query imported metadata by public key -- `bde35bc` fix: import existing profile metadata -- `d2d773a` fix: remove Stardust background dots -- `7605f51` fix: keep NIP-46 signer subscription open -- `76deca6` feat: add Cosmic theme + motion system (palette/branding refinements currently uncommitted) -- `8366af7` feat: add Impeccable themes (light + dark) + unified ProfileEditModal -- `832e114` checkpoint: fix delete/undo + themed auto-dismiss bar -- `9e635e7` fix: restore profile delete/undo and themed auto-dismiss undo bar +**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. -## Verification commands run -- Rust: `cargo fmt --check`, `cargo clippy --all-targets`, `cargo test` (115 passed), and `cargo build --release` passed; existing warnings remain in `src/ipc.rs` and `src/profiles.rs`. -- Frontend: `npm test` (15 files / 99 tests), `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run build`, and `npm run electron:build` passed. -- Packaging: `npx electron-builder --linux AppImage deb` passed; AppImage and Debian files verified with `file`. -- Branding verification: `npm test`, `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run electron:build`, `npm run build`, and `npx electron-builder --linux AppImage deb` passed. -- Frontend build no longer reports the Cosmic font `@import` ordering warning; standard Vite/ESM and ESLint module warnings remain. -- Wayland `--ozone-platform` / `has no handler` messages on `electron:build` are harmless. -- NIP-46 fix: use a persistent subscription instead of the auto-closing `stream_events` helper, so clients can send requests after EOSE. -- Stardust verification: `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` still reports existing issues in three unrelated frontend files. -- Existing-account import verification: `cargo test` (115 passed), `cargo clippy --all-targets`, `cargo fmt --check`, `cargo build --release`, `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` reports existing issues in three frontend files. -- Imported account naming: the name field is no longer required; kind-0 `display_name`/`name` is used automatically, with a shortened npub fallback. Verification: `cargo test` (116 passed), `cargo clippy --all-targets`, `cargo fmt --check`, `cargo build --release`, `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` still reports existing issues in three frontend files. -- Corrected metadata lookup to query with the derived public-key type directly, preventing silent fallback when importing accounts. -- Release verification: Rust test suite (116 passed), clippy, fmt, release build, frontend tests (99 passed), typecheck, lint, Electron build, production build, and AppImage/Debian packaging passed. Frontend format check retains three existing warnings. +- **`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. -## How to resume / reproduce -GUI (Cosmic): `cargo build --release && cd frontend && npm run build && npm run electron:build && npm start` (or dev: `npm run dev` in one terminal + `NOSTR_GUI_DEV_URL=http://localhost:5173 npm start` in second). Settings → Appearance → `Cosmic — Stardust`. CLI: `cargo run -- settings set theme cosmic`. -- Build installers: `cd frontend && npm run build && npm run electron:build && npx electron-builder --linux AppImage deb`. Install the `.deb` with `sudo apt install ./release/keynectr_0.1.0_amd64.deb`, or run the AppImage with `./release/SOLARPUNK\ SUMMIT-0.1.0.AppImage`. -- Current installers: `sudo apt install ./release/keynectr_0.1.0_amd64.deb`, or `./release/Keynctr-0.1.0.AppImage`. -- Signer GUI: unlock vault, open `Signer`, select remote signer in the client, paste its `nostrconnect://` URI, then approve requests. CLI: `cargo run --release -- signer connect `. -- Stardust GUI: select `Settings -> Appearance -> Cosmic - Stardust`, then restart the frontend to load the updated CSS bundle. -- Import GUI: restart after rebuilding, open `Profiles -> Add existing account`, enter only the private key, and Keynctr will derive the profile name and metadata from the network. -- Release artifacts: `frontend/release/Keynctr-0.1.0.AppImage` and `frontend/release/keynectr_0.1.0_amd64.deb`, verified with `file`. +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`. -## Outstanding / next-step items -- Undo restores with empty `secret_key` (stores `ProfileSummary`); needs `StoredProfile` in `undo_history` for full secret recovery. -- `.opencode/`, `COSMIC_THEME.md`, and `KeynectrAppIconPossibility02.jpeg` remain untracked; no commit was created in this session. -- Installer artifacts are local build outputs under `frontend/release/` and are not committed. +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) +was triaged into **three logical, independently-verifiable commits** in a prior session, +and the packaging fix landed in `114b343`. Order is +`a → c → b → (packaging)`: `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. + +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. +4. **Packaging icon restored (this session, `114b343`)** — `frontend/build/icon.png` + was deleted from the working tree, so electron-builder had no Linux icon and every + AppImage/deb it emitted carried the generic Electron placeholder. The icon was + **regenerated from `KeynectrAppIconPossibility02.jpeg`** (not resized): the white + (254) JPEG background was cut to transparent (alpha derived from luma), the art was + flattened to a square canvas with symmetric padding, ink kept pure black (RGB 0,0,0), + and exported as **512x512 RGBA**. The single declared config path + (`linux.icon: build/icon.png`) was kept single — no `build/linux/` fan-out — because + the 512 master satisfies both targets: AppImage downscales to 256 internally (≥256 + required) and the deb installs `usr/share/icons/hicolor/512x512/apps/keynectr.png`, + matching the generated `.desktop` `Icon=keynectr`. + +### 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. + +## Commits added this session (newest first) +| Hash | Message | +|------|---------| +| `510cb65` | feat(signer): store NIP-46 connection secrets in the vault, keyed by VaultRef | + +(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 `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**: `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 + `src/main.rs`. +- GUI: from `frontend/`, `npm run electron:build && electron .` (prod) or `npm run + start:dev` with `NOSTR_GUI_DEV_URL`. +- Packaging: from `frontend/`, `npm run dist` (unpacked dir) or `npx electron-builder + --linux AppImage deb` (declared targets) → artifacts in `release/`. +- 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. + +## Still untracked (do NOT lose; do NOT commit the hygiene junk) +- **Source JPEG** `KeynectrAppIconPossibility02.jpeg` — intentionally **untracked** + (the icon is now derived from it; the JPEG itself is not a build input and stays out + of git, per instruction). +- Pre-existing untracked hygiene leftovers (out of scope, address in the Step-7 hygiene + pass): `COSMIC_THEME.md`, `.opencode/`, `.impeccable/`, `.directory`. +- **`frontend/build/icon.png` is no longer a leftover** — it was regenerated and + committed in `114b343` this session. The prior "deleted icon" item is closed. +- Build artifacts `release/` and `dist/` are gitignored and not committed. +- `profiles_vault.json*` and `target/` remain correctly untracked and uncommitted + (vault is gitignored). + +## On the record (not fixed, per instruction) +- **`package.json` → `homepage` still reads `https://github.com/avi/Keynctr`.** This is + the Step-7 rename/hygiene item and was **left untouched** in this session; noted here + so it is on the record rather than silently fixed. + +## Deferred / next steps (unchanged, plus new) +- **Step 2 (packaging): DONE.** Icon restored, `npm run dist` + declared targets green, + 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); `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`. 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* + is duplicated on `App` (app-level), and `App` holds three separate signer handles + (`embedded_signer`, `nip46_signer`, `nip46_bunker_signer`). The IPC dispatcher + (`src/ipc.rs`) branches on `signer_mode`/handle presence in ~12 sites. +- 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 (includes the `homepage` → correct repo fix noted + above), delete legacy Python, migrate root `profiles_vault.json*` into + `~/.local/share/keynectr`, and a Prettier format pass to clear the 5 pre-existing + `format:check` failures. +- 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. diff --git a/Cargo.lock b/Cargo.lock index b3bd988..8dd71c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -19,8 +19,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -30,13 +41,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "argon2" version = "0.5.3" @@ -45,7 +76,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -55,6 +86,137 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "async-utility" version = "0.3.2" @@ -87,6 +249,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -176,7 +350,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -188,6 +362,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -197,6 +380,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -221,7 +426,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -247,8 +461,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -259,7 +473,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -270,11 +484,64 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -284,6 +551,21 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -295,13 +577,31 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -316,11 +616,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -338,6 +650,39 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -348,6 +693,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -358,6 +723,12 @@ dependencies = [ "serde", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -421,6 +792,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -537,6 +921,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heapless" version = "0.8.0" @@ -547,6 +937,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -571,6 +967,24 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -587,6 +1001,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -691,16 +1114,36 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -724,25 +1167,55 @@ version = "0.1.0" dependencies = [ "aes-gcm", "argon2", + "async-trait", "base64", "getrandom 0.2.17", "hex", + "keyring", "nostr", "nostr-sdk", "rpassword", "serde", "serde_json", + "sha2 0.10.9", "tokio", "uuid", "zeroize", ] +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -776,6 +1249,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mio" version = "1.2.2" @@ -803,7 +1285,7 @@ dependencies = [ "bech32", "bip39", "bitcoin_hashes 1.2.0", - "cbc", + "cbc 0.1.2", "chacha20", "chacha20poly1305", "faster-hex", @@ -861,6 +1343,78 @@ dependencies = [ "universal-time", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -879,6 +1433,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -925,13 +1495,38 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -943,7 +1538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -966,6 +1561,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1080,6 +1684,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -1115,6 +1748,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.43" @@ -1181,6 +1827,48 @@ dependencies = [ "cc", ] +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes 0.9.3", + "cbc 0.2.1", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.0", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1224,6 +1912,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "sha1" version = "0.10.7" @@ -1231,8 +1930,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1318,6 +2039,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1483,6 +2217,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1490,9 +2254,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1527,6 +2303,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1548,7 +2335,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -1597,6 +2384,7 @@ checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -1710,6 +2498,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1801,6 +2602,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -1836,6 +2646,87 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -1921,3 +2812,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index f30d41b..6212a43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,6 @@ base64 = "0.22" getrandom = "0.2" zeroize = "1" rpassword = "7" +sha2 = "0.10" +async-trait = "0.1" +keyring = "4.2" diff --git a/deferred/original-icons/icon-public-512-white.png b/deferred/original-icons/icon-public-512-white.png new file mode 100644 index 0000000..18ff052 Binary files /dev/null and b/deferred/original-icons/icon-public-512-white.png differ diff --git a/deferred/original-icons/logo-sidebar-338-white.png b/deferred/original-icons/logo-sidebar-338-white.png new file mode 100644 index 0000000..fcb48ba Binary files /dev/null and b/deferred/original-icons/logo-sidebar-338-white.png differ diff --git a/frontend/build/icon.png b/frontend/build/icon.png index 18ff052..174f479 100644 Binary files a/frontend/build/icon.png and b/frontend/build/icon.png differ diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index d973028..b8be8f2 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from import { lookup } from 'node:dns/promises'; import { spawn, type ChildProcess } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; import * as net from 'node:net'; import * as path from 'node:path'; @@ -28,6 +28,306 @@ protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } }, ]); +// ----------------------------------------------------------------------------- +// Linux display-server compatibility (X11, Wayland, Hyprland, ...) +// +// Hyprland (and every Wayland session running XWayland) exports BOTH $DISPLAY +// and $WAYLAND_DISPLAY, so the platform must be chosen explicitly before +// Chromium initializes. Everything here runs at module load — before +// `app.whenReady()` — so the switches take effect. +// +// If the first attempt dies before the window ever paints (a common Wayland +// symptom: GPU/dmabuf issues or a broken sandbox), a watchdog relaunches the +// app one rung down a fixed ladder: +// +// 0. as detected, software rendering (safe default) +// 1. the other platform (Wayland -> XWayland, X11 -> Wayland) +// 2. detected platform with the GPU enabled +// 3. the other platform with the GPU enabled +// 4. give up: show an error dialog with the escape hatches below +// +// Escape hatches (environment): +// KEYNCTR_FORCE_X11=1 always use X11/XWayland +// KEYNCTR_FORCE_WAYLAND=1 always use native Wayland +// KEYNCTR_ENABLE_GPU=1 use hardware-accelerated rendering +// KEYNCTR_DISABLE_GPU=1 force software rendering (the default) +// KEYNCTR_NO_RELAUNCH=1 disable the fallback relauncher +// ----------------------------------------------------------------------------- + +/** How long a launch has to prove it works before the watchdog intervenes. */ +const LAUNCH_PROVE_MS = 8_000; +/** The max ladder distance: a marker newer than this means the last launch crashed early. */ +const CRASH_WINDOW_MS = 45_000; + +function detectSessionPlatform(): 'wayland' | 'x11' { + if (process.env.KEYNCTR_FORCE_X11) { + return 'x11'; + } + if (process.env.KEYNCTR_FORCE_WAYLAND) { + return 'wayland'; + } + const sessionType = (process.env.XDG_SESSION_TYPE ?? '').toLowerCase(); + if (sessionType === 'wayland' || process.env.WAYLAND_DISPLAY) { + return 'wayland'; + } + return 'x11'; +} + +const sessionPlatform = detectSessionPlatform(); +const fallbackStep = Number.parseInt(process.env.KEYNCTR_FALLBACK_STEP ?? '0', 10); + +/** + * Per-user marker recording the launch currently in flight. If a previous + * process left one behind and it is recent, that launch died before its + * window ever painted — so this process continues the fallback ladder. + */ +function startupMarkerPath(): string { + return path.join(app.getPath('temp'), 'keynctr-startup.json'); +} + +interface StartupMarker { + step: number; + platform: string; + startedAt: number; + /** Set when the app shut down on purpose (not a crash before first paint). */ + clean?: boolean; +} + +function readStartupMarker(): StartupMarker | null { + try { + const parsed = JSON.parse(readFileSync(startupMarkerPath(), 'utf8')) as StartupMarker; + if (typeof parsed.step === 'number' && typeof parsed.startedAt === 'number') { + return parsed; + } + } catch { + // No marker (or unreadable): nothing to learn. + } + return null; +} + +function writeStartupMarker(step: number): void { + try { + writeFileSync( + startupMarkerPath(), + JSON.stringify({ step, platform: sessionPlatform, startedAt: Date.now() }), + ); + } catch { + // Marker is best-effort only. + } +} + +function clearStartupMarker(): void { + try { + rmSync(startupMarkerPath(), { force: true }); + } catch { + // Best-effort. + } +} + +/** + * Stamp the marker as a clean exit so the next launch does not mistake an + * intentional quit (e.g. closing the window a few seconds after it opened) + * for a crash before first paint. + */ +function markStartupCleanExit(): void { + const marker = readStartupMarker(); + if (marker && !marker.clean) { + try { + writeFileSync(startupMarkerPath(), JSON.stringify({ ...marker, clean: true })); + } catch { + // Best-effort. + } + } +} + +/** Environment for fallback ladder rung `step` (0 keeps the detected setup). */ +function envForFallbackStep(step: number): Record { + const env: Record = { KEYNCTR_FALLBACK_STEP: String(step) }; + const other = sessionPlatform === 'wayland' ? 'x11' : 'wayland'; + switch (step) { + case 1: + if (other === 'x11') { + env.KEYNCTR_FORCE_X11 = '1'; + } else { + env.KEYNCTR_FORCE_WAYLAND = '1'; + } + break; + case 2: + if (sessionPlatform === 'x11') { + env.KEYNCTR_FORCE_WAYLAND = '1'; // X11 failed: try native Wayland (still software GL) + } else { + env.KEYNCTR_ENABLE_GPU = '1'; // Wayland failed: retry Wayland with hardware GL + env.KEYNCTR_DISABLE_GPU = ''; // clear any user override that would block the retry + } + break; + case 3: + if (other === 'x11') { + env.KEYNCTR_FORCE_X11 = '1'; + } else { + env.KEYNCTR_FORCE_WAYLAND = '1'; + } + env.KEYNCTR_ENABLE_GPU = '1'; + env.KEYNCTR_DISABLE_GPU = ''; + break; + } + return env; +} + +function describeFallbackStep(step: number): string { + const other = sessionPlatform === 'wayland' ? 'XWayland (X11)' : 'native Wayland'; + switch (step) { + case 1: + return `${other}, software rendering`; + case 2: + return sessionPlatform === 'wayland' + ? `${sessionPlatform} with hardware acceleration` + : 'native Wayland, software rendering'; + case 3: + return `${other} with hardware acceleration`; + default: + return 'default settings'; + } +} + +/** Relaunch this executable with extra environment variables, then quit. */ +function relaunchLinux(extraEnv: Record): void { + // On AppImage, process.execPath is the temporary FUSE mount, which is torn + // down when this process exits — relaunch the original file instead. + const target = process.env.APPIMAGE || process.execPath; + try { + const child = spawn(target, process.argv.slice(1), { + env: { ...process.env, ...extraEnv }, + detached: true, + stdio: 'ignore', + }); + child.unref(); + app.exit(0); + } catch (err) { + console.error('[linux] relaunch failed:', err); + } +} + +/** Set when the fallback ladder is exhausted: shown once Electron is ready. */ +let pendingGiveUpDialog: string | null = null; + +/** + * Decide, at startup, whether the previous launch crashed before painting a + * window and, if so, relaunch one rung further down the fallback ladder. + * Called once at module load, before the Ozone switches below are applied. + */ +function evaluateLinuxStartup(): void { + if (process.platform !== 'linux' || process.env.KEYNCTR_NO_RELAUNCH) { + clearStartupMarker(); + return; + } + const marker = readStartupMarker(); + const crashedEarly = + marker !== null && !marker.clean && Date.now() - marker.startedAt < CRASH_WINDOW_MS; + + if (fallbackStep > 0) { + // We are already a relaunch: record this attempt (cleared once the window + // paints and stays up). Never cascade from here — each crash advances the + // ladder exactly one rung on the NEXT launch. + if (marker && crashedEarly) { + console.warn( + `[linux] previous launch (${describeFallbackStep(marker.step)}) exited before its ` + + 'window was ready.', + ); + } + writeStartupMarker(fallbackStep); + return; + } + + if (marker && crashedEarly) { + const nextStep = marker.step + 1; + if (nextStep <= 3) { + console.warn( + `[linux] previous launch (${describeFallbackStep(marker.step)}) exited before its ` + + `window was ready; retrying with ${describeFallbackStep(nextStep)}.`, + ); + relaunchLinux(envForFallbackStep(nextStep)); + return; // relaunchLinux exits the process. + } + // Ladder exhausted. Stay on the safest default (detected platform, + // software rendering) and tell the user about the escape hatches instead + // of relaunching forever. + delete process.env.KEYNCTR_ENABLE_GPU; + pendingGiveUpDialog = + 'Keynctr failed to start with every display configuration (default, ' + + `${describeFallbackStep(1)}, ${describeFallbackStep(2)}, ${describeFallbackStep(3)}).\n\n` + + 'This attempt uses the most compatible mode. If it still fails, force a ' + + 'configuration from a terminal, e.g.:\n' + + ' KEYNCTR_FORCE_X11=1 keynctr (XWayland)\n' + + ' KEYNCTR_FORCE_WAYLAND=1 keynctr (native Wayland)\n' + + ' KEYNCTR_ENABLE_GPU=1 keynctr (hardware acceleration)\n'; + } + // Fresh launch: record the attempt; cleared once the window proves itself. + clearStartupMarker(); + writeStartupMarker(0); +} + +if (process.platform === 'linux') { + // Runs FIRST so the env overrides below (and the GPU switch) see any + // force-flags this process just adopted from the fallback ladder. + evaluateLinuxStartup(); + + if (detectSessionPlatform() === 'wayland') { + app.commandLine.appendSwitch('ozone-platform', 'wayland'); + } else { + app.commandLine.appendSwitch('ozone-platform', 'x11'); + } + + // Chromium refuses to sandbox when running as root. + if (typeof process.getuid === 'function' && process.getuid() === 0) { + app.commandLine.appendSwitch('no-sandbox'); + } + + // SUID sandbox pre-flight: if the helper exists but is not setuid-root AND + // unprivileged user namespaces are blocked (Ubuntu 24.04 AppArmor, hardened + // kernels, some containers), Chromium aborts before any window appears. + // Start without the sandbox instead of refusing to start. + if (app.isPackaged) { + try { + const helper = path.join(path.dirname(process.execPath), 'chrome-sandbox'); + if (existsSync(helper) && (statSync(helper).mode & 0o4000) === 0) { + const procFlag = (file: string, blockedValue: string): boolean => { + try { + return readFileSync(file, 'utf8').trim() === blockedValue; + } catch { + return false; // Kernel without the knob: assume allowed. + } + }; + const cloneBlocked = procFlag('/proc/sys/kernel/unprivileged_userns_clone', '0'); + const apparmorRestricted = procFlag( + '/proc/sys/kernel/apparmor_restrict_unprivileged_userns', + '1', + ); + if (cloneBlocked || apparmorRestricted) { + console.warn( + '[linux] chrome-sandbox is not setuid and unprivileged user namespaces are ' + + 'restricted; starting with the sandbox disabled.', + ); + app.commandLine.appendSwitch('no-sandbox'); + } + } + } catch (err) { + console.error('[linux] sandbox pre-flight failed:', err); + } + } + + // Chromium's hardware GL path is unreliable under Wayland compositors on + // some Mesa/EGL setups (observed: the GPU process segfaults inside + // eglCreateWindowSurface on Intel Iris Xe under Hyprland, so the window + // never paints). Software rendering costs nothing noticeable for this app, + // so hardware acceleration is off by default on Linux; set + // KEYNCTR_ENABLE_GPU=1 to opt back in. + const gpuEnabled = Boolean(process.env.KEYNCTR_ENABLE_GPU) && !process.env.KEYNCTR_DISABLE_GPU; + if (!gpuEnabled) { + app.disableHardwareAcceleration(); + app.commandLine.appendSwitch('disable-gpu-compositing'); + } +} + /** * Content-Security-Policy applied to every page this app loads. * @@ -38,12 +338,12 @@ protocol.registerSchemesAsPrivileged([ * (HMR websocket included). */ const CSP_PROD = - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + - "connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " + + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "connect-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; " + "base-uri 'none'; form-action 'none'"; const CSP_DEV = - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " + - "connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " + + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; " + "object-src 'none'; base-uri 'none'; form-action 'none'"; /** The CSP for a URL this window may load, or `null` for anywhere else. */ @@ -195,6 +495,7 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'init', 'get_state', 'create_profile', + 'import_profile', 'select_profile', 'publish_profile_metadata', 'set_profile_picture', @@ -217,10 +518,26 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'lock_vault', 'remove_vault_password', 'reveal_secret_key', + 'export_secret_key', + // Legacy bunker 'signer_connect', 'signer_disconnect', 'signer_status', 'signer_approve', + // New signer modes (default: nip46_client most secure) + 'signer_mode_get', + 'signer_mode_set', + 'embedded_signer_status', + 'embedded_signer_approve', + 'nip46_connect', + 'nip46_disconnect', + 'nip46_status', + 'nip46_approve', + // Sidecar (local isolated signer, planned) + 'sidecar_connect', + 'sidecar_disconnect', + 'sidecar_status', + 'sidecar_approve', ]); /** True when `method` may be dispatched. Unknown methods never reach the backend. */ @@ -515,6 +832,7 @@ function createWindow(): void { icon: resolveWindowIcon(), backgroundColor: '#f6f4f0', autoHideMenuBar: true, + show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -522,6 +840,29 @@ function createWindow(): void { }, }); + // Always reveal the window once it has painted. On Linux the startup + // watchdog additionally waits LAUNCH_PROVE_MS before clearing the marker: + // if the process dies before that, the next launch advances the fallback + // ladder one rung. + window.once('ready-to-show', () => { + window.show(); + }); + if (process.platform === 'linux' && !process.env.KEYNCTR_NO_RELAUNCH) { + let proveTimer: ReturnType | null = null; + window.once('ready-to-show', () => { + proveTimer = setTimeout(() => { + proveTimer = null; + clearStartupMarker(); + }, LAUNCH_PROVE_MS); + }); + window.webContents.on('render-process-gone', () => { + if (proveTimer) { + clearTimeout(proveTimer); + proveTimer = null; + } + }); + } + const devServer = process.env.NOSTR_GUI_DEV_URL; if (devServer) { void window.loadURL(devServer); @@ -648,6 +989,11 @@ app.whenReady().then(() => { createWindow(); + if (pendingGiveUpDialog) { + dialog.showErrorBox('Keynctr — display problems', pendingGiveUpDialog); + pendingGiveUpDialog = null; + } + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -656,6 +1002,7 @@ app.whenReady().then(() => { }); app.on('before-quit', () => { + markStartupCleanExit(); if (backend) { backend.kill(); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e127ff..88f4e9c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "keynectr", "version": "0.1.0", "dependencies": { + "nostr-tools": "^2.25.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -862,6 +863,45 @@ "node": ">=10" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", @@ -1202,6 +1242,66 @@ "dev": true, "license": "MIT" }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -5007,6 +5107,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nostr-tools": { + "version": "2.25.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.25.1.tgz", + "integrity": "sha512-k/yCjpjHR18n9E6kCh1MdlP+fGZnP9UkuIDt1cHF87jqAE6ohOnZGFuQXPfWhDaRzNj9TQgJZPAPkCqOylqtAg==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/nwsapi": { "version": "2.2.24", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", @@ -6249,7 +6390,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 8fa43e2..83ec6e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,10 +22,12 @@ "format:check": "prettier --check .", "electron:build": "tsc -p tsconfig.electron.json", "start": "npm run electron:build && electron .", + "start:dev": "npm run electron:build && NOSTR_GUI_DEV_URL=${NOSTR_GUI_DEV_URL:-http://localhost:5173} electron .", "desktop:install": "bash scripts/install-desktop-entry.sh", "dist": "npm run build && npm run electron:build && electron-builder --linux dir" }, "dependencies": { + "nostr-tools": "^2.25.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/frontend/public/icon.png b/frontend/public/icon.png index 18ff052..a4925df 100644 Binary files a/frontend/public/icon.png and b/frontend/public/icon.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be6814f..4646a00 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { ProfilesScreen } from './screens/ProfilesScreen'; import { ComposeScreen } from './screens/ComposeScreen'; import { RelaysScreen } from './screens/RelaysScreen'; import { SignerScreen } from './screens/SignerScreen'; +import { SignerModeScreen } from './screens/SignerModeScreen'; import { SettingsScreen } from './screens/SettingsScreen'; import { CreateProfileModal } from './screens/CreateProfileModal'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; @@ -74,6 +75,7 @@ function Shell() { {screen === 'compose' && } {screen === 'relays' && } {screen === 'signer' && } + {screen === 'signer-mode' && } {screen === 'settings' && } setCreateOpen(false)} /> diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png index fcb48ba..d366bf7 100644 Binary files a/frontend/src/assets/logo.png and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/ExportSecretKeyModal.tsx b/frontend/src/components/ExportSecretKeyModal.tsx new file mode 100644 index 0000000..6aba1c4 --- /dev/null +++ b/frontend/src/components/ExportSecretKeyModal.tsx @@ -0,0 +1,209 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { BackendError } from '../lib/api'; +import { useApp } from '../state/AppProvider'; +import type { RevealedKey } from '../lib/types'; +import { Alert } from './Alert'; +import { Button } from './Button'; +import { CopyButton } from './CopyButton'; +import { ErrorText } from './ErrorText'; +import { Modal } from './Modal'; + +interface ExportSecretKeyModalProps { + open: boolean; + onClose: () => void; + profile: { label: string; npub: string } | null; +} + +type Phase = 'form' | 'exporting' | 'revealed' | 'error'; + +/** + * Exports a profile's secret key with fresh passphrase re-authentication. + * + * Every export requires the vault passphrase and a human-readable reason, + * regardless of whether the vault is already unlocked. The key is never + * stored in component state beyond the revealed display phase, and all + * sensitive state is cleared when the modal closes. + */ +export function ExportSecretKeyModal({ open, onClose, profile }: ExportSecretKeyModalProps) { + const { exportSecretKey } = useApp(); + const [phase, setPhase] = useState('form'); + const [revealed, setRevealed] = useState(null); + const [password, setPassword] = useState(''); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null); + const passwordRef = useRef(null); + + const clearState = () => { + setPhase('form'); + setRevealed(null); + setPassword(''); + setReason(''); + setBusy(false); + setError(null); + setFatal(null); + }; + + useEffect(() => { + if (open && profile) { + clearState(); + // Focus password field after modal opens + setTimeout(() => passwordRef.current?.focus(), 0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, profile?.npub]); + + const handleClose = () => { + clearState(); + onClose(); + }; + + const trimmedReason = reason.trim(); + const canSubmit = password.length > 0 && trimmedReason.length > 0 && !busy; + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit || !profile) { + return; + } + setBusy(true); + setError(null); + setFatal(null); + try { + const key = await exportSecretKey(profile.npub, password, trimmedReason); + setRevealed(key); + setPhase('revealed'); + // Clear password and reason immediately after successful export + setPassword(''); + setReason(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const code = err instanceof BackendError ? err.code : undefined; + + // Map specific error codes to user-friendly messages + if (code === 'wrong_password') { + setError(msg); + setPhase('form'); + passwordRef.current?.select(); + } else if (code === 'profile_not_found') { + setFatal({ message: 'That profile is not stored on this computer.' }); + setPhase('error'); + } else if (code === 'external_signer_not_connected' || code === 'external_signer_identity_mismatch') { + setFatal({ + message: 'This profile uses an external signer. Secret key export is not possible for externally managed accounts.', + }); + setPhase('error'); + } else { + setFatal({ + message: msg, + details: err instanceof BackendError ? err.details : undefined, + }); + setPhase('error'); + } + } finally { + setBusy(false); + } + }; + + const title = `Export secret key${profile ? ` — ${profile.label}` : ''}`; + + return ( + + {phase === 'form' && ( +
+ + Exporting a secret key creates an audit entry. The key itself is never stored in logs. + + +
+ + setPassword(e.target.value)} + autoComplete="current-password" + disabled={busy} + aria-describedby={error ? 'export-password-error' : undefined} + aria-invalid={error ? true : undefined} + /> + {error && {error}} +
+ +
+ + setReason(e.target.value)} + placeholder="e.g. backup, migration, device transfer" + disabled={busy} + /> +
+ +
+ + +
+
+ )} + + {phase === 'error' && fatal && ( +
+ + {fatal.message} + +
+ +
+
+ )} + + {phase === 'revealed' && revealed && ( +
+ + Anyone who has this key can fully control the profile: publish as it, sign messages, and + move its funds. Never paste it into chat, logs, or screenshots. Store it offline and + back it up. + + +
+
+ Private key (hex) + {revealed.hex} +
+ +
+ +
+
+ Private key (nsec) + {revealed.nsec} +
+ +
+ +

+ The nsec1… form is what most Nostr wallets and clients import. It encodes + exactly the same key as the hex form above. +

+ +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 318d3d6..ef787ca 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -16,7 +16,8 @@ export type IconName = | 'shield' | 'publish' | 'external' - | 'key'; + | 'key' + | 'server'; const PATHS: Record = { home: ( @@ -101,6 +102,12 @@ const PATHS: Record = { ), + server: ( + <> + + + + ), }; interface IconProps { diff --git a/frontend/src/components/ShowSecretKeyModal.tsx b/frontend/src/components/ShowSecretKeyModal.tsx deleted file mode 100644 index ae1eb40..0000000 --- a/frontend/src/components/ShowSecretKeyModal.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useEffect, useRef, useState, type FormEvent } from 'react'; -import { BackendError } from '../lib/api'; -import { useApp } from '../state/AppProvider'; -import type { RevealedKey } from '../lib/types'; -import { Alert } from './Alert'; -import { Button } from './Button'; -import { CopyButton } from './CopyButton'; -import { ErrorText } from './ErrorText'; -import { Modal } from './Modal'; -import { Spinner } from './Spinner'; - -interface ShowSecretKeyModalProps { - open: boolean; - onClose: () => void; - /** The profile whose secret key is being revealed. */ - profile: { label: string; npub: string } | null; -} - -type Phase = 'loading' | 'unlock' | 'revealed' | 'error'; - -/** - * Shows a profile's secret key (hex + nsec) after unlocking the vault. - * - * When the vault is password-protected and still locked, the modal asks for - * the password inline, unlocks, and then reveals the key. The secret key is - * only ever fetched from the backend, never stored in state before reveal. - */ -export function ShowSecretKeyModal({ open, onClose, profile }: ShowSecretKeyModalProps) { - const { revealSecretKey, unlockVault } = useApp(); - const [phase, setPhase] = useState('loading'); - const [revealed, setRevealed] = useState(null); - const [password, setPassword] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null); - const inputRef = useRef(null); - const unlockErrorId = 'show-secret-unlock-error'; - - useEffect(() => { - if (open && profile) { - setPhase('loading'); - setRevealed(null); - setPassword(''); - setError(null); - setFatal(null); - setBusy(false); - void reveal(profile.npub); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, profile?.npub]); - - const reveal = async (npub: string) => { - setBusy(true); - setError(null); - setFatal(null); - try { - const key = await revealSecretKey(npub); - setRevealed(key); - setPhase('revealed'); - } catch (err) { - if (err instanceof BackendError && err.code === 'vault_locked') { - setPhase('unlock'); - return; - } - setFatal({ - message: err instanceof Error ? err.message : String(err), - details: err instanceof BackendError ? err.details : undefined, - }); - setPhase('error'); - } finally { - setBusy(false); - } - }; - - const canSubmit = password.length > 0 && !busy; - - const onUnlock = async (event: FormEvent) => { - event.preventDefault(); - if (!canSubmit) { - return; - } - setBusy(true); - setError(null); - try { - await unlockVault(password); - setPassword(''); - if (profile) { - await reveal(profile.npub); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setPassword(''); - setBusy(false); - inputRef.current?.focus(); - } - }; - - const title = `Secret key${profile ? ` — ${profile.label}` : ''}`; - - return ( - - {phase === 'loading' && } - - {phase === 'unlock' && ( -
- - This profile's keys are password-protected. Enter the vault password to reveal the - secret key. The password itself is never saved. - -
- - setPassword(event.target.value)} - autoComplete="current-password" - autoFocus - aria-describedby={error ? unlockErrorId : undefined} - aria-invalid={error ? true : undefined} - disabled={busy} - /> - {error && {error}} -
-
- - -
-
- )} - - {phase === 'error' && fatal && ( -
- - {fatal.message} - -
- -
-
- )} - - {phase === 'revealed' && revealed && ( -
- - Anyone who has this key can fully control the profile: publish as it, sign messages, and - move its funds. Never paste it into chat, logs, or screenshots. Store it offline and - back it up. - - -
-
- Private key (hex) - {revealed.hex} -
- -
- -
-
- Private key (nsec) - {revealed.nsec} -
- -
- -

- The nsec1… form is what most Nostr wallets and clients import. It encodes - exactly the same key as the hex form above. -

- -
- -
-
- )} -
- ); -} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9a50167..db3d22e 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -11,7 +11,8 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [ { id: 'feed', label: 'Feed', icon: 'list' }, { id: 'compose', label: 'Compose', icon: 'edit' }, { id: 'relays', label: 'Relays', icon: 'relay' }, - { id: 'signer', label: 'Signer', icon: 'key' }, + { id: 'signer-mode', label: 'Signer Mode', icon: 'key' }, + { id: 'signer', label: 'Signer (Bunker)', icon: 'server' }, { id: 'settings', label: 'Settings', icon: 'settings' }, ]; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8f0f7d6..1bfc078 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,15 +1,18 @@ import type { AppState, BackendResponse, + EmbeddedSignerStatus, FeedItem, LinkPreview, MetadataPublishReport, + Nip46SignerStatus, PickedImage, ProfileSummary, PublishReport, RelayTestResult, RevealedKey, Settings, + SignerMode, SignerStatus, UpdateApplyReport, UpdateCheckReport, @@ -52,6 +55,8 @@ export const api = { getState: () => call('get_state'), createProfile: (label: string, settings?: Settings) => call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, settings }), + importProfile: (label: string, secret: string) => + call<{ profile: ProfileSummary; state: AppState }>('import_profile', { label, secret }), selectProfile: (npub: string) => call('select_profile', { npub }), publishProfileMetadata: (npub: string) => call('publish_profile_metadata', { npub }), @@ -96,10 +101,29 @@ export const api = { unlockVault: (password: string) => call('unlock_vault', { password }), lockVault: () => call('lock_vault'), removeVaultPassword: (password: string) => call('remove_vault_password', { password }), - revealSecretKey: (npub: string) => call('reveal_secret_key', { npub }), + exportSecretKey: (npub: string, password: string, reason: string) => + call('export_secret_key', { npub, password, reason }), pickImages: () => call('pick_image'), uploadImage: (token: string) => call('upload_image', { token }), linkPreview: (url: string) => call('link_preview', { url }), + // Signer mode management + signerModeGet: () => call<{ mode: SignerMode }>('signer_mode_get'), + signerModeSet: (mode: SignerMode) => call('signer_mode_set', { mode }), + + // Embedded signer + embeddedSignerStatus: () => call('embedded_signer_status'), + embeddedSignerApprove: (index: number, approved: boolean) => + call('embedded_signer_approve', { index, approved }), + + // NIP-46 client signer + nip46Connect: (uri: string, label: string) => + call('nip46_connect', { uri, label }), + nip46Disconnect: () => call('nip46_disconnect'), + nip46Status: () => call('nip46_status'), + nip46Approve: (id: string, approved: boolean) => + call('nip46_approve', { id, approved }), + + // Legacy NIP-46 bunker (deprecated, kept for compatibility) signerConnect: (uri: string) => call('signer_connect', { uri }), signerDisconnect: () => call('signer_disconnect'), signerStatus: () => call('signer_status'), diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index ee97e85..e625286 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,4 +1,5 @@ -export type Screen = 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings'; +export type Screen = + 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'signer-mode' | 'settings'; export const SCREEN_TITLES: Record = { home: 'Home', @@ -6,6 +7,7 @@ export const SCREEN_TITLES: Record = { profiles: 'Profiles', compose: 'Compose', relays: 'Relays', - signer: 'Signer', + signer: 'Signer (Bunker)', + 'signer-mode': 'Signer Mode', settings: 'Settings', }; diff --git a/frontend/src/lib/publications.ts b/frontend/src/lib/publications.ts new file mode 100644 index 0000000..4b97ae0 --- /dev/null +++ b/frontend/src/lib/publications.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { FeedItem, PublicationStatus, RelayConfig } from './types'; +import { useApp } from '../state/AppProvider'; + +/** Determine whether a note is fully or partially published. */ +export function computePublicationStatus( + itemRelays: string[], + enabledRelays: RelayConfig[], +): PublicationStatus { + const enabled = enabledRelays.filter((r) => r.enabled).map((r) => r.url); + if (enabled.length === 0) { + return 'fully_published'; + } + const served = new Set(itemRelays); + const allServed = enabled.every((url) => served.has(url)); + return allServed ? 'fully_published' : 'partially_published'; +} + +export interface ProfilePublication extends FeedItem { + publicationStatus: PublicationStatus; +} + +export interface UseProfilePublicationsResult { + publications: ProfilePublication[]; + fullyPublished: ProfilePublication[]; + loading: boolean; + error: string | null; +} + +export function useProfilePublications(): UseProfilePublicationsResult { + const { state, feedGet } = useApp(); + const [publications, setPublications] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const authorNpub = state?.active_profile?.npub ?? null; + + const load = useCallback(async () => { + if (!authorNpub) { + setPublications([]); + return; + } + setLoading(true); + setError(null); + try { + const items = await feedGet(50, false, authorNpub); + const enabledRelays = state?.settings.relays ?? []; + const enriched: ProfilePublication[] = items.map((item) => ({ + ...item, + publicationStatus: computePublicationStatus(item.relays, enabledRelays), + })); + enriched.sort((a, b) => b.created_at - a.created_at); + setPublications(enriched); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setPublications([]); + } finally { + setLoading(false); + } + }, [authorNpub, feedGet, state?.settings.relays]); + + useEffect(() => { + void load(); + }, [load]); + + const fullyPublished = publications.filter((p) => p.publicationStatus === 'fully_published'); + + return { publications, fullyPublished, loading, error }; +} diff --git a/frontend/src/lib/signer/SignerManager.ts b/frontend/src/lib/signer/SignerManager.ts new file mode 100644 index 0000000..063e451 --- /dev/null +++ b/frontend/src/lib/signer/SignerManager.ts @@ -0,0 +1,536 @@ +import { nip19, generateSecretKey, finalizeEvent, EventTemplate } from 'nostr-tools'; +import { bytesToHex } from 'nostr-tools/utils'; + +export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client'; + +export interface Keypair { + nsec: string; + npub: string; + privateKey: Uint8Array; + publicKey: Uint8Array; +} + +export interface NostrConnectURI { + uri: string; + signerPubkey: string; + relays: string[]; + secret?: string; +} + +export interface ExternalSignerConnection { + signerPubkey: string; + relays: string[]; + secret?: string; + connected: boolean; + conversationKey?: string; +} + +export class SignerError extends Error { + constructor( + public readonly code: SignerErrorCode, + message: string, + public readonly details?: string, + ) { + super(message); + this.name = 'SignerError'; + } +} + +export type SignerErrorCode = + | 'NO_KEYPAIR' + | 'VAULT_LOCKED' + | 'ACTIVE_SESSION_EXISTS' + | 'INVALID_NOSTRCONNECT_URI' + | 'NO_RELAYS_CONFIGURED' + | 'BUNKER_START_FAILED' + | 'CLIENT_CONNECT_FAILED' + | 'SIGNING_FAILED' + | 'APPROVAL_REJECTED' + | 'APPROVAL_TIMEOUT'; + +export interface SignerState { + mode: SignerMode; + keypair: Keypair | null; + isVaultUnlocked: boolean; + /** Bunker mode (this app acts as signer for other clients) */ + bunker: { + isRunning: boolean; + connectionURI: string | null; + connectedClients: Map; + }; + /** Client mode (this app connects to external signer like Amber) */ + client: { + isConnected: boolean; + signerPubkey: string | null; + relays: string[]; + pendingRequests: Map; + }; + embedded: { + isActive: boolean; + }; +} + +export interface PendingSignRequest { + id: string; + event: EventTemplate; + method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt'; + params: unknown[]; + resolve: (result: string) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +} + +type StateListener = (state: SignerState) => void; + +export class SignerManager { + private state: SignerState = { + mode: 'nip46_client', + keypair: null, + isVaultUnlocked: false, + bunker: { + isRunning: false, + connectionURI: null, + connectedClients: new Map(), + }, + client: { + isConnected: false, + signerPubkey: null, + relays: [], + pendingRequests: new Map(), + }, + embedded: { + isActive: false, + }, + }; + + private listeners: Set = new Set(); + private abortController: AbortController | null = null; + private requestIdCounter = 0; + + /** Subscribe to state changes */ + subscribe(listener: StateListener): () => void { + this.listeners.add(listener); + listener(this.getState()); + return () => this.listeners.delete(listener); + } + + private notify(): void { + for (const listener of this.listeners) { + listener(this.getState()); + } + } + + getState(): Readonly { + return Object.freeze({ ...this.state }); + } + + /** Import a keypair from nsec or generate new one */ + async importKeypair(nsecOrPrivateKey?: string): Promise { + let pk: Uint8Array; + + if (nsecOrPrivateKey) { + try { + const decoded = nip19.decode(nsecOrPrivateKey); + if (decoded.type !== 'nsec') { + throw new SignerError('NO_KEYPAIR', 'Provided key is not a valid nsec'); + } + pk = decoded.data as any; + } catch { + throw new SignerError('NO_KEYPAIR', 'Invalid nsec format'); + } + } else { + pk = generateSecretKey(); + } + + // biome-ignore lint/suspicious/noExplicitAny: Explicit cast for nip19 API + const nsec = nip19.nsecEncode(pk as any); + const npub = nip19.npubEncode(bytesToHex(pk as any)); + + const keypair: Keypair = { + nsec, + npub, + privateKey: pk, + publicKey: pk, + }; + + this.state.keypair = keypair; + this.notify(); + return keypair; + } + + /** Set vault unlock state (called by vault unlock/lock) */ + async setVaultUnlocked(unlocked: boolean): Promise { + this.state.isVaultUnlocked = unlocked; + if (!unlocked) { + await this.stopAll(); + } + this.notify(); + } + + /** Switch signer mode with full validation */ + async setMode(mode: SignerMode): Promise { + if (mode === this.state.mode) return; + + // Stop current mode + switch (this.state.mode) { + case 'embedded': + await this.stopEmbedded(); + break; + case 'nip46_bunker': + await this.stopBunker(); + break; + case 'nip46_client': + await this.disconnectClient(); + break; + } + + // Start new mode + switch (mode) { + case 'embedded': + await this.startEmbedded(); + break; + case 'nip46_bunker': + await this.startBunker(); + break; + case 'nip46_client': + // Client mode requires explicit connection via connectToExternalSigner() + break; + } + + this.state.mode = mode; + this.notify(); + } + + /** Start embedded signer (local signing) */ + private async startEmbedded(): Promise { + if (!this.state.keypair || !this.state.isVaultUnlocked) { + throw new SignerError( + this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR', + this.state.keypair + ? 'Vault locked: Please unlock to use embedded signer.' + : 'No keypair found: Please import a key first.', + ); + } + this.state.embedded.isActive = true; + this.notify(); + } + + /** Stop embedded signer */ + private async stopEmbedded(): Promise { + this.state.embedded.isActive = false; + this.notify(); + } + + // ==================== BUNKER MODE (this app acts as signer) ==================== + + /** Generate nostrconnect:// URI for bunker mode */ + generateBunkerURI(relays: string[], secret?: string): NostrConnectURI { + if (!this.state.keypair) { + throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.'); + } + if (relays.length === 0) { + throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46 connection.'); + } + + const signerPubkey = this.state.keypair.npub; + const params = new URLSearchParams(); + for (const relay of relays) { + params.append('relay', relay); + } + if (secret) { + params.append('secret', secret); + } + + const uri = `nostrconnect://${signerPubkey}?${params.toString()}`; + + return { uri, signerPubkey, relays, secret }; + } + + /** Start NIP-46 bunker server (this app acts as signer) */ + async startBunker(relays?: string[]): Promise { + this.validateBunkerPreconditions(); + + const relayList = relays ?? this.getDefaultRelays(); + if (relayList.length === 0) { + throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46.'); + } + + const connectionInfo = this.generateBunkerURI(relayList); + + this.abortController = new AbortController(); + const { signal } = this.abortController; + + try { + await this.runBunkerServer(signal); + } catch (error) { + this.state.bunker.isRunning = false; + this.state.bunker.connectionURI = null; + this.notify(); + throw new SignerError( + 'BUNKER_START_FAILED', + 'Failed to start NIP-46 bunker server', + String(error), + ); + } + + this.state.bunker.isRunning = true; + this.state.bunker.connectionURI = connectionInfo.uri; + this.notify(); + + return connectionInfo; + } + + private validateBunkerPreconditions(): void { + if (!this.state.keypair) { + throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.'); + } + if (!this.state.isVaultUnlocked) { + throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to switch modes.'); + } + if (this.state.bunker.isRunning) { + throw new SignerError('ACTIVE_SESSION_EXISTS', 'Bunker already running.'); + } + if (this.state.client.isConnected) { + throw new SignerError('ACTIVE_SESSION_EXISTS', 'Client mode active. Disconnect first.'); + } + if (this.state.embedded.isActive) { + throw new SignerError('ACTIVE_SESSION_EXISTS', 'Embedded signer active. Stop it first.'); + } + } + + async stopBunker(): Promise { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + this.state.bunker.isRunning = false; + this.state.bunker.connectionURI = null; + this.state.bunker.connectedClients.clear(); + this.notify(); + } + + private async runBunkerServer(signal: AbortSignal): Promise { + // Simplified - real impl would use websocket + NIP-44 + await new Promise((resolve) => { + const checkAbort = () => { + if (signal.aborted) resolve(); + else setTimeout(checkAbort, 100); + }; + checkAbort(); + }); + } + + // ==================== CLIENT MODE (connect TO external signer) ==================== + + /** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */ + parseExternalSignerURI(uri: string): ExternalSignerConnection { + if (!uri.startsWith('nostrconnect://')) { + throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://'); + } + + const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?'); + const signerPubkey = authority; + const params = new URLSearchParams(queryString || ''); + const relays = params.getAll('relay'); + const secret = params.get('secret') || undefined; + + if (!signerPubkey) { + throw new SignerError('INVALID_NOSTRCONNECT_URI', 'Missing signer pubkey in URI'); + } + if (relays.length === 0) { + throw new SignerError('NO_RELAYS_CONFIGURED', 'URI must contain at least one relay'); + } + + return { signerPubkey, relays, secret, connected: false }; + } + + /** Connect to external signer (Amber, Nostr Connect, bunker) using nostrconnect:// URI */ + async connectToExternalSigner(uri: string, relays?: string[]): Promise { + if (this.state.client.isConnected) { + throw new SignerError( + 'ACTIVE_SESSION_EXISTS', + 'Already connected to external signer. Disconnect first.', + ); + } + if (!this.state.keypair) { + throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.'); + } + if (!this.state.isVaultUnlocked) { + throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to connect.'); + } + + const parsed = this.parseExternalSignerURI(uri); + const relayList = relays ?? parsed.relays ?? this.getDefaultRelays(); + + if (relayList.length === 0) { + throw new SignerError( + 'NO_RELAYS_CONFIGURED', + 'No relays configured for NIP-46 client connection.', + ); + } + + // Derive conversation key with external signer + const conversationKey = this.deriveConversationKey(); + + // In real implementation: + // 1. Connect to relays via websocket + // 2. Subscribe to kind 24133 from external signer + // 3. Send 'connect' request with our pubkey + secret + // 4. Handle incoming requests (sign_event, nip44_encrypt, nip44_decrypt) + + // For now, simulate connection + this.state.client = { + isConnected: true, + signerPubkey: parsed.signerPubkey, + relays: relayList, + pendingRequests: new Map(), + }; + + this.notify(); + return { ...parsed, connected: true, conversationKey }; + } + + /** Disconnect from external signer */ + async disconnectClient(): Promise { + // Clear pending requests with rejection + for (const [, request] of this.state.client.pendingRequests) { + clearTimeout(request.timeout); + request.reject(new SignerError('APPROVAL_REJECTED', 'Disconnected from external signer')); + } + this.state.client = { + isConnected: false, + signerPubkey: null, + relays: [], + pendingRequests: new Map(), + }; + this.notify(); + } + + /** Sign event via external signer (request/response with user approval) */ + async signEventViaExternalSigner(event: EventTemplate): Promise { + if (!this.state.client.isConnected) { + throw new SignerError( + 'CLIENT_CONNECT_FAILED', + 'Not connected to external signer. Connect first.', + ); + } + + return this.sendNip46Request('sign_event', [JSON.stringify(event)]); + } + + /** Send NIP-46 request to external signer and wait for approval */ + private async sendNip46Request(method: string, params: unknown[]): Promise { + if (!this.state.client.isConnected) { + throw new SignerError('CLIENT_CONNECT_FAILED', 'Not connected to external signer.'); + } + + const requestId = `req_${++this.requestIdCounter}_${Date.now()}`; + + // Create promise that resolves when user approves/rejects + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.state.client.pendingRequests.delete(requestId); + reject(new SignerError('APPROVAL_TIMEOUT', 'Approval request timed out')); + }, 30000); // 30 second timeout + + const request: PendingSignRequest = { + id: requestId, + event: params[0] as EventTemplate, + method: method as 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt', + params, + resolve, + reject, + timeout, + }; + + this.state.client.pendingRequests.set(requestId, request); + this.notify(); + + // In real implementation: encrypt request with conversation key, publish to relays + // External signer receives, shows UI, user approves, response encrypted and published back + }); + } + + /** Approve or reject a pending external signer request */ + async respondToExternalRequest(requestId: string, approved: boolean): Promise { + const request = this.state.client.pendingRequests.get(requestId); + if (!request) { + throw new SignerError('APPROVAL_REJECTED', 'Request not found or already processed'); + } + + clearTimeout(request.timeout); + this.state.client.pendingRequests.delete(requestId); + + if (approved) { + // In real impl: sign/encrypt with conversation key, publish response + // For now, simulate success + request.resolve('signed_event_id_or_encrypted_result'); + } else { + request.reject(new SignerError('APPROVAL_REJECTED', 'Request rejected by user')); + } + this.notify(); + } + + /** Derive NIP-44 conversation key with another pubkey */ + private deriveConversationKey(): string { + // Real impl: nip44.v2.ConversationKey.derive(mySk, theirPk) + return 'derived_conversation_key'; + } + + /** Stop all signers */ + async stopAll(): Promise { + await this.stopEmbedded(); + await this.stopBunker(); + await this.disconnectClient(); + this.state.mode = 'embedded'; + this.notify(); + } + + /** Sign an event (embedded mode only) */ + async signEvent(event: EventTemplate): Promise { + if (this.state.mode !== 'embedded') { + throw new SignerError( + 'SIGNING_FAILED', + `Signing not available in ${this.state.mode} mode. Use external signer.`, + ); + } + if (!this.state.keypair || !this.state.isVaultUnlocked) { + throw new SignerError( + this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR', + 'Cannot sign: vault locked or no keypair.', + ); + } + + try { + const signedEvent = finalizeEvent(event, this.state.keypair.privateKey); + return signedEvent.id; + } catch (error) { + throw new SignerError('SIGNING_FAILED', 'Failed to sign event', String(error)); + } + } + + private getDefaultRelays(): string[] { + return ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol']; + } +} + +export interface PendingSignRequest { + id: string; + event: EventTemplate; + method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt'; + params: unknown[]; + resolve: (result: string) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +} + +/** React hook for using SignerManager */ +export function useSignerManager(): SignerManager { + return new SignerManager(); +} + +/** React hook for signer state */ +export function useSignerState(): Readonly { + const manager = useSignerManager(); + return manager.getState(); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f64bd77..b3d8cf4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,8 +1,22 @@ -export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; +export type Theme = + 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; + +/** Active signer mode. */ +export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client'; /** Lifecycle of the NIP-46 remote signer. */ export type SignerPhase = 'stopped' | 'connecting' | 'connected'; +/** Approval request details for user confirmation. */ +export interface ApprovalDetails { + method: string; + summary: string; + event_kind?: number; + destination_relays: string[]; + content_preview: string; + is_sensitive: boolean; +} + /** A NIP-46 request waiting for the user to approve or reject it. */ export interface PendingApproval { /** Internal id used to answer this request. */ @@ -11,6 +25,8 @@ export interface PendingApproval { method: string; /** A short human-readable description of what will be done. */ summary: string; + /** Detailed approval information. */ + details?: ApprovalDetails; } /** Non-secret snapshot of the NIP-46 remote signer for display. */ @@ -20,12 +36,38 @@ export interface SignerStatus { peer: string | null; /** Relays used for the connection. */ relays: string[]; + /** The relays in `relays` that are actually connected right now. */ + connectedRelays: string[]; /** A user-facing error if the signer stopped because of one. */ error: string | null; /** Requests currently waiting for the user's approval. */ pending: PendingApproval[]; } +/** Embedded signer status. */ +export interface EmbeddedSignerStatus { + type: 'embedded'; + available: boolean; + active_npub?: string; + pending_count: number; + pending: PendingApproval[]; + error?: string; +} + +/** NIP-46 client signer status. */ +export interface Nip46SignerStatus { + type: 'nip46'; + connected: boolean; + signer_pubkey?: string; + relays: string[]; + connected_relays: string[]; + error?: string; + pending_approvals: PendingApproval[]; +} + +/** Union of all signer statuses. */ +export type AnySignerStatus = EmbeddedSignerStatus | Nip46SignerStatus; + /** A safe view of a profile with no secret key material. */ export interface ProfileSummary { label: string; @@ -38,6 +80,8 @@ export interface ProfileSummary { picture?: string | null; /** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */ nip05?: string | null; + /** Per-profile signer mode. Absent for legacy profiles; defaults to embedded. */ + signer_mode?: SignerMode | null; } export interface RelayConfig { @@ -65,6 +109,8 @@ export interface PublishReport { event_id: string; succeeded: string[]; failed: RelayFailure[]; + /** The note content that was published. */ + content?: string; } /** Per-relay outcome of publishing a profile's name as kind 0 metadata. */ @@ -73,6 +119,9 @@ export interface MetadataPublishReport { failed: RelayFailure[]; } +/** Publication status derived from comparing served relays against all enabled relays. */ +export type PublicationStatus = 'fully_published' | 'partially_published'; + /** A single note shown in the aggregated feed. */ export interface FeedItem { /** Bech32 note id. */ @@ -139,6 +188,15 @@ export interface AppState { settings: Settings; /** Recently deleted profiles, newest last, for undo. */ undo_history?: ProfileSummary[]; + /** The most recent publish report, persisted across restarts. */ + last_publish?: { + event_id: string; + succeeded: string[]; + failed: RelayFailure[]; + content: string; + } | null; + /** Active signer mode. */ + signer_mode: SignerMode; } /** A secret key revealed after the vault is unlocked. */ diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index 0bf3db6..54e9cf1 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -8,6 +8,8 @@ import { EmptyState } from '../components/EmptyState'; import { Icon } from '../components/Icon'; import { shortenNpub } from '../lib/format'; import type { Screen } from '../lib/navigation'; +import type { ProfilePublication } from '../lib/publications'; +import { useProfilePublications } from '../lib/publications'; import { useApp } from '../state/AppProvider'; interface HomeScreenProps { @@ -16,7 +18,8 @@ interface HomeScreenProps { } export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { - const { state, lastPublish, selectProfile } = useApp(); + const { state, selectProfile } = useApp(); + const { publications, fullyPublished, loading, error } = useProfilePublications(); const [selecting, setSelecting] = useState(null); const onSelect = async (npub: string) => { @@ -72,7 +75,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { @@ -91,11 +94,15 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
-
    +
      {state?.profiles.map((profile) => (
    • { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if ((event.target as HTMLElement).closest('button, a, input')) { + return; + } + void onSelect(profile.npub); + } + } + } >
      onNavigate('compose')} />
      @@ -176,92 +199,114 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { } function PublicationResult({ - lastPublish, + publications, + fullyPublished, + loading, + error, onNavigateCompose, }: { - lastPublish: ReturnType['lastPublish']; + publications: ProfilePublication[]; + fullyPublished: ProfilePublication[]; + loading: boolean; + error: string | null; onNavigateCompose: () => void; }) { - if (!lastPublish) { - return ( -

      - You haven't published anything yet.{' '} - - . -

      - ); + if (loading) { + return

      Loading publications…

      ; } - if (lastPublish.error) { + if (error) { return ( - - {lastPublish.error} + + {error} ); } - const report = lastPublish.report; - if (!report) { - return null; + if (publications.length === 0) { + return ( +
      +

      You haven't published anything yet.

      + +
      + ); } - if (report.failed.length === 0) { + const newest = fullyPublished[0] ?? null; + const newestPartial = + publications.find((p) => p.publicationStatus === 'partially_published') ?? null; + + if (!newest && !newestPartial) { return ( -
      - - Published - - - {shortenNpub(report.event_id, true)} - - +
      +

      No fully published publications found.

      +
      + ); + } + + if (!newest) { + return ( +
      +

      No fully published publications found.

      + {newestPartial && }
      ); } return ( -
      - - The note reached {report.succeeded.length} of{' '} - {report.succeeded.length + report.failed.length} enabled relays. Event ID:{' '} - - {shortenNpub(report.event_id, true)} - - - -
      - Relay results -
        - {report.succeeded.map((url) => ( -
      • - {url} — accepted -
      • - ))} - {report.failed.map((failure) => ( -
      • - {failure.url} — {failure.error} -
      • - ))} -
      -
      -
      + <> +
      + + Published + + + {shortenNpub(newest.id, true)} + + +
      + {newest.content &&

      {newest.content}

      } + {newestPartial && newestPartial.id !== newest.id && ( + + )} + + ); +} + +function PartialPublicationDetails({ item }: { item: ProfilePublication }) { + const totalRelays = item.relays.length; + return ( +
      + Relay results +
        + {item.relays.map((url) => ( +
      • + {url} — accepted +
      • + ))} + {totalRelays === 0 &&
      • No relays returned this event.
      • } +
      +
      ); } function FirstRunGuide() { - const steps: { title: string; body: string }[] = [ + const steps: { icon: string; title: string; body: string }[] = [ { - title: '1 · Create a profile', + icon: 'users', + title: 'Create a profile', body: 'The app generates a public npub address and a private key for you. They are stored only on this computer.', }, { - title: '2 · Share your npub', + icon: 'copy', + title: 'Share your npub', body: 'Your npub is public and safe to share. Never share your private key with anyone.', }, { - title: '3 · Publish a note', + icon: 'edit', + title: 'Publish a note', body: 'Write a note in Compose and publish it. Your note is signed locally and sent to the enabled relays.', }, ]; @@ -270,9 +315,14 @@ function FirstRunGuide() {

      How it works

        {steps.map((step) => ( -
      1. - {step.title} -

        {step.body}

        +
      2. + +
        + {step.title} +

        {step.body}

        +
      3. ))}
      diff --git a/frontend/src/screens/ImportProfileModal.tsx b/frontend/src/screens/ImportProfileModal.tsx index 09430fb..d5018d8 100644 --- a/frontend/src/screens/ImportProfileModal.tsx +++ b/frontend/src/screens/ImportProfileModal.tsx @@ -13,31 +13,55 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose: useEffect(() => { if (open) { - setSecret(''); setError(null); setSaving(false); - requestAnimationFrame(() => labelRef.current?.focus()); + setSecret(''); + setError(null); + setSaving(false); + requestAnimationFrame(() => labelRef.current?.focus()); } }, [open]); const submit = async (event: FormEvent) => { event.preventDefault(); if (!secret.trim() || saving) return; - setSaving(true); setError(null); - try { await importProfile('', secret.trim()); onClose(); } - catch (err) { setError(err instanceof Error ? err.message : String(err)); setSaving(false); } + setSaving(true); + setError(null); + try { + await importProfile('', secret.trim()); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setSaving(false); + } }; - return -
      -

      Import an account using its private key. The key stays in your local vault and is never displayed.

      -
      - - setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" /> - {error && {error}} -
      -
      - - -
      -
      -
      ; + return ( + +
      +

      + Import an account using its private key. The key stays in your local vault and is never + displayed. +

      +
      + + setSecret(e.target.value)} + placeholder="nsec1... or 64-character hex" + autoComplete="off" + /> + {error && {error}} +
      +
      + + +
      +
      +
      + ); } diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index fdd796b..60c292b 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -8,7 +8,8 @@ import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; import { Modal } from '../components/Modal'; import { ProfileEditModal } from '../components/ProfileEditModal'; -import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal'; +import { ImportProfileModal } from './ImportProfileModal'; +import { ExportSecretKeyModal } from '../components/ExportSecretKeyModal'; import { formatDate, shortenNpub } from '../lib/format'; import type { MetadataPublishReport } from '../lib/types'; import { useApp } from '../state/AppProvider'; @@ -41,6 +42,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { picture?: string | null; nip05?: string | null; } | null>(null); + const [importOpen, setImportOpen] = useState(false); const profiles = state?.profiles ?? []; const shorten = state?.settings.shorten_npub ?? true; @@ -120,10 +122,15 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { title="No profiles yet" description="Create a profile to get your own Nostr identity — a public npub address you can share, with a private key kept safely on this computer." action={ - +
      + + +
      } />
      @@ -158,6 +165,9 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { Create Profile + {error && {error}} @@ -337,7 +347,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { ))}
- setRevealTarget(null)} @@ -394,6 +404,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { onError={setError} /> )} + setImportOpen(false)} /> ); diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx new file mode 100644 index 0000000..dcabe76 --- /dev/null +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -0,0 +1,501 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Alert } from '../components/Alert'; +import { Badge } from '../components/Badge'; +import { Button } from '../components/Button'; +import { ErrorText } from '../components/ErrorText'; +import { Icon } from '../components/Icon'; +import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types'; +import { useApp } from '../state/AppProvider'; + +export function SignerModeScreen() { + const { + state, + signerModeSet, + embeddedSignerStatus, + nip46Status, + nip46Connect, + nip46Disconnect, + nip46Approve, + embeddedSignerApprove, + refresh, + createProfile, + importProfile, + unlockVault, + } = useApp(); + + const [embeddedStatus, setEmbeddedStatus] = useState(null); + const [nip46StatusState, setNip46StatusState] = useState(null); + const [uri, setUri] = useState(''); + const [label, setLabel] = useState('Remote Signer'); + const [error, setError] = useState(null); + const [connecting, setConnecting] = useState(false); + const [loading, setLoading] = useState(true); + + // Single source of truth: backend state (defaults to most secure) + const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode; + const isNip46Active = (mode === 'nip46_client' || mode === 'nip46_bunker') && !!nip46StatusState?.connected; + const isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available; + + + + const refreshStatus = useCallback(async () => { + try { + // Mode comes from AppProvider state, just refresh signer statuses + const currentMode = (state?.signer_mode ?? 'nip46_client') as string; + let fetchedMode = currentMode; + if (fetchedMode === 'nip46') fetchedMode = 'nip46_client'; + if (!['embedded', 'nip46_bunker', 'nip46_client'].includes(fetchedMode)) { + fetchedMode = 'nip46_client'; + } + + if (fetchedMode === 'embedded') { + try { + const status = await embeddedSignerStatus(); + setEmbeddedStatus(status); + } catch { + setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any); + } + } else { + try { + const status = await nip46Status(); + setNip46StatusState(status); + } catch { + setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [state?.signer_mode, embeddedSignerStatus, nip46Status]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + useEffect(() => { + const timer = window.setInterval(() => { + void refreshStatus(); + }, 2000); + return () => window.clearInterval(timer); + }, [refreshStatus]); + + const vaultLocked = state?.vault_locked ?? false; + const hasProfile = !!state?.active_profile; + + const canSwitchToBunker = hasProfile && !vaultLocked; + const canSwitchToEmbedded = hasProfile && !vaultLocked; + + const handleModeSwitch = useCallback( + async (newMode: SignerMode) => { + setError(null); + try { + await signerModeSet(newMode); + await refreshStatus(); + await refresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('No keypair') || msg.includes('No active profile') || msg.includes('no active profile')) { + setError('No keypair found: Please import a key first.'); + } else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) { + setError('Vault locked: Please unlock to switch modes.'); + } else if (msg.includes('ACTIVE_SESSION') || msg.toLowerCase().includes('active session')) { + setError('Invalid mode transition: Cannot switch while active session exists. Disconnect first.'); + } else { + setError(msg || 'That operation is not permitted.'); + } + } + }, + [signerModeSet, refreshStatus, refresh], + ); + + const handleNip46Connect = useCallback(async () => { + const trimmed = uri.trim(); + if (!trimmed.startsWith('nostrconnect://')) { + setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.'); + return; + } + setError(null); + setConnecting(true); + try { + const status = await nip46Connect(trimmed, label.trim() || 'Remote Signer'); + setNip46StatusState(status); + setUri(''); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setConnecting(false); + } + }, [uri, label, nip46Connect]); + + const handleNip46Disconnect = useCallback(async () => { + setError(null); + try { + const status = await nip46Disconnect(); + setNip46StatusState(status); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [nip46Disconnect]); + + const handleEmbeddedApprove = useCallback( + async (index: number, approved: boolean) => { + setError(null); + try { + const status = await embeddedSignerApprove(index, approved); + setEmbeddedStatus(status); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [embeddedSignerApprove], + ); + + const handleNip46Approve = useCallback( + async (id: string, approved: boolean) => { + setError(null); + try { + const status = await nip46Approve(id, approved); + setNip46StatusState(status); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [nip46Approve], + ); + + const modeBadge = () => { + if (mode === 'nip46_client') { + return isNip46Active ? ( + NIP-46 Client (Connected) + ) : ( + NIP-46 Client (Most Secure) + ); + } + if (mode === 'nip46_bunker') { + return isNip46Active ? ( + NIP-46 Bunker (Running) + ) : ( + NIP-46 Bunker (Moderate) + ); + } + return isEmbeddedActive ? ( + Embedded (Least Secure) + ) : ( + Embedded {vaultLocked ? '(Vault Locked)' : ''} + ); + }; + + const handleImportKey = useCallback(async () => { + const nsec = prompt('Enter your nsec (npub will be derived) or leave blank to generate a new key:'); + if (nsec === null) return; + setError(null); + try { + if (nsec.trim()) { + await importProfile('Imported', nsec.trim()); + } else { + await createProfile('Generated'); + } + await refresh(); + await refreshStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [importProfile, createProfile, refresh, refreshStatus]); + + const handleUnlockVault = useCallback(async () => { + const pwd = prompt('Enter vault password to unlock:'); + if (!pwd) return; + setError(null); + try { + await unlockVault(pwd); + await refresh(); + await refreshStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [unlockVault, refresh, refreshStatus]); + + return ( +
+
+
+

Signer Mode

+

+ Choose how your keys are managed and where signing happens. +
+ Ordered by security: most secure → least secure +

+
+ +
+
+

Key Status

+
+
+
+
+ Keypair + {hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'} +
+
+ Vault + {vaultLocked ? 'Locked' : 'Unlocked / No password'} +
+
+ Current Mode + {mode} +
+
+ {!hasProfile && ( + + )} + {hasProfile && vaultLocked && ( + + )} +
+
+ +
+
+

Current Mode

+
{modeBadge()}
+
+
+
+ {/* 1. Most Secure */} + + + {/* 2. Moderately Secure */} + + + {/* 3. Least Secure */} + +
+ + {mode === 'nip46_bunker' && !canSwitchToBunker && ( + + {hasProfile ? 'Unlock vault to enable bunker mode.' : 'No keypair found: Please import a key first.'} + + )} + {mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && ( + + Unlock vault to use embedded signer. + + )} + {!hasProfile && mode !== 'nip46_client' && ( + + No keypair found: Please import a key first. (NIP-46 Client can be selected without a local key.) + + )} + {error && {error}} +
+
+ + {/* Embedded pending */} + {mode === 'embedded' && (embeddedStatus?.pending?.length ?? 0) > 0 && ( +
+
+

Pending Approvals

+ {embeddedStatus!.pending.length} +
+
+ {(embeddedStatus!.pending).map((req, idx) => ( +
+
+ {req.method} +

{req.summary}

+ {req.details?.is_sensitive && Sensitive} +
+
+ + +
+
+ ))} +
+
+ )} + + {/* NIP-46 Client config */} + {(mode === 'nip46_client' || mode === 'nip46_bunker') && ( +
+
+

{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}

+
+
+ {isNip46Active && nip46StatusState ? ( +
+

+ Connected to {nip46StatusState.signer_pubkey?.slice(0, 16)}… via{' '} + {(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays +

+ {nip46StatusState.error && {nip46StatusState.error}} + + {(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && ( +
+

Pending ({nip46StatusState!.pending_approvals!.length})

+ {(nip46StatusState!.pending_approvals ?? []).map((r) => ( +
+
+ {r.method} +

{r.summary}

+
+
+ + +
+
+ ))} +
+ )} +
+ ) : ( +
+
+ setUri(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +

+ {mode === 'nip46_client' + ? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.' + : 'Share this with client apps that want to connect to this bunker.'} +

+
+
+ + setLabel(e.target.value)} placeholder="Remote Signer" /> +
+ {error && {error}} +
+ + +
+
+ )} +
+
+ )} + +
+
+

Security Notes

+
+
+
    +
  • + NIP-46 Client (Most Secure): Private key never on this device. External + signer (hardware wallet / Amber) holds key. +
  • +
  • + NIP-46 Bunker (Moderate): Key in this app's vault, you approve each + remote request. +
  • +
  • + Embedded (Least Secure): Local signing, key in memory when unlocked. +
  • +
+
+
+
+
+ ); +} diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index 1b3c1ac..4d514f2 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -12,6 +12,7 @@ const EMPTY_STATUS: SignerStatus = { phase: 'stopped', peer: null, relays: [], + connectedRelays: [], error: null, pending: [], }; @@ -144,11 +145,25 @@ export function SignerScreen() {
Relays
{status.relays.length > 0 ? ( - status.relays.map((relay) => ( - - {relay} - - )) + <> + {status.relays.map((relay) => { + const connected = status.connectedRelays.includes(relay); + return ( + + {relay} + + ); + })} + {status.phase === 'connecting' && status.connectedRelays.length === 0 && ( + + Waiting for a relay to answer… + + )} + ) : ( None )} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 473d782..602369a 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -10,15 +10,18 @@ import { import { api, BackendError } from '../lib/api'; import type { AppState, + EmbeddedSignerStatus, FeedItem, LinkPreview, MetadataPublishReport, + Nip46SignerStatus, PickedImage, ProfileSummary, PublishReport, RelayTestResult, RevealedKey, Settings, + SignerMode, SignerStatus, Theme, UpdateApplyReport, @@ -40,6 +43,7 @@ interface AppContextValue { lastPublish: LastPublish | null; refresh: () => Promise; createProfile: (label: string) => Promise; + importProfile: (label: string, secret: string) => Promise; selectProfile: (npub: string) => Promise; publishProfileMetadata: (npub: string) => Promise; setProfilePicture: (npub: string, url: string | null) => Promise; @@ -63,10 +67,22 @@ interface AppContextValue { unlockVault: (password: string) => Promise; lockVault: () => Promise; removeVaultPassword: (password: string) => Promise; - revealSecretKey: (npub: string) => Promise; + exportSecretKey: (npub: string, password: string, reason: string) => Promise; pickImages: () => Promise; uploadImage: (token: string) => Promise; linkPreview: (url: string) => Promise; + // Signer mode management + signerModeGet: () => Promise<{ mode: SignerMode }>; + signerModeSet: (mode: SignerMode) => Promise; + // Embedded signer + embeddedSignerStatus: () => Promise; + embeddedSignerApprove: (index: number, approved: boolean) => Promise; + // NIP-46 client signer + nip46Connect: (uri: string, label: string) => Promise; + nip46Disconnect: () => Promise; + nip46Status: () => Promise; + nip46Approve: (id: string, approved: boolean) => Promise; + // Legacy NIP-46 bunker (deprecated) signerConnect: (uri: string) => Promise; signerDisconnect: () => Promise; signerStatus: () => Promise; @@ -97,6 +113,14 @@ export function AppProvider({ children }: { children: ReactNode }) { const initial = await api.init(); if (!cancelled) { setState(initial); + if (initial.last_publish) { + setLastPublish({ + report: initial.last_publish, + error: null, + details: null, + at: Date.now(), + }); + } } } catch (error) { if (!cancelled) { @@ -122,6 +146,15 @@ export function AppProvider({ children }: { children: ReactNode }) { [state?.settings], ); + const importProfile = useCallback( + async (label: string, secret: string): Promise => { + const result = await api.importProfile(label, secret); + setState(result.state); + return result.profile; + }, + [], + ); + const selectProfile = useCallback(async (npub: string) => { const fresh = await api.selectProfile(npub); setState(fresh); @@ -210,6 +243,32 @@ export function AppProvider({ children }: { children: ReactNode }) { return next; }, []); + // Signer mode management + const signerModeGet = useCallback(() => api.signerModeGet(), []); + const signerModeSet = useCallback( + (mode: SignerMode) => applyState(api.signerModeSet(mode)), + [applyState], + ); + + // Embedded signer + const embeddedSignerStatus = useCallback(() => api.embeddedSignerStatus(), []); + const embeddedSignerApprove = useCallback( + (index: number, approved: boolean) => api.embeddedSignerApprove(index, approved), + [], + ); + + // NIP-46 client signer + const nip46Connect = useCallback( + (uri: string, label: string) => api.nip46Connect(uri, label), + [], + ); + const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []); + const nip46Status = useCallback(() => api.nip46Status(), []); + const nip46Approve = useCallback( + (id: string, approved: boolean) => api.nip46Approve(id, approved), + [], + ); + const setVaultPassword = useCallback( (currentPassword: string | null, newPassword: string) => applyState(api.setVaultPassword(currentPassword, newPassword)), @@ -224,7 +283,11 @@ export function AppProvider({ children }: { children: ReactNode }) { (password: string) => applyState(api.removeVaultPassword(password)), [applyState], ); - const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []); + const exportSecretKey = useCallback( + (npub: string, password: string, reason: string) => + api.exportSecretKey(npub, password, reason), + [], + ); const pickImages = useCallback(() => api.pickImages(), []); const uploadImage = useCallback((token: string) => api.uploadImage(token), []); const linkPreview = useCallback((url: string) => api.linkPreview(url), []); @@ -261,6 +324,7 @@ export function AppProvider({ children }: { children: ReactNode }) { lastPublish, refresh, createProfile, + importProfile, selectProfile, publishNote, recordPublishFailure, @@ -278,10 +342,18 @@ export function AppProvider({ children }: { children: ReactNode }) { unlockVault, lockVault, removeVaultPassword, - revealSecretKey, + exportSecretKey, pickImages, uploadImage, linkPreview, + signerModeGet, + signerModeSet, + embeddedSignerStatus, + embeddedSignerApprove, + nip46Connect, + nip46Disconnect, + nip46Status, + nip46Approve, signerConnect, signerDisconnect, signerStatus, @@ -302,6 +374,7 @@ export function AppProvider({ children }: { children: ReactNode }) { lastPublish, refresh, createProfile, + importProfile, selectProfile, publishProfileMetadata, setProfilePicture, @@ -326,10 +399,18 @@ export function AppProvider({ children }: { children: ReactNode }) { unlockVault, lockVault, removeVaultPassword, - revealSecretKey, + exportSecretKey, pickImages, uploadImage, linkPreview, + signerModeGet, + signerModeSet, + embeddedSignerStatus, + embeddedSignerApprove, + nip46Connect, + nip46Disconnect, + nip46Status, + nip46Approve, signerConnect, signerDisconnect, signerStatus, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5ecf371..4294cb6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -709,7 +709,7 @@ a { } .page-subtitle { - margin: 4px 0 0; + margin: 6px 0 0; color: var(--text-muted); font-size: 14px; } @@ -808,22 +808,22 @@ a { .sidebar-logo { width: 38px; height: 38px; - border-radius: 11px; display: grid; place-items: center; - background: #fff; + /* The logo PNG now carries a real alpha channel: no tile background, + border, or mask — the artwork composites directly on the sidebar. */ overflow: hidden; } .sidebar-logo img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; display: block; } -/* The artwork is black-on-white; flip it in dark themes so it stays black - bird on dark tile instead of a glaring white square. */ +/* The artwork is black ink; flip it in dark themes so it stays visible on + dark sidebars. */ html[data-theme='dark'] .sidebar-logo img, html[data-theme='neon'] .sidebar-logo img, html[data-theme='glass'] .sidebar-logo img { @@ -1408,16 +1408,9 @@ select { .home-grid { display: grid; - grid-template-columns: 1fr; gap: 20px; } -.active-profile-row { - display: flex; - align-items: center; - gap: 14px; -} - .active-profile-meta { flex: 1; min-width: 0; @@ -1472,7 +1465,7 @@ select { padding: 0; display: flex; flex-direction: column; - gap: 10px; + gap: 12px; } .home-profile-row { @@ -1480,19 +1473,28 @@ select { align-items: center; gap: 14px; padding: 8px; - border-radius: 8px; + border-radius: var(--radius-sm); border: 1px solid transparent; } .home-profile-row.is-active { - background: var(--token-item-bg, rgba(0, 0, 0, 0.04)); - border-color: var(--token-border, rgba(0, 0, 0, 0.12)); + background: var(--surface-2); + border-color: var(--border); } .home-profile-row:not(.is-active) { cursor: pointer; } +.home-profile-row:not(.is-active):hover { + background: var(--surface-hover); +} + +.home-profile-row:not(.is-active):focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + .home-profile-row .active-profile-meta { flex: 1; min-width: 0; @@ -1511,7 +1513,17 @@ select { } .home-add-profile { - margin-top: 14px; + margin-top: 16px; +} + +.home-publish-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 12px 0; + text-align: center; + color: var(--text-muted); } .relay-status-list { @@ -1575,6 +1587,18 @@ select { flex-basis: 100%; } +.publish-preview { + margin: 8px 0 0; + padding: 10px 12px; + background: var(--surface-2); + border-radius: var(--radius-sm); + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-width: 65ch; +} + .relay-result-list { margin: 8px 0 0; padding-left: 18px; @@ -1596,16 +1620,45 @@ select { text-align: left; } -.first-run-guide ol { - margin: 12px 0 0; - padding-left: 20px; - display: flex; - flex-direction: column; - gap: 10px; +.first-run-guide h2 { + margin-bottom: 4px; } -.first-run-guide p { - margin: 2px 0 0; +.first-run-guide ol { + margin: 12px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; +} + +.first-run-step { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.first-run-step-indicator { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--primary-soft); + color: var(--primary); + display: grid; + place-items: center; + flex-shrink: 0; + margin-top: 2px; +} + +.first-run-step-content strong { + display: block; + font-size: 14px; + margin-bottom: 2px; +} + +.first-run-step-content p { + margin: 0; color: var(--text-muted); font-size: 14px; } @@ -2160,6 +2213,16 @@ select { font-size: 12px; } +.signer-relay.is-connected { + border-color: var(--success); + color: var(--success); +} + +.signer-relay-hint { + margin-left: 4px; + font-size: 12px; +} + .signer-actions { display: flex; flex-direction: column; @@ -2260,3 +2323,320 @@ select { transition: none; } } + +/* ------------------------------------------------------------------------- + Signer Mode Screen + ------------------------------------------------------------------------- */ + +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.status-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.status-item.ok { + border-color: var(--success); +} + +.status-item.missing, +.status-item.locked { + border-color: var(--danger); +} + +.status-label { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.status-value { + font-size: 14px; + font-family: ui-monospace, SFMono-Regular, monospace; + color: var(--text); +} + +.mode-options { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mode-option { + position: relative; + cursor: pointer; + border: 2px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: + border-color 200ms ease, + box-shadow 200ms ease; +} + +.mode-option input[type='radio'] { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.mode-option.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-soft); +} + +.mode-option.active:focus-within { + outline: none; + box-shadow: 0 0 0 3px var(--primary); +} + +.mode-option-content { + padding: 20px; +} + +.mode-option-content h3 { + margin: 0 0 8px; + font-size: 16px; + color: var(--text); +} + +.mode-option-content p { + margin: 0 0 12px; + font-size: 14px; + color: var(--text-muted); + line-height: 1.5; +} + +.mode-features { + margin: 0; + padding-left: 20px; + font-size: 13px; + color: var(--text); + line-height: 1.8; +} + +.mode-features li { + margin: 0; +} + +.mode-badge { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 12px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + border-radius: 999px; +} + +.mode-badge.active { + background: var(--success-soft); + color: var(--success); +} + +/* Security level badges */ +.security-badge { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 12px; + padding: 4px 10px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-radius: 999px; +} + +.security-badge.most-secure { + background: var(--success-soft); + color: var(--success); +} + +.security-badge.moderate-secure { + background: var(--warning-soft); + color: var(--warning); +} + +.security-badge.least-secure { + background: var(--danger-soft); + color: var(--danger); +} + +/* Security level card variants */ +.mode-option.security-most { + border-color: var(--success); + box-shadow: 0 0 0 1px var(--success); +} + +.mode-option.security-most.active { + border-color: var(--success); + box-shadow: 0 0 0 3px var(--success-soft); +} + +.mode-option.security-moderate { + border-color: var(--warning); + box-shadow: 0 0 0 1px var(--warning); +} + +.mode-option.security-moderate.active { + border-color: var(--warning); + box-shadow: 0 0 0 3px var(--warning-soft); +} + +.mode-option.security-least { + border-color: var(--danger); + box-shadow: 0 0 0 1px var(--danger); +} + +.mode-option.security-least.active { + border-color: var(--danger); + box-shadow: 0 0 0 3px var(--danger-soft); +} + +.security-desc { + font-size: 13px; + line-height: 1.6; + color: var(--text); + margin-bottom: 12px; +} + +.security-desc strong { + color: var(--text); +} + +.security-desc code { + font-size: 12px; + background: var(--surface-2); + padding: 2px 6px; + border-radius: 4px; +} + +.subtitle-hint { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + +.mode-disabled-reason { + margin-top: 8px; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.relay-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.relay-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 13px; +} + +.field-row { + display: flex; + gap: 8px; +} + +.field-row input { + flex: 1; +} + +.connection-uri { + margin-top: 16px; +} + +.connection-uri label { + display: block; + margin-bottom: 8px; + font-size: 13px; + color: var(--text-muted); +} + +.uri-row { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + overflow: hidden; +} + +.uri-row code { + flex: 1; + min-width: 0; + font-size: 12px; + word-break: break-all; + white-space: pre-wrap; +} + +.connected-clients { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.connected-clients h4 { + margin: 0 0 12px; + font-size: 13px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.client-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: 8px; + font-size: 13px; +} + +.security-notes { + margin: 0; + padding-left: 20px; + font-size: 13px; + line-height: 1.8; + color: var(--text); +} + +.security-notes li { + margin: 8px 0; +} + +.security-notes strong { + color: var(--text); +} diff --git a/frontend/src/test/ExportSecretKey.test.tsx b/frontend/src/test/ExportSecretKey.test.tsx new file mode 100644 index 0000000..cfbb87b --- /dev/null +++ b/frontend/src/test/ExportSecretKey.test.tsx @@ -0,0 +1,257 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ProfilesScreen } from '../screens/ProfilesScreen'; +import { ALICE, makeState } from './apiMock'; +import { createFakeBackend, installFakeBackend } from './fakeBackend'; +import { renderWithApp } from './render'; + +const ALICE_HEX = `${ALICE.slice(4)}0000000000000000000000000000000000`.slice(0, 64); +const ALICE_NSEC = `nsec1${ALICE.slice(5)}`; + +/** Open the export-secret-key modal for the first profile. */ +async function openExport(user: ReturnType) { + await screen.findByText('Alice'); + await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]); + return screen.findByRole('dialog', { name: 'Export secret key — Alice' }); +} + +describe('exporting a secret key', () => { + it('shows the password and reason form immediately', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + expect(within(dialog).getByText(/This action is logged/)).toBeInTheDocument(); + expect(within(dialog).getByLabelText('Vault password')).toBeInTheDocument(); + expect(within(dialog).getByLabelText('Reason for export')).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled(); + }); + + it('requires a non-empty trimmed reason before enabling Export', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + + // Password only — still disabled + await user.type(within(dialog).getByLabelText('Vault password'), 'test'); + expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled(); + + // Password + whitespace-only reason — still disabled + await user.type(within(dialog).getByLabelText('Reason for export'), ' '); + expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled(); + + // Password + real reason — enabled + await user.clear(within(dialog).getByLabelText('Reason for export')); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + expect(within(dialog).getByRole('button', { name: 'Export' })).toBeEnabled(); + }); + + it('sends npub, password, and reason to the backend', async () => { + const backend = createFakeBackend(makeState({ encrypted_storage: true })); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + // Use paste to avoid char-by-char form interaction issues + const pwInput = within(dialog).getByLabelText('Vault password'); + const reasonInput = within(dialog).getByLabelText('Reason for export'); + await user.click(pwInput); + await user.paste('test'); + await user.click(reasonInput); + await user.paste('migration'); + await user.click(within(dialog).getByRole('button', { name: 'Export' })); + + await waitFor(() => { + const reqs = backend.requests.filter((r) => r.method === 'export_secret_key'); + const last = reqs[reqs.length - 1]; + expect(last.params).toEqual({ + npub: ALICE, + password: 'test', + reason: 'migration', + }); + }); + }); + + it('shows hex and nsec after successful export', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + await user.type(within(dialog).getByLabelText('Vault password'), 'test'); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + await user.click(within(dialog).getByRole('button', { name: 'Export' })); + + await waitFor(() => { + expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument(); + expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument(); + }); + expect( + within(dialog).getByText(/Anyone who has this key can fully control the profile/i), + ).toBeInTheDocument(); + + // Copy buttons work + await user.click(within(dialog).getByRole('button', { name: 'Copy hex key' })); + await user.click(within(dialog).getByRole('button', { name: 'Copy nsec key' })); + await waitFor(() => { + expect(backend.copied).toContain(ALICE_HEX); + expect(backend.copied).toContain(ALICE_NSEC); + }); + }); + + it('does not call revealSecretKey', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + await user.type(within(dialog).getByLabelText('Vault password'), 'test'); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + await user.click(within(dialog).getByRole('button', { name: 'Export' })); + + await waitFor(() => { + const exportReq = backend.requests.find((r) => r.method === 'export_secret_key'); + expect(exportReq).toBeDefined(); + }); + // reveal_secret_key should never have been requested + const revealReq = backend.requests.find((r) => r.method === 'reveal_secret_key'); + expect(revealReq).toBeUndefined(); + }); + + it('clears sensitive state when the modal closes', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + await user.type(within(dialog).getByLabelText('Vault password'), 'test'); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + + // Close without exporting + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: /Export secret key/ })).not.toBeInTheDocument(); + }); + + // Reopen — fields should be empty + const dialog2 = await openExport(user); + expect((within(dialog2).getByLabelText('Vault password') as HTMLInputElement).value).toBe(''); + expect((within(dialog2).getByLabelText('Reason for export') as HTMLInputElement).value).toBe(''); + }); + + it('shows an error for an incorrect password', async () => { + const backend = createFakeBackend(makeState({ encrypted_storage: true })); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + await user.type(within(dialog).getByLabelText('Vault password'), 'wrong'); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + await user.click(within(dialog).getByRole('button', { name: 'Export' })); + + await waitFor(() => { + expect(within(dialog).getByText('Wrong password.')).toBeInTheDocument(); + }); + // Form is still visible for retry + expect(within(dialog).getByLabelText('Vault password')).toBeInTheDocument(); + expect(within(dialog).getByLabelText('Reason for export')).toBeInTheDocument(); + }); + + it('shows an error for a missing profile', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + // Type a reason first, then use nextErrors to inject a profile_not_found error + await user.type(within(dialog).getByLabelText('Vault password'), 'test'); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + backend.nextErrors.export_secret_key = { + message: 'That profile is not stored on this computer.', + code: 'profile_not_found', + }; + await user.click(within(dialog).getByRole('button', { name: 'Export' })); + + await waitFor(() => { + expect( + within(dialog).getByText(/not stored on this computer/), + ).toBeInTheDocument(); + }); + }); + + it('shows an error for an external (Nip46Client) signer profile', async () => { + const state = makeState({ + profiles: [ + { + label: 'Team Account', + npub: ALICE, + created_at: 1700000000, + is_active: true, + signer_mode: 'nip46_client', + }, + ], + active_profile: { + label: 'Team Account', + npub: ALICE, + created_at: 1700000000, + is_active: true, + signer_mode: 'nip46_client', + }, + }); + const backend = createFakeBackend(state); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + // Open modal for the Team Account profile + await screen.findByText('Team Account'); + await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]); + const dialog = await screen.findByRole('dialog', { + name: 'Export secret key — Team Account', + }); + await user.type(within(dialog).getByLabelText('Vault password'), 'test'); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + await user.click(within(dialog).getByRole('button', { name: 'Export' })); + + await waitFor(() => { + expect( + within(dialog).getByText(/external signer/i), + ).toBeInTheDocument(); + }); + }); + + it('does not return the key if the audit-log write fails', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + const dialog = await openExport(user); + await user.type(within(dialog).getByLabelText('Vault password'), 'test'); + await user.type(within(dialog).getByLabelText('Reason for export'), 'backup'); + backend.nextErrors.export_secret_key = { + message: 'Could not write audit log.', + code: 'io', + }; + await user.click(within(dialog).getByRole('button', { name: 'Export' })); + + await waitFor(() => { + expect(within(dialog).getByText(/Could not write audit log/)).toBeInTheDocument(); + }); + // Key must NOT be shown + expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument(); + expect(within(dialog).queryByText(ALICE_NSEC)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/HomeScreen.test.tsx b/frontend/src/test/HomeScreen.test.tsx index 9d56d97..9eec91b 100644 --- a/frontend/src/test/HomeScreen.test.tsx +++ b/frontend/src/test/HomeScreen.test.tsx @@ -23,10 +23,12 @@ describe('HomeScreen', () => { const { onNavigate } = renderHome(backend); renderWithApp(); - expect(await screen.findByText('Alice')).toBeInTheDocument(); - expect(screen.getByText(/npub1alice\.\.\./)).toBeInTheDocument(); + // The active profile appears in the profile list with its shortened npub. + const profileList = await screen.findByRole('listbox'); + expect(within(profileList).getByText('Alice')).toBeInTheDocument(); + expect(within(profileList).getByText(/npub1alice\.\.\./)).toBeInTheDocument(); - const compose = screen.getByRole('button', { name: /Compose note/i }); + const compose = screen.getByRole('button', { name: 'Compose' }); await userEvent.setup().click(compose); expect(onNavigate).toHaveBeenCalledWith('compose'); }); @@ -36,7 +38,11 @@ describe('HomeScreen', () => { renderHome(backend); renderWithApp(); - const aliceRow = (await screen.findByText('Alice')).closest('.home-profile-row') as HTMLElement; + // The active profile row carries the "Selected" badge; find Alice via the profile list. + const profileList = await screen.findByRole('listbox'); + const aliceRow = within(profileList) + .getByText('Alice') + .closest('.home-profile-row') as HTMLElement; await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' })); await waitFor(() => { expect(backend.copied).toContain(ALICE); diff --git a/frontend/src/test/ShowSecretKey.test.tsx b/frontend/src/test/ShowSecretKey.test.tsx deleted file mode 100644 index 93337fa..0000000 --- a/frontend/src/test/ShowSecretKey.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ProfilesScreen } from '../screens/ProfilesScreen'; -import { makeState } from './apiMock'; -import { createFakeBackend, installFakeBackend } from './fakeBackend'; -import { renderWithApp } from './render'; -import { ALICE } from './apiMock'; - -const ALICE_HEX = `${ALICE.slice(4)}0000000000000000000000000000000000`.slice(0, 64); -const ALICE_NSEC = `nsec1${ALICE.slice(5)}`; - -/** Wait for the profile list to settle, then open the first profile's key reveal. */ -async function openReveal(user: ReturnType) { - await screen.findByText('Alice'); - await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]); - return screen.findByRole('dialog', { name: 'Secret key — Alice' }); -} - -describe('revealing a secret key', () => { - it('shows hex and nsec for an unencrypted vault without asking for a password', async () => { - const backend = createFakeBackend(); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - const dialog = await openReveal(user); - expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument(); - expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument(); - expect( - within(dialog).getByText(/Anyone who has this key can fully control the profile/i), - ).toBeInTheDocument(); - - await user.click(within(dialog).getByRole('button', { name: 'Copy hex key' })); - await user.click(within(dialog).getByRole('button', { name: 'Copy nsec key' })); - await waitFor(() => { - expect(backend.copied).toContain(ALICE_HEX); - expect(backend.copied).toContain(ALICE_NSEC); - }); - }); - - it('asks for the vault password when locked, then reveals the key', async () => { - const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true })); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - const dialog = await openReveal(user); - expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument(); - expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument(); - - await user.type(within(dialog).getByLabelText('Vault password'), 'correct horse'); - await user.click(within(dialog).getByRole('button', { name: 'Unlock' })); - - await waitFor(() => { - expect(backend.state.vault_locked).toBe(false); - }); - expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument(); - expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument(); - }); - - it('keeps the unlock form when an incorrect password is reported', async () => { - const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true })); - backend.nextErrors.unlock_vault = { message: 'The password is not correct.' }; - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - const dialog = await openReveal(user); - await user.type(within(dialog).getByLabelText('Vault password'), 'wrong'); - await user.click(within(dialog).getByRole('button', { name: 'Unlock' })); - - expect(await screen.findByText('The password is not correct.')).toBeInTheDocument(); - expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument(); - expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument(); - }); - - it('reveals directly when the vault is encrypted but already unlocked', async () => { - const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: false })); - installFakeBackend(backend); - const user = userEvent.setup(); - renderWithApp(); - - const dialog = await openReveal(user); - expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument(); - expect(within(dialog).queryByLabelText('Vault password')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx index ea9ef27..45345c6 100644 --- a/frontend/src/test/SignerScreen.test.tsx +++ b/frontend/src/test/SignerScreen.test.tsx @@ -48,6 +48,47 @@ describe('SignerScreen', () => { expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true); }); + it('marks connected relays while the handshake is still in progress', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + renderWithApp(); + + // The signer is dialling the link's relays: one has answered, one has not. + backend.setSigner({ + phase: 'connecting', + peer: 'ab12', + relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], + connectedRelays: ['wss://relay.damus.io'], + error: null, + pending: [], + }); + + expect(await screen.findByText('Connecting…')).toBeInTheDocument(); + const connected = screen.getByTitle('Connected'); + expect(connected).toHaveTextContent('wss://relay.damus.io'); + const pending = screen.getByTitle('No connection yet'); + expect(pending).toHaveTextContent('wss://relay.nostr.band'); + // No "waiting" hint while at least one relay is already up. + expect(screen.queryByText('Waiting for a relay to answer…')).not.toBeInTheDocument(); + }); + + it('shows a waiting hint when no relay has answered yet', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + renderWithApp(); + + backend.setSigner({ + phase: 'connecting', + peer: 'ab12', + relays: ['wss://relay.nostr.band'], + connectedRelays: [], + error: null, + pending: [], + }); + + expect(await screen.findByText('Waiting for a relay to answer…')).toBeInTheDocument(); + }); + it('disconnects an active connection', async () => { const backend = createFakeBackend(); installFakeBackend(backend); @@ -80,6 +121,7 @@ describe('SignerScreen', () => { phase: 'connected', peer: 'ab12', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [ { id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' }, @@ -115,6 +157,7 @@ describe('SignerScreen', () => { phase: 'connected', peer: '79ab', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }], }); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index 21b89ee..f616efc 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -4,6 +4,7 @@ import type { ProfileSummary, RelayTestResult, Settings, + SignerMode, SignerStatus, } from '../lib/types'; @@ -43,6 +44,8 @@ export function makeState(overrides?: Partial): AppState { active_profile: alice, profiles: [alice, bob], settings, + last_publish: null, + signer_mode: 'embedded' as SignerMode, ...overrides, }; } @@ -71,7 +74,15 @@ export function makeRelayTest(url: string, overrides?: Partial) } export function makeSignerStatus(overrides?: Partial): SignerStatus { - return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides }; + return { + phase: 'stopped', + peer: null, + relays: [], + connectedRelays: [], + error: null, + pending: [], + ...overrides, + }; } /** @@ -95,7 +106,7 @@ export interface ApiMock { unlockVault: ReturnType; lockVault: ReturnType; removeVaultPassword: ReturnType; - revealSecretKey: ReturnType; + exportSecretKey: ReturnType; pickImages: ReturnType; uploadImage: ReturnType; linkPreview: ReturnType; @@ -202,7 +213,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock { encrypted_storage: false, vault_locked: false, })), - revealSecretKey: vi.fn(async (npub: string) => ({ + exportSecretKey: vi.fn(async (npub: string) => ({ hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64), nsec: `nsec1${npub.slice(5)}`, })), @@ -225,6 +236,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock { phase: 'connected', peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [], }), diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index ccef5e5..f4f2551 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -324,6 +324,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend { phase: 'connected', peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', relays: ['wss://relay.damus.io'], + connectedRelays: ['wss://relay.damus.io'], error: null, pending: [], }; @@ -436,16 +437,48 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return next; } - case 'reveal_secret_key': { - if (state.encrypted_storage && state.vault_locked) { + case 'export_secret_key': { + const npub = String(params.npub); + const password = String(params.password ?? ''); + const reason = String(params.reason ?? ''); + + // Check profile exists first + const profile = state.profiles.find((p) => p.npub === npub); + if (!profile) { throw Object.assign( - new Error('Your vault is locked. Enter your password to unlock it.'), - { code: 'vault_locked' }, + new Error('That profile is not stored on this computer.'), + { code: 'profile_not_found' }, ); } - const npub = String(params.npub); - if (!state.profiles.some((p) => p.npub === npub)) { - throw new Error('That profile is not stored on this computer.'); + + // External signer profiles cannot export secret keys + if (profile.signer_mode === 'nip46_client') { + throw Object.assign( + new Error('This profile uses an external signer. Secret key export is not possible.'), + { code: 'external_signer_not_connected' }, + ); + } + + if (state.encrypted_storage) { + if (!password) { + throw Object.assign( + new Error('Password required to export secret key.'), + { code: 'wrong_password' }, + ); + } + // Fake password check: accept "test" or "password" + if (password !== 'test' && password !== 'password') { + throw Object.assign( + new Error('Wrong password.'), + { code: 'wrong_password' }, + ); + } + } + if (!reason) { + throw Object.assign( + new Error('A reason is required for key export.'), + { code: 'config' }, + ); } const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64); return { hex, nsec: `nsec1${npub.slice(5)}` }; diff --git a/frontend/src/test/publications.test.tsx b/frontend/src/test/publications.test.tsx new file mode 100644 index 0000000..90d62c8 --- /dev/null +++ b/frontend/src/test/publications.test.tsx @@ -0,0 +1,194 @@ +import { screen, waitFor } from '@testing-library/react'; +import { HomeScreen } from '../screens/HomeScreen'; +import { renderWithApp } from './render'; +import { ALICE } from './apiMock'; +import { createFakeBackend, installFakeBackend } from './fakeBackend'; +import { computePublicationStatus } from '../lib/publications'; +import type { FeedItem, RelayConfig } from '../lib/types'; + +const RELAYS: RelayConfig[] = [ + { url: 'wss://relay.damus.io', enabled: true }, + { url: 'wss://relay.nostr.band', enabled: true }, +]; + +function makeItem(overrides: Partial & { id: string; relays: string[] }): FeedItem { + return { + author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', + author_npub: ALICE, + content: '', + created_at: 1700000000, + ...overrides, + }; +} + +function renderHome(backend: ReturnType) { + installFakeBackend(backend); + renderWithApp(); +} + +async function waitForData() { + await waitFor(() => { + const loading = screen.queryByText(/Loading publications/); + expect(loading).not.toBeInTheDocument(); + const emptyNoPub = screen.queryByText("You haven't published anything yet."); + expect(emptyNoPub).not.toBeInTheDocument(); + }); +} + +describe('computePublicationStatus', () => { + it('returns fully_published when all enabled relays served the event', () => { + const item = makeItem({ id: 'a', relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'] }); + expect(computePublicationStatus(item.relays, RELAYS)).toBe('fully_published'); + }); + + it('returns partially_published when only some relays served the event', () => { + const item = makeItem({ id: 'a', relays: ['wss://relay.damus.io'] }); + expect(computePublicationStatus(item.relays, RELAYS)).toBe('partially_published'); + }); + + it('returns fully_published when no relays are configured', () => { + expect(computePublicationStatus(['wss://x'], [])).toBe('fully_published'); + }); +}); + +describe('HomeScreen — Most recent publication', () => { + it('shows the newest fully published event', async () => { + const backend = createFakeBackend(); + backend.profileFeedItems = [ + makeItem({ + id: 'note1full', + content: 'Fully published note', + created_at: 100, + relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], + }), + ]; + renderHome(backend); + + await waitForData(); + expect(screen.getByText('Fully published note')).toBeInTheDocument(); + expect(screen.getByText(/Published/)).toBeInTheDocument(); + expect(screen.getByText(/note1full/)).toBeInTheDocument(); + }); + + it('hides a partially published newest event from the main box', async () => { + const backend = createFakeBackend(); + backend.profileFeedItems = [ + makeItem({ + id: 'note1partial', + content: 'Partial note', + created_at: 100, + relays: ['wss://relay.damus.io'], + }), + ]; + renderHome(backend); + + await waitForData(); + expect(screen.queryByText('Partial note')).not.toBeInTheDocument(); + expect(screen.getByText(/No fully published publications found/)).toBeInTheDocument(); + }); + + it('shows an older fully published event when the newest is partial', async () => { + const backend = createFakeBackend(); + backend.profileFeedItems = [ + makeItem({ + id: 'note1partial', + content: 'Newer partial', + created_at: 200, + relays: ['wss://relay.damus.io'], + }), + makeItem({ + id: 'note1full', + content: 'Older full', + created_at: 100, + relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], + }), + ]; + renderHome(backend); + + await waitForData(); + expect(screen.getByText('Older full')).toBeInTheDocument(); + expect(screen.getByText(/Published/)).toBeInTheDocument(); + expect(screen.queryByText('Newer partial')).not.toBeInTheDocument(); + }); + + it('shows empty state when all events are partial', async () => { + const backend = createFakeBackend(); + backend.profileFeedItems = [ + makeItem({ + id: 'note1a', + content: 'Partial A', + created_at: 200, + relays: ['wss://relay.damus.io'], + }), + makeItem({ + id: 'note1b', + content: 'Partial B', + created_at: 100, + relays: ['wss://relay.nostr.band'], + }), + ]; + renderHome(backend); + + await waitForData(); + expect(screen.getByText(/No fully published publications found/)).toBeInTheDocument(); + expect(screen.queryByText('Partial A')).not.toBeInTheDocument(); + }); + + it('shows partial event details in Relay results expandable', async () => { + const backend = createFakeBackend(); + backend.profileFeedItems = [ + makeItem({ + id: 'note1full', + content: 'Full note', + created_at: 200, + relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], + }), + makeItem({ + id: 'note1partial', + content: 'Partial note', + created_at: 100, + relays: ['wss://relay.damus.io'], + }), + ]; + renderHome(backend); + + await waitForData(); + expect(screen.getByText('Full note')).toBeInTheDocument(); + + const relaySummary = screen.getByText('Relay results'); + expect(relaySummary).toBeInTheDocument(); + + const details = relaySummary.closest('details') as HTMLDetailsElement; + details.open = true; + details.dispatchEvent(new Event('toggle')); + + await waitFor(() => { + expect( + screen.getByText((_, element) => { + return ( + element?.textContent?.includes('wss://relay.damus.io') === true && + element?.textContent?.includes('accepted') === true && + element?.tagName === 'LI' + ); + }), + ).toBeInTheDocument(); + }); + }); + + it('does not duplicate events with the same ID', async () => { + const backend = createFakeBackend(); + backend.profileFeedItems = [ + makeItem({ + id: 'note1same', + content: 'Same event', + created_at: 100, + relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], + }), + ]; + renderHome(backend); + + await waitForData(); + const matches = screen.getAllByText(/note1same/); + expect(matches.length).toBe(1); + }); +}); diff --git a/frontend/src/test/publishFlow.test.tsx b/frontend/src/test/publishFlow.test.tsx index 53ea87c..b415c36 100644 --- a/frontend/src/test/publishFlow.test.tsx +++ b/frontend/src/test/publishFlow.test.tsx @@ -1,19 +1,31 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import App from '../App'; +import { ALICE } from './apiMock'; import { createFakeBackend, installFakeBackend } from './fakeBackend'; describe('publication flow across screens', () => { it('publishes from Compose and shows the result on Home', async () => { const backend = createFakeBackend(); backend.state.settings.confirm_before_publish = false; + backend.profileFeedItems = [ + { + id: backend.publishReport.event_id, + author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', + author_npub: ALICE, + content: 'Hello from the flow test', + created_at: Math.floor(Date.now() / 1000), + relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], + }, + ]; installFakeBackend(backend); const user = userEvent.setup(); render(); await screen.findByRole('heading', { name: 'Home' }); - await user.click(screen.getByRole('button', { name: /Compose note/i })); + const main = screen.getByRole('main'); + await user.click(within(main).getByRole('button', { name: 'Compose' })); await screen.findByRole('heading', { name: 'Compose' }); await user.type(screen.getByLabelText('Note content'), 'Hello from the flow test'); @@ -23,7 +35,7 @@ describe('publication flow across screens', () => { await user.click(screen.getByRole('button', { name: 'Home' })); await screen.findByRole('heading', { name: 'Home' }); - expect(await screen.findByText('Published')).toBeInTheDocument(); - expect(screen.getByTitle(backend.publishReport.event_id)).toBeInTheDocument(); + expect(await screen.findByText(/Published/)).toBeInTheDocument(); + expect(screen.getByText(/Hello from the flow test/)).toBeInTheDocument(); }); }); diff --git a/src/app.rs b/src/app.rs index 88b569a..b6f9372 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,17 @@ use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use serde::Serialize; +use std::sync::Arc; use zeroize::{Zeroize, Zeroizing}; +use crate::audit::AuditLog; use crate::crypto::{self, VaultKey}; use crate::errors::AppError; use crate::profiles::{self, ProfileSummary}; use crate::settings::Settings; -use crate::vault::{self, KdfParams, StoredProfile, Vault, VaultCrypto}; +use crate::vault::{ + self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto, +}; /// Minimum password length accepted when encrypting the vault. pub const MIN_PASSWORD_LEN: usize = 8; @@ -20,8 +24,29 @@ pub struct App { unlock_key: Option, /// Stack of deleted profiles for undo functionality. pub undo_history: Vec, + /// The most recent publish report, persisted across restarts. + pub last_publish: Option, + /// Active signer mode. + pub signer_mode: SignerMode, + /// Embedded signer instance. + pub embedded_signer: Option, + /// NIP-46 client signer instance. + pub nip46_signer: Option, + /// NIP-46 bunker signer instance (legacy). + pub nip46_bunker_signer: Option, + /// Audit log for security-sensitive operations. May be absent in test environments. + pub audit_log: Option, } +/// Handle for the embedded signer (type-erased for App storage). +pub type EmbeddedSignerHandle = Arc; + +/// Handle for the NIP-46 client signer (type-erased for App storage). +pub type Nip46ClientSignerHandle = Arc; + +/// Handle for the NIP-46 bunker signer (type-erased for App storage). +pub type Nip46BunkerSignerHandle = Arc; + /// Snapshot of everything the UI needs, containing no secret keys. #[derive(Debug, Clone, Serialize)] pub struct AppStateView { @@ -38,16 +63,35 @@ pub struct AppStateView { /// Recently deleted profiles, newest last, for undo. #[serde(skip_serializing_if = "Vec::is_empty")] pub undo_history: Vec, + /// The most recent publish report, persisted across restarts. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_publish: Option, + /// Active signer mode. + pub signer_mode: SignerMode, } impl App { /// Load the vault (migrating a legacy vault if needed) and settings. pub fn load() -> Result { + let mut vault = vault::load_vault()?; + // Ensure every profile has an explicit signer_mode and the vault + // version is current. Idempotent — safe to call on every load. + let migrated = vault::migrate_vault_signer_modes(&mut vault); + if migrated { + // Persist the normalised vault so the on-disk format stays canonical. + vault::save_vault(&vault)?; + } Ok(Self { - vault: vault::load_vault()?, + vault, settings: vault::load_settings()?, unlock_key: None, undo_history: Vec::new(), + last_publish: vault::load_last_publish(), + signer_mode: SignerMode::Nip46Client, + embedded_signer: None, + nip46_signer: None, + nip46_bunker_signer: None, + audit_log: AuditLog::open().ok(), }) } @@ -94,6 +138,83 @@ impl App { } } + /// Export a profile's secret key with fresh re-authentication. + /// + /// Always requires `password` to be provided, even if the vault is + /// currently unlocked for the session. This is a deliberate security + /// decision: every export is an explicit, logged, authenticated action. + /// + /// Returns the revealed key (hex + nsec) on success. + pub fn export_secret_key( + &mut self, + npub: &str, + password: &str, + reason: &str, + is_deprecated: bool, + ) -> Result { + if reason.trim().is_empty() && !is_deprecated { + return Err(AppError::config("A reason is required for key export.")); + } + + // Check profile exists and is not externally managed + let stored = profiles::find_stored_profile(&self.vault, npub)?; + let signer_mode = stored.signer_mode; + if signer_mode == SignerMode::Nip46Client { + if let Some(ref mut log) = self.audit_log { + let _ = log.record( + npub, + crate::audit::AuditAction::KeyExport, + reason, + false, + Some("Profile uses external signer; key export not possible".to_string()), + ); + } + return Err(AppError::external_signer_not_connected()); + } + + // Derive key from password and verify + let export_key = if let Some(crypto) = self.vault.crypto.as_ref() { + let key = derive_with(crypto, password)?; + if !crypto::verify(&key, &crypto.verifier) { + if let Some(ref mut log) = self.audit_log { + let _ = log.record( + npub, + crate::audit::AuditAction::KeyExport, + reason, + false, + Some("Authentication failed".to_string()), + ); + } + return Err(AppError::wrong_password()); + } + Some(key) + } else { + // No vault password set; password param is ignored + None + }; + + // Decrypt the secret key + let revealed = profiles::reveal_secret_key(&self.vault, npub, export_key.as_ref())?; + + // Audit the successful export — MUST succeed before returning the key. + // If the audit log cannot be written, the key is not returned (fail-closed). + if let Some(ref mut log) = self.audit_log { + log.record( + npub, + crate::audit::AuditAction::KeyExport, + if is_deprecated { + "[deprecated direct call]" + } else { + reason + }, + true, + None, + )?; + } + + Ok(revealed) + } + /// Undo the last profile deletion, restoring the profile to the vault. /// Returns the restored profile summary, or an error if there is no undo history. pub fn undo_delete(&mut self) -> Result { @@ -115,6 +236,7 @@ impl App { created_at: restored.created_at, picture: restored.picture.clone(), nip05: restored.nip05.clone(), + signer_mode: SignerMode::Embedded, }; self.vault.profiles.push(stored); // If no active profile, this restored one becomes active @@ -240,6 +362,8 @@ impl App { profiles: profiles::summaries(&self.vault), settings: self.settings.clone(), undo_history: self.undo_history.clone(), + last_publish: self.last_publish.clone(), + signer_mode: self.signer_mode, } } } @@ -302,6 +426,12 @@ mod tests { settings: offline_settings(), unlock_key: None, undo_history: Vec::new(), + last_publish: None, + signer_mode: SignerMode::Embedded, + embedded_signer: None, + nip46_signer: None, + nip46_bunker_signer: None, + audit_log: None, } } diff --git a/src/audit.rs b/src/audit.rs new file mode 100644 index 0000000..1e7512f --- /dev/null +++ b/src/audit.rs @@ -0,0 +1,476 @@ +use std::fs; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::errors::AppError; + +/// File name for the append-only audit log. +const AUDIT_LOG_FILE: &str = "audit.log"; + +/// Algorithm used for hash-chaining. +/// Hash algorithm used for chain entries (informational only). +#[allow(dead_code)] +const HASH_ALGORITHM: &str = "sha256"; + +/// Canonical audit log entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEntry { + /// Unix timestamp in seconds. + pub timestamp: u64, + /// The profile npub this action relates to. + pub profile_npub: String, + /// Action type. + pub action: AuditAction, + /// Human-readable reason for the action (required for exports). + pub reason: String, + /// Whether the action succeeded. + pub success: bool, + /// Error message if failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Hash of the previous entry for chain integrity. + pub prev_hash: String, + /// Hash of this entry (timestamp|profile|action|reason|success|error|prev_hash). + pub this_hash: String, +} + +/// Actions that are audited. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + /// Private key export requested. + KeyExport, + /// NIP-46 connection created. + ConnectionCreated, + /// NIP-46 connection revoked. + ConnectionRevoked, + /// Signing request approved/rejected. + SignRequest, + /// Encrypt/decrypt request. + Nip44Request, + /// Vault unlocked. + VaultUnlocked, + /// Vault locked. + VaultLocked, + /// NIP-46 operation denied by permission check. + ConnectionPermissionDenied, +} + +/// The audit log writer. +pub struct AuditLog { + path: PathBuf, + last_hash: Mutex, +} + +impl AuditLog { + /// Open or create the audit log, returning the last hash for chaining. + pub fn open() -> Result { + let path = crate::vault::data_dir().join(AUDIT_LOG_FILE); + let last_hash = Self::compute_last_hash(&path)?; + Ok(Self { + path, + last_hash: Mutex::new(last_hash), + }) + } + + /// Compute the hash of the last entry in the log, or genesis hash if empty. + fn compute_last_hash(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(Self::genesis_hash()); + } + let content = + fs::read_to_string(path).map_err(|e| AppError::io("Could not read audit log", e))?; + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return Ok(Self::genesis_hash()); + } + // Parse the last line as JSON and extract its this_hash + let last_line = lines.last().unwrap(); + let entry: AuditEntry = serde_json::from_str(last_line) + .map_err(|e| AppError::vault_malformed(format!("Audit log corrupted: {e}")))?; + Ok(entry.this_hash) + } + + /// Genesis hash for empty log. + fn genesis_hash() -> String { + "0".repeat(64) + } + + /// Write an audit entry atomically. Fails closed if write fails. + /// + /// The mutex is held across the entire check-write-update cycle to prevent + /// concurrent threads from reading the same `prev_hash`, which would cause + /// one entry to silently overwrite another on rename. + pub fn write_entry(&self, entry: &AuditEntry) -> Result<(), AppError> { + let mut last = self.last_hash.lock().expect("audit mutex poisoned"); + + // Verify chain integrity before appending + if entry.prev_hash != *last { + return Err(AppError::storage( + "Audit chain integrity check failed: prev_hash mismatch", + )); + } + + // Serialize canonically: sorted keys, no whitespace, deterministic + let json = serde_json::to_string(entry) + .map_err(|e| AppError::json("Could not serialize audit entry", e))?; + + // Atomic append: read existing, write all to temp, sync, rename + let tmp_path = self.path.with_extension("log.tmp"); + { + // Read existing content (empty file is fine) + let existing = fs::read_to_string(&self.path).unwrap_or_default(); + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp_path) + .map_err(|e| AppError::io("Could not open audit log temp file", e))?; + file.write_all(existing.as_bytes()) + .map_err(|e| AppError::io("Could not write existing audit log content", e))?; + file.write_all(json.as_bytes()) + .map_err(|e| AppError::io("Could not write audit log temp file", e))?; + file.write_all(b"\n") + .map_err(|e| AppError::io("Could not write audit log newline", e))?; + file.sync_all() + .map_err(|e| AppError::io("Could not sync audit log temp file", e))?; + } + + // Rename temp to actual (atomic on POSIX) + fs::rename(&tmp_path, &self.path) + .map_err(|e| AppError::io("Could not finalize audit log", e))?; + + // Update last hash — still under the same lock + *last = entry.this_hash.clone(); + + Ok(()) + } + + /// Build and write a new entry, returning the entry for the caller. + /// + /// The entire read-compute-write-update cycle is under a single mutex + /// acquisition to prevent concurrent writers from interleaving. + pub fn record( + &self, + profile_npub: &str, + action: AuditAction, + reason: &str, + success: bool, + error: Option, + ) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| AppError::internal(format!("System clock error: {e}")))? + .as_secs(); + + let mut last = self.last_hash.lock().expect("audit mutex poisoned"); + let prev_hash = last.clone(); + + // Compute this hash from canonical fields + let this_hash = Self::compute_hash(&AuditEntry { + timestamp, + profile_npub: profile_npub.to_string(), + action, + reason: reason.to_string(), + success, + error: error.clone(), + prev_hash: prev_hash.clone(), + this_hash: String::new(), // placeholder + }); + + let entry = AuditEntry { + timestamp, + profile_npub: profile_npub.to_string(), + action, + reason: reason.to_string(), + success, + error, + prev_hash, + this_hash, + }; + + // Serialize canonically + let json = serde_json::to_string(&entry) + .map_err(|e| AppError::json("Could not serialize audit entry", e))?; + + // Atomic append: read existing, write all to temp, sync, rename + let tmp_path = self.path.with_extension("log.tmp"); + { + let existing = fs::read_to_string(&self.path).unwrap_or_default(); + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp_path) + .map_err(|e| AppError::io("Could not open audit log temp file", e))?; + file.write_all(existing.as_bytes()) + .map_err(|e| AppError::io("Could not write existing audit log content", e))?; + file.write_all(json.as_bytes()) + .map_err(|e| AppError::io("Could not write audit log temp file", e))?; + file.write_all(b"\n") + .map_err(|e| AppError::io("Could not write audit log newline", e))?; + file.sync_all() + .map_err(|e| AppError::io("Could not sync audit log temp file", e))?; + } + + // Rename temp to actual (atomic on POSIX) + fs::rename(&tmp_path, &self.path) + .map_err(|e| AppError::io("Could not finalize audit log", e))?; + + // Update last hash — still under the same lock + *last = entry.this_hash.clone(); + + Ok(entry) + } + + /// Canonical hash: timestamp|profile_npub|action|reason|success|error|prev_hash + /// All fields are JSON-encoded to avoid delimiter ambiguity. + fn compute_hash(entry: &AuditEntry) -> String { + let mut hasher = Sha256::new(); + // Use JSON values for canonical representation + let timestamp_json = serde_json::to_string(&entry.timestamp).unwrap(); + let profile_json = serde_json::to_string(&entry.profile_npub).unwrap(); + let action_json = serde_json::to_string(&entry.action).unwrap(); + let reason_json = serde_json::to_string(&entry.reason).unwrap(); + let success_json = serde_json::to_string(&entry.success).unwrap(); + let error_json = serde_json::to_string(&entry.error).unwrap(); + let prev_hash_json = serde_json::to_string(&entry.prev_hash).unwrap(); + + hasher.update(timestamp_json.as_bytes()); + hasher.update(b"|"); + hasher.update(profile_json.as_bytes()); + hasher.update(b"|"); + hasher.update(action_json.as_bytes()); + hasher.update(b"|"); + hasher.update(reason_json.as_bytes()); + hasher.update(b"|"); + hasher.update(success_json.as_bytes()); + hasher.update(b"|"); + hasher.update(error_json.as_bytes()); + hasher.update(b"|"); + hasher.update(prev_hash_json.as_bytes()); + + B64.encode(hasher.finalize()) + } + + /// Verify the entire chain from genesis to end. + pub fn verify_chain(&self) -> Result { + if !self.path.exists() { + return Ok(true); + } + let content = fs::read_to_string(&self.path) + .map_err(|e| AppError::io("Could not read audit log for verification", e))?; + let mut expected_prev = Self::genesis_hash(); + for line in content.lines() { + let entry: AuditEntry = match serde_json::from_str(line) { + Ok(e) => e, + Err(_) => return Ok(false), // corrupted line = invalid chain + }; + if entry.prev_hash != expected_prev { + return Ok(false); + } + let computed = Self::compute_hash(&entry); + if computed != entry.this_hash { + return Ok(false); + } + expected_prev = entry.this_hash; + } + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + fn temp_audit_dir() -> PathBuf { + let dir = env::temp_dir().join(format!( + "keynectr-audit-test-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn audit_log_chain_works() { + let dir = temp_audit_dir(); + let log_path = dir.join(AUDIT_LOG_FILE); + // Manually create an AuditLog pointing to our temp dir + let audit = AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + + // Write first entry + let e1 = audit + .record( + "npub1alice", + AuditAction::KeyExport, + "migration backup", + true, + None, + ) + .unwrap(); + assert_eq!(e1.prev_hash, AuditLog::genesis_hash()); + assert!(AuditLog::compute_hash(&e1) == e1.this_hash); + + // Write second entry + let e2 = audit + .record( + "npub1bob", + AuditAction::KeyExport, + "key rotation", + true, + None, + ) + .unwrap(); + assert_eq!(e2.prev_hash, e1.this_hash); + assert!(AuditLog::compute_hash(&e2) == e2.this_hash); + + // Verify chain + assert!(audit.verify_chain().unwrap()); + + // Read back and verify + let content = fs::read_to_string(&log_path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2); + let parsed1: AuditEntry = serde_json::from_str(lines[0]).unwrap(); + let parsed2: AuditEntry = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(parsed1.this_hash, e1.this_hash); + assert_eq!(parsed2.this_hash, e2.this_hash); + } + + #[test] + fn audit_log_rejects_tampered_chain() { + let dir = temp_audit_dir(); + let log_path = dir.join(AUDIT_LOG_FILE); + let audit = AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + + let e1 = audit + .record("npub1alice", AuditAction::KeyExport, "reason", true, None) + .unwrap(); + // Tamper: modify the file directly + let mut content = fs::read_to_string(&log_path).unwrap(); + content = content.replace(&e1.reason, "tampered"); + fs::write(&log_path, content).unwrap(); + + // New AuditLog should detect mismatch + let audit2 = AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + assert!(!audit2.verify_chain().unwrap()); + } + + #[test] + fn audit_entry_serialization_deterministic() { + let entry = AuditEntry { + timestamp: 1_700_000_000, + profile_npub: "npub1test".to_string(), + action: AuditAction::KeyExport, + reason: "test reason".to_string(), + success: true, + error: None, + prev_hash: "0".repeat(64), + this_hash: "1".repeat(64), + }; + let json1 = serde_json::to_string(&entry).unwrap(); + let json2 = serde_json::to_string(&entry).unwrap(); + assert_eq!(json1, json2); + } + + #[test] + fn audit_log_fails_on_write_error() { + // Use a path we can't write to + let audit = AuditLog { + path: PathBuf::from("/root/cannot_write.log"), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }; + let entry = AuditEntry { + timestamp: 1, + profile_npub: "npub1test".to_string(), + action: AuditAction::KeyExport, + reason: "test".to_string(), + success: true, + error: None, + prev_hash: AuditLog::genesis_hash(), + this_hash: "x".repeat(64), + }; + assert!(audit.write_entry(&entry).is_err()); + } + + #[test] + fn concurrent_audit_writes_are_serialized() { + use std::sync::Arc; + use std::thread; + + let dir = temp_audit_dir(); + let log_path = dir.join(AUDIT_LOG_FILE); + let audit = Arc::new(AuditLog { + path: log_path.clone(), + last_hash: Mutex::new(AuditLog::genesis_hash()), + }); + + let num_writers = 8; + let mut handles = vec![]; + + for i in 0..num_writers { + let audit_clone = Arc::clone(&audit); + handles.push(thread::spawn(move || { + audit_clone + .record( + &format!("npub1writer{i}"), + AuditAction::KeyExport, + &format!("concurrent write {i}"), + true, + None, + ) + .unwrap(); + })); + } + + for h in handles { + h.join().unwrap(); + } + + // Verify chain: all entries present and chain valid + let content = fs::read_to_string(&log_path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), num_writers); + + // Verify chain integrity + assert!(audit.verify_chain().unwrap()); + + // Verify no duplicate npubs (each writer wrote a unique entry) + let npubs: Vec = lines + .iter() + .filter_map(|line| { + let entry: AuditEntry = serde_json::from_str(line).ok()?; + Some(entry.profile_npub) + }) + .collect(); + let unique: std::collections::HashSet<_> = npubs.iter().collect(); + assert_eq!(unique.len(), num_writers); + } +} diff --git a/src/signer.rs b/src/bunker.rs similarity index 90% rename from src/signer.rs rename to src/bunker.rs index 56fa85e..307f69a 100644 --- a/src/signer.rs +++ b/src/bunker.rs @@ -73,6 +73,10 @@ pub struct SignerStatus { pub peer: Option, /// Relays used for the connection. pub relays: Vec, + /// The subset of `relays` that is actually connected right now. Empty + /// while the pool is still connecting; used by the UI to show which of + /// the link's relays answered and which did not. + pub connected_relays: Vec, /// A user-facing error if the signer stopped because of one. pub error: Option, /// Requests currently waiting for the user to approve or reject them. @@ -89,6 +93,7 @@ struct SignerInner { phase: SignerPhase, peer: Option, relays: Vec, + connected_relays: Vec, error: Option, task: Option>, /// Requests waiting for the user to approve or reject, keyed by an @@ -118,6 +123,7 @@ impl Signer { phase: SignerPhase::Stopped, peer: None, relays: Vec::new(), + connected_relays: Vec::new(), error: None, task: None, pending: HashMap::new(), @@ -142,6 +148,7 @@ impl Signer { phase: inner.phase, peer: inner.peer.map(|pk| pk.to_hex()), relays: inner.relays.clone(), + connected_relays: inner.connected_relays.clone(), error: inner.error.clone(), pending, } @@ -157,6 +164,7 @@ impl Signer { inner.phase = SignerPhase::Stopped; inner.peer = None; inner.relays.clear(); + inner.connected_relays.clear(); inner.error = None; inner.pending.clear(); } @@ -259,6 +267,7 @@ impl Signer { inner.phase = SignerPhase::Connecting; inner.peer = Some(parsed.peer); inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect(); + inner.connected_relays.clear(); inner.error = None; } @@ -272,6 +281,7 @@ impl Signer { inner.phase = SignerPhase::Stopped; inner.error = Some(message.into()); inner.task = None; + inner.connected_relays.clear(); inner.pending.clear(); } @@ -598,6 +608,26 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result { } } +/// URLs of the pool's relays that are connected right now, polling until at +/// least one answers or `deadline` passes. `and_wait` can return while relays +/// are still dialling, so a single status check would undercount slow relays. +async fn connected_relay_urls(client: &Client, deadline: tokio::time::Instant) -> Vec { + let mut urls: Vec = loop { + let map = client.relays().all().await; + let urls: Vec = map + .into_iter() + .filter(|(_, relay)| relay.status().is_connected()) + .map(|(url, _)| url.to_string()) + .collect(); + if !urls.is_empty() || tokio::time::Instant::now() >= deadline { + break urls; + } + tokio::time::sleep(Duration::from_millis(250)).await; + }; + urls.sort(); + urls +} + /// The background loop: connect to the client's relays, announce ourselves, /// subscribe to kind 24133 events, and answer requests until stopped. async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { @@ -646,6 +676,33 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C } client.connect().and_wait(CONNECT_TIMEOUT).await; + // `and_wait` returns when the pool has settled or the timeout elapsed, + // but individual relays may still be dialling. Poll for a short while so + // slow-but-alive relays are counted, and record which relays actually + // connected — the UI shows this so a partially dead link is visible + // instead of a silent "Connecting…". + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + let connected = connected_relay_urls(&client, deadline).await; + signer + .inner + .lock() + .expect("signer mutex poisoned") + .connected_relays = connected.clone(); + if connected.is_empty() { + let list = uri + .relays + .iter() + .map(|r| r.to_string()) + .collect::>() + .join(", "); + signer.fail(format!( + "None of the relays in the link answered: {list}. The link's relays are unreachable \ + from this machine — check your internet connection or have the app use a different \ + relay, then try again." + )); + return; + } + // 4. Subscribe to the client's kind 24133 events so we hear its requests. // Do not use `stream_events` here: it is an auto-closing historical-event // helper and ends at EOSE. NIP-46 needs a long-lived subscription because @@ -1063,6 +1120,63 @@ mod tests { assert!(signer.approve("no-such-id", true).is_err()); } + #[tokio::test] + async fn connected_relay_urls_is_empty_when_no_relay_answers() { + // 192.0.2.1 is TEST-NET-1: guaranteed to be unroutable, so the pool + // can never connect to it. The helper must report "nothing" and stop + // at the deadline rather than hang. + let client = Client::new(); + client + .add_relay("wss://192.0.2.1") + .await + .expect("add relay"); + let deadline = tokio::time::Instant::now() + Duration::from_millis(100); + let urls = connected_relay_urls(&client, deadline).await; + assert!(urls.is_empty()); + } + + #[tokio::test] + async fn status_reports_connected_relays_and_fail_clears_them() { + let signer = Signer::new(); + { + let mut inner = signer.inner.lock().unwrap(); + inner.phase = SignerPhase::Connecting; + inner.relays = vec![ + "wss://relay.damus.io".to_string(), + "wss://relay.nostr.band".to_string(), + ]; + inner.connected_relays = vec!["wss://relay.damus.io".to_string()]; + } + + let status = signer.status(); + assert_eq!(status.phase, SignerPhase::Connecting); + assert_eq!(status.relays.len(), 2); + assert_eq!(status.connected_relays, vec!["wss://relay.damus.io"]); + + signer.fail("None of the relays answered"); + let status = signer.status(); + assert_eq!(status.phase, SignerPhase::Stopped); + assert!(status.connected_relays.is_empty()); + assert_eq!(status.error.as_deref(), Some("None of the relays answered")); + } + + #[test] + fn disconnect_clears_connected_relays() { + let signer = Signer::new(); + { + let mut inner = signer.inner.lock().unwrap(); + inner.phase = SignerPhase::Connected; + inner.relays = vec!["wss://relay.damus.io".to_string()]; + inner.connected_relays = vec!["wss://relay.damus.io".to_string()]; + } + + signer.disconnect(); + let status = signer.status(); + assert_eq!(status.phase, SignerPhase::Stopped); + assert!(status.connected_relays.is_empty()); + assert!(status.relays.is_empty()); + } + #[tokio::test] async fn pending_approvals_are_capped() { let signer = Signer::new(); diff --git a/src/errors.rs b/src/errors.rs index effad16..1736444 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -42,6 +42,20 @@ pub enum ErrorKind { Config, /// Unexpected internal failure. Internal, + /// External signing is selected but no external signer is connected. + ExternalSignerNotConnected, + /// The external signer's identity differs from the active profile. + ExternalSignerIdentityMismatch, + /// A signing operation was rejected by the signer. + SignerRejected, + /// A signing operation timed out. + SignerTimeout, + /// A NIP-46 permission check denied the requested operation. + Nip46PermissionDenied, + /// A NIP-46 connection has expired. + Nip46ConnectionExpired, + /// A NIP-46 connection has been revoked. + Nip46ConnectionRevoked, } /// Structured application error. @@ -191,6 +205,65 @@ impl AppError { details, ) } + + /// External signing is selected but no external signer is connected. + pub fn external_signer_not_connected() -> Self { + Self::simple( + ErrorKind::ExternalSignerNotConnected, + "An external signer is selected but not connected. Connect it, or switch to the local signer.", + ) + } + + /// The external signer's identity differs from the active profile. + pub fn external_signer_identity_mismatch() -> Self { + Self::simple( + ErrorKind::ExternalSignerIdentityMismatch, + "The external signer's key does not match this profile. Reconnect with the correct signer.", + ) + } + + /// A signing operation was rejected by the signer. + pub fn signer_rejected(details: impl fmt::Display) -> Self { + Self::with_details( + ErrorKind::SignerRejected, + "The signing request was rejected by the signer.", + details, + ) + } + + /// A signing operation timed out. + pub fn signer_timeout(details: impl fmt::Display) -> Self { + Self::with_details( + ErrorKind::SignerTimeout, + "The signing request timed out. Check that your signer is running and try again.", + details, + ) + } + + /// A NIP-46 permission check denied the requested operation. + pub fn nip46_permission_denied(method: &str) -> Self { + Self::with_details( + ErrorKind::Nip46PermissionDenied, + "This operation is not permitted by the connected signer.", + format!("Permission denied for NIP-46 method: {method}"), + ) + } + + /// A NIP-46 connection has expired. + pub fn nip46_connection_expired() -> Self { + Self::simple( + ErrorKind::Nip46ConnectionExpired, + "The NIP-46 connection has expired. Reconnect to the signer.", + ) + } + + /// A NIP-46 connection has been revoked. + pub fn nip46_connection_revoked() -> Self { + Self::simple( + ErrorKind::Nip46ConnectionRevoked, + "The NIP-46 connection has been revoked. Reconnect to the signer.", + ) + } } impl fmt::Display for AppError { diff --git a/src/ipc.rs b/src/ipc.rs index f5554d9..cfa1bed 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -12,8 +12,11 @@ use crate::profiles; use crate::publish; use crate::relays; use crate::settings::Theme; -use crate::signer::Signer; +use crate::signer::embedded::EmbeddedSigner; +use crate::signer::nip46_client::Nip46ClientSigner; +use crate::signer::Signer as SignerTrait; use crate::updates; +use crate::vault::SignerMode; /// How long to wait for a relay connection test. const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); @@ -37,6 +40,10 @@ pub enum Request { CreateProfile { label: String, }, + ImportProfile { + label: String, + secret: String, + }, SelectProfile { npub: String, }, @@ -125,20 +132,58 @@ pub enum Request { RevealSecretKey { npub: String, }, + /// Export a profile's secret key with fresh re-authentication and audit logging. + /// Always requires the vault passphrase, even if already unlocked. + ExportSecretKey { + npub: String, + password: String, + reason: String, + }, /// Sign a NIP-98 auth event for the active profile, for uploading media. UploadAuth { url: String, http_method: String, }, - /// Start the NIP-46 remote signer for a `nostrconnect://` link. + /// ===== SIGNER MODE MANAGEMENT ===== + /// Get the current signer mode. + SignerModeGet, + /// Set the signer mode (embedded or nip46). + SignerModeSet { + mode: SignerMode, + }, + /// ===== EMBEDDED SIGNER ===== + /// Get embedded signer status. + EmbeddedSignerStatus, + /// Approve/reject a pending embedded signer request. + EmbeddedSignerApprove { + index: usize, + approved: bool, + }, + /// ===== NIP-46 CLIENT SIGNER ===== + /// Connect to a NIP-46 signer using a nostrconnect:// URI. + Nip46Connect { + uri: String, + label: String, + }, + /// Disconnect from the NIP-46 signer. + Nip46Disconnect, + /// Get NIP-46 connection status. + Nip46Status, + /// Approve/reject a pending NIP-46 request. + Nip46Approve { + id: String, + approved: bool, + }, + /// ===== LEGACY NIP-46 BUNKER (server mode) ===== + /// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). SignerConnect { uri: String, }, - /// Stop the NIP-46 remote signer. + /// Stop the NIP-46 remote signer (bunker mode). SignerDisconnect, - /// Report the remote signer's current status. + /// Report the remote signer's current status (bunker mode). SignerStatus, - /// Approve or reject a NIP-46 request that is waiting for a decision. + /// Approve or reject a NIP-46 request that is waiting for a decision (bunker mode). SignerApprove { /// The internal id of the pending request, as reported by /// `SignerStatus.pending`. @@ -192,7 +237,6 @@ pub async fn serve() -> Result<(), AppError> { // Shared state, so the NIP-46 signer's background task and the request loop // both see the same vault (including its unlock key) without racing writes. let app = Arc::new(Mutex::new(App::load()?)); - let signer = Arc::new(Signer::new()); let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); let stdin = tokio::io::stdin(); @@ -222,10 +266,9 @@ pub async fn serve() -> Result<(), AppError> { }; let task_app = app.clone(); - let task_signer = signer.clone(); let task_stdout = stdout.clone(); tasks.spawn(async move { - let reply = handle(task_app, task_signer, envelope.request).await; + let reply = handle(task_app, envelope.request).await; let _ = write_line( &task_stdout, ReplyEnvelope { @@ -264,12 +307,8 @@ async fn write_line( Ok(()) } -async fn handle( - app: Arc>, - signer: Arc, - request: Request, -) -> Reply { - let result = run(&app, &signer, request).await; +async fn handle(app: Arc>, request: Request) -> Reply { + let result = run(&app, request).await; match result { Ok(value) => Reply::Ok { data: value }, Err(err) => Reply::Error { @@ -292,43 +331,167 @@ fn error_code(err: &AppError) -> String { .unwrap_or_else(|_| "error".to_string()) } -/// Signer control commands never touch the vault directly, so they take the -/// shared handle (a clone) rather than locking the state. Read-only network -/// requests (relay tests, feed reads) grab what they need under a short lock -/// and then run without it, so slow relays cannot delay interactive requests. -/// Everything else locks the state for the duration of the call, so mutations -/// remain serialized and never interleave. -async fn run( - app: &Arc>, - signer: &Signer, - request: Request, -) -> Result { +/// Main request dispatcher. +async fn run(app: &Arc>, request: Request) -> Result { match request { + // Signer mode management + Request::SignerModeGet => { + let guard = app.lock().await; + Ok(json!({ "mode": guard.signer_mode })) + } + Request::SignerModeSet { mode } => { + let mut guard = app.lock().await; + // Initialize the appropriate signer if needed + match mode { + SignerMode::Embedded => { + if guard.embedded_signer.is_none() { + let signer = Arc::new(EmbeddedSigner::new(app.clone())); + if let Some(npub) = &guard.vault.active_profile { + signer.set_active_profile(Some(npub.clone())).await; + } + guard.embedded_signer = Some(signer); + } + guard.nip46_signer = None; + guard.nip46_bunker_signer = None; + } + SignerMode::Nip46Bunker => { + // Legacy bunker mode - not fully implemented + guard.embedded_signer = None; + guard.nip46_signer = None; + } + SignerMode::Nip46Client => { + if guard.nip46_signer.is_none() { + let signer = Arc::new(Nip46ClientSigner::new(app.clone())); + guard.nip46_signer = Some(signer); + } + guard.embedded_signer = None; + guard.nip46_bunker_signer = None; + } + } + guard.signer_mode = mode; + guard.save_vault()?; + Ok(json!(guard.state_view())) + } + + // Embedded signer + Request::EmbeddedSignerStatus => { + let guard = app.lock().await; + if let Some(signer) = &guard.embedded_signer { + let status = signer.detailed_status().await; + Ok(json!(status)) + } else { + Ok(json!({ "type": "embedded", "available": false, "error": "Not initialized" })) + } + } + Request::EmbeddedSignerApprove { index, approved } => { + let guard = app.lock().await; + if let Some(signer) = &guard.embedded_signer { + signer.respond_to_approval(index, approved).await?; + let status = signer.detailed_status().await; + Ok(json!(status)) + } else { + Err(AppError::config("Embedded signer not initialized")) + } + } + + // NIP-46 client signer + Request::Nip46Connect { uri, label } => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + let status = signer.connect(&uri, label).await?; + Ok(json!(status)) + } else { + Err(AppError::config( + "NIP-46 signer not initialized. Set signer mode to nip46 first.", + )) + } + } + Request::Nip46Disconnect => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + signer.disconnect().await?; + let status = signer.status().await; + Ok(json!(status)) + } else { + Err(AppError::config("NIP-46 signer not initialized")) + } + } + Request::Nip46Status => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + let status = signer.status().await; + Ok(json!(status)) + } else { + Ok(json!({ "connected": false, "error": "Not initialized" })) + } + } + Request::Nip46Approve { id, approved } => { + let guard = app.lock().await; + if let Some(signer) = &guard.nip46_signer { + signer.respond_to_approval(&id, approved).await?; + let status = signer.status().await; + Ok(json!(status)) + } else { + Err(AppError::config("NIP-46 signer not initialized")) + } + } + + // Legacy NIP-46 bunker (server mode) Request::SignerConnect { uri } => { - signer.connect(app.clone(), &uri)?; - Ok(json!(signer.status())) + let guard = app.lock().await; + if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?; + return Ok(json!(status)); + } + } + Err(AppError::config( + "Legacy bunker mode not supported. Use NIP-46 client mode.", + )) } Request::SignerDisconnect => { - signer.disconnect(); - Ok(json!(signer.status())) + let guard = app.lock().await; + if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + signer.disconnect().await?; + let status = signer.status().await; + return Ok(json!(status)); + } + } + Err(AppError::config("Not in NIP-46 client mode")) + } + Request::SignerStatus => { + let guard = app.lock().await; + if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + let status = signer.status().await; + return Ok(json!(status)); + } + } + Err(AppError::config("Not in NIP-46 client mode")) } - Request::SignerStatus => Ok(json!(signer.status())), Request::SignerApprove { id, approved } => { - signer.approve(&id, approved)?; - Ok(json!(signer.status())) + let guard = app.lock().await; + if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { + if let Some(signer) = &guard.nip46_signer { + signer.respond_to_approval(&id, approved).await?; + let status = signer.status().await; + return Ok(json!(status)); + } + } + Err(AppError::config("Not in NIP-46 client mode")) } + + // Network-only requests (no shared state lock) Request::RelayTest { url } => { - // Pure network probe against the given URL; no shared state. let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; Ok(json!(result)) } Request::UpdateCheck => { - // Long-running package-manager scan; never touches shared state. let report = updates::check().await?; Ok(json!(report)) } Request::UpdateApply => { - // Installs updates on disk; a rebuild + restart picks them up. let report = updates::apply().await?; Ok(json!(report)) } @@ -339,14 +502,10 @@ async fn run( } => { let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); let contacts_only = contacts_only.unwrap_or(false); - // Resolve the requested author outside any lock: parsing a key is - // pure and must not queue behind vault mutations. let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()), None => None, }; - // Copy the inputs out of shared state under a short lock so the - // multi-second relay fetches below never block a Select or save. let (settings, owner_hex) = { let guard = app.lock().await; let owner_hex = if author_hex.is_some() { @@ -372,6 +531,8 @@ async fn run( }; Ok(json!(items)) } + + // Vault state requests (require lock) other => { let mut guard = app.lock().await; run_with_app(&mut guard, other).await @@ -391,12 +552,54 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let label = normalise_label(&label); + let key = app.vault_key().copied(); + let summary = profiles::import_profile( + &mut app.vault, + label, + &secret, + key.as_ref(), + &app.settings, + )?; + if app.signer_mode == SignerMode::Embedded { + if let Some(signer) = &app.embedded_signer { + signer.set_active_profile(Some(summary.npub.clone())).await; + } + } else if app.signer_mode == SignerMode::Nip46Client { + if let Some(signer) = &app.nip46_signer { + signer.set_active_profile(Some(summary.npub.clone())).await; + } + } app.save_vault()?; Ok(json!({ "profile": summary, "state": app.state_view() })) } Request::SelectProfile { npub } => { profiles::set_active(&mut app.vault, &npub)?; + if app.signer_mode == SignerMode::Embedded { + if let Some(signer) = &app.embedded_signer { + signer.set_active_profile(Some(npub)).await; + } + } else if app.signer_mode == SignerMode::Nip46Client { + if let Some(signer) = &app.nip46_signer { + signer.set_active_profile(Some(npub)).await; + } + } app.save_vault()?; Ok(json!(app.state_view())) } @@ -446,7 +649,17 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { @@ -485,11 +698,32 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { app.unlock(&password)?; + // Re-initialize signers with unlocked vault + if app.signer_mode == SignerMode::Embedded { + if let Some(signer) = &app.embedded_signer { + if let Some(npub) = &app.vault.active_profile { + signer.set_active_profile(Some(npub.clone())).await; + } + } + } else if app.signer_mode == SignerMode::Nip46Client { + if let Some(signer) = &app.nip46_signer { + if let Some(npub) = &app.vault.active_profile { + signer.set_active_profile(Some(npub.clone())).await; + } + } + } Ok(json!(app.state_view())) } Request::LockVault => { app.lock(); + // Clear signers' active profiles + if let Some(signer) = &app.embedded_signer { + signer.set_active_profile(None).await; + } + if let Some(signer) = &app.nip46_signer { + signer.set_active_profile(None).await; + } Ok(json!(app.state_view())) } @@ -500,7 +734,33 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + // DEPRECATED path: only works when vault is already unlocked for + // this session. ExportSecretKey requires fresh auth always. + if app.is_locked() { + return Err(AppError::config( + "This method is deprecated. Use export_secret_key with a password instead.", + )); + } let revealed = profiles::reveal_secret_key(&app.vault, &npub, app.vault_key())?; + // Audit the deprecated call + if let Some(ref mut log) = app.audit_log { + let _ = log.record( + &npub, + crate::audit::AuditAction::KeyExport, + "[deprecated direct call]", + true, + None, + ); + } + Ok(json!(revealed)) + } + + Request::ExportSecretKey { + npub, + password, + reason, + } => { + let revealed = app.export_secret_key(&npub, &password, &reason, false)?; Ok(json!(revealed)) } @@ -539,13 +799,11 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - let restored = app.undo_delete()?; + app.undo_delete()?; app.save_vault()?; Ok(json!(app.state_view())) } - // Signer control requests are handled by `run` before this function is - // reached; keeping a wildcard arm keeps the match exhaustive here. - _ => Err(AppError::internal("Unexpected signer request.")), + _ => Err(AppError::internal("Unexpected request.")), } } diff --git a/src/lib.rs b/src/lib.rs index 7ca39ef..45c78f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod app; +pub mod audit; +pub mod bunker; pub mod crypto; pub mod errors; pub mod feed; diff --git a/src/main.rs b/src/main.rs index 920afe9..c578b63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,13 +2,13 @@ use std::process::ExitCode; use std::sync::Arc; use keynectr::app::App; +use keynectr::bunker::Signer; use keynectr::errors::{AppError, ErrorKind}; use keynectr::ipc; use keynectr::profiles::{self, ProfileSummary}; use keynectr::publish; use keynectr::relays; use keynectr::settings::Theme; -use keynectr::signer::Signer; use keynectr::vault::{self, StoredProfile, Vault}; const USAGE: &str = "\ @@ -682,6 +682,7 @@ fn cli_undo_delete() -> Result { created_at: restored.created_at, picture: restored.picture, nip05: restored.nip05, + signer_mode: keynectr::vault::SignerMode::Embedded, }; app.vault.profiles.push(stored); if app.vault.active_profile.is_none() { diff --git a/src/profiles.rs b/src/profiles.rs index 77da462..ec6ad14 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -75,6 +75,7 @@ pub fn create_profile( created_at, picture: None, nip05: None, + signer_mode: crate::vault::SignerMode::Embedded, }; let is_active = vault.active_profile.is_none(); @@ -157,6 +158,7 @@ pub fn import_profile( created_at, picture: metadata.as_ref().and_then(|m| m.picture.clone()), nip05: metadata.as_ref().and_then(|m| m.nip05.clone()), + signer_mode: crate::vault::SignerMode::Embedded, }); let relay_urls = relays::enabled_urls(settings); @@ -247,7 +249,6 @@ pub fn set_profile_picture( stored.picture.clone(), stored.nip05.clone(), ); - drop(stored); let summary = ProfileSummary { label, npub, @@ -450,6 +451,18 @@ fn validate_picture_url(url: &str) -> Result<(), AppError> { Ok(()) } +/// Look up a stored profile by npub (public access for signer-mode checks). +pub fn find_stored_profile<'a>( + vault: &'a Vault, + npub: &str, +) -> Result<&'a StoredProfile, AppError> { + vault + .profiles + .iter() + .find(|p| p.public_key == npub) + .ok_or_else(|| AppError::profile_not_found(npub)) +} + fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> { vault .profiles @@ -768,6 +781,7 @@ mod tests { created_at: 1, picture: None, nip05: None, + signer_mode: crate::vault::SignerMode::Embedded, }); vault.profiles.push(StoredProfile { label: "Bob".to_string(), @@ -776,6 +790,7 @@ mod tests { created_at: 2, picture: None, nip05: None, + signer_mode: crate::vault::SignerMode::Embedded, }); vault } diff --git a/src/publish.rs b/src/publish.rs index 76196b3..de3fbad 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -2,13 +2,14 @@ use std::collections::HashSet; use std::time::Duration; use nostr_sdk::prelude::*; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::crypto::VaultKey; use crate::errors::{AppError, ErrorKind}; use crate::profiles; use crate::relays; use crate::settings::Settings; +use crate::signer::Signing; use crate::vault::Vault; /// How long to wait for a single relay to accept an event. Relays are sent @@ -16,7 +17,7 @@ use crate::vault::Vault; const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(6); /// A relay that rejected a published note. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayFailure { pub url: String, /// Concise, user-facing reason. @@ -56,8 +57,8 @@ pub async fn publish_active( validate_content(content)?; let secret_hex = profiles::resolve_active_secret_key(vault, key)?; let secret_key = profiles::parse_secret_key(&secret_hex)?; - let keys = Keys::new(secret_key); - publish_with_keys(settings, content, &keys).await + let signing = Signing::Local(Keys::new(secret_key)); + publish_with_keys(settings, content, &signing).await } /// Publish a text note as a specific profile (used by the CLI). @@ -73,8 +74,8 @@ pub async fn publish_as( validate_content(content)?; let secret_hex = profiles::resolve_secret_key(vault, npub, key)?; let secret_key = profiles::parse_secret_key(&secret_hex)?; - let keys = Keys::new(secret_key); - publish_with_keys(settings, content, &keys).await + let signing = Signing::Local(Keys::new(secret_key)); + publish_with_keys(settings, content, &signing).await } /// Reject empty notes before any key or network work happens. @@ -151,7 +152,7 @@ fn image_tags(content: &str) -> Vec { async fn publish_with_keys( settings: &Settings, content: &str, - keys: &Keys, + signing: &Signing, ) -> Result { let content = content.trim(); if content.is_empty() { @@ -163,21 +164,43 @@ async fn publish_with_keys( return Err(AppError::no_enabled_relays()); } - // Sign locally before touching the network so a signing failure is - // reported as such rather than as a network error. + // Extract &Keys from Signing::Local for EventBuilder operations. + // Currently Signing::Local is used from publish_active/publish_as, + // but the pattern supports External signers in the future. + let keys = match signing { + Signing::Local(k) => k, + Signing::External { + signer: _, + profile_pubkey: _, + } => { + return Err(AppError::sign_failed( + "External signer not yet supported in publish_with_keys", + )); + } + }; + + // Build the unsigned event. let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content)); - let event = builder + let unsigned = builder .finalize_async(keys) .await .map_err(|e| AppError::sign_failed(format!("{e}")))?; - let event_id = event + // Sign the event through the Signing trait (routes to Keys::sign_event or + // Signer::sign_event depending on the variant). This is the core refactor: + // the IPC layer no longer calls Keys::sign_event directly. + let signed = signing + .sign(unsigned.into()) + .await + .map_err(|e| AppError::sign_failed(format!("{e}")))?; + + let event_id = signed .id .to_bech32() .map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?; let client = relays::open_pool(keys.clone(), &relay_urls, None).await?; - let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await; + let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &signed, "note").await; if succeeded.is_empty() { return Err(AppError::publish_failed(failed)); diff --git a/src/signer/backend.rs b/src/signer/backend.rs new file mode 100644 index 0000000..2700c8c --- /dev/null +++ b/src/signer/backend.rs @@ -0,0 +1,509 @@ +//! The per-profile [`SigningBackend`] selection and the [`SigningError`] type. +//! +//! `SigningBackend` is the *choice* of where a profile's user content gets +//! signed: +//! +//! - [`SigningBackend::Internal`] — the key is held locally in the encrypted +//! vault (the embedded signer). +//! - [`SigningBackend::Remote`] — the key is held by a remote NIP-46 signer. +//! This variant holds **only** a [`VaultRef`]: an opaque pointer into the +//! vault's encrypted connection-secret store. The NIP-46 connection secret +//! itself is *never* stored inline here, so a serialized `SigningBackend` +//! (or a leaked one) can never hand a raw connection secret to a renderer, +//! the audit log, or a crash dump. +//! +//! `SigningBackend` is plain data: `Clone`, `PartialEq`, and +//! `Serialize`/`Deserialize` (so it can be persisted per-profile). The heavy +//! lifting — actually signing — is done by the [`crate::signer::Signer`] trait +//! implementations (`EmbeddedSigner`, `Nip46ClientSigner`), selected by the +//! backend. +//! +//! [`SigningError`] is the closed set of ways a signing operation can go +//! wrong. It is the single error type the `Signer` trait, `SigningBackend` +//! helpers, and the IPC reroute layer speak, and it converts to the app-wide +//! [`crate::errors::AppError`] at the IPC boundary via [`From`]. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::errors::{AppError, ErrorKind}; + +/// Opaque reference into the vault's encrypted connection-secret store. +/// +/// The reference *names* a stored secret (which profile owns it, which remote +/// signer it points at) but never carries the secret bytes. Resolution — +/// turning a `VaultRef` into the decrypted secret the NIP-46 handshake needs — +/// happens at the vault boundary, only while the vault is unlocked, and the +/// result is `Zeroizing`. +/// +/// Keeping this a distinct newtype means every `VaultRef` in the codebase is +/// unambiguously a pointer, not a secret. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VaultRef { + /// The profile `npub` that owns the connection, if any. + /// + /// `None` for a NIP-46 connection that has no local profile (created while + /// no profile was active). This mirrors `Nip46Connection::profile_npub`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_npub: Option, + /// The remote signer's public key (hex) this reference points at. + pub signer_pubkey: String, +} + +impl VaultRef { + /// Build a reference from an optional profile `npub` and a remote signer + /// pubkey. + pub fn new(profile_npub: Option, signer_pubkey: impl Into) -> Self { + Self { + profile_npub, + signer_pubkey: signer_pubkey.into(), + } + } + + /// Build a reference from a stored [`Nip46Connection`]. + /// + /// This is the canonical way a `SigningBackend::Remote` (and the vault + /// secret store) is keyed by a connection. + pub fn from_connection(conn: &crate::signer::types::Nip46Connection) -> Self { + Self { + profile_npub: conn.profile_npub.clone(), + signer_pubkey: conn.signer_pubkey.clone(), + } + } +} + +impl fmt::Display for VaultRef { + /// A stable, secret-free string form, safe to log or show in the UI. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.profile_npub { + Some(npub) => write!(f, "vault://{npub}#{}", self.signer_pubkey), + None => write!(f, "vault:#{}", self.signer_pubkey), + } + } +} + +/// Where a profile's user content is signed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SigningBackend { + /// The key is held locally in the encrypted vault. + Internal, + /// The key is held by a remote NIP-46 signer. + /// + /// Carries **only** an opaque [`VaultRef`]. The NIP-46 connection secret is + /// stored separately, encrypted, and is resolved on demand — it is never + /// inlined in this variant. + Remote { + /// Opaque pointer into the vault's encrypted connection-secret store. + vault_ref: VaultRef, + }, +} + +impl SigningBackend { + /// `true` when the key is held locally in the vault. + pub fn is_internal(&self) -> bool { + matches!(self, Self::Internal) + } + + /// `true` when the key is held by a remote NIP-46 signer. + pub fn is_remote(&self) -> bool { + matches!(self, Self::Remote { .. }) + } + + /// The opaque vault pointer for a remote backend, if this is one. + /// + /// `None` for [`SigningBackend::Internal`]. This is the *only* place a + /// remote backend exposes its secret location — the secret itself is never + /// a field of this type. + pub fn vault_ref(&self) -> Option<&VaultRef> { + match self { + Self::Remote { vault_ref } => Some(vault_ref), + Self::Internal => None, + } + } +} + +impl fmt::Display for SigningBackend { + /// A stable, secret-free string form. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Internal => write!(f, "internal (local vault)"), + Self::Remote { vault_ref } => write!(f, "remote ({vault_ref})"), + } + } +} + +/// Canonical error for the signing subsystem. +/// +/// Every signing operation resolves a profile's [`SigningBackend`], enforces +/// identity and permissions, and produces a signed event. `SigningError` is +/// the closed set of ways that can go wrong, each with a stable +/// [`ErrorKind`] for programmatic handling (including IPC) and a user-facing +/// message. +/// +/// It converts to the app-wide [`AppError`] at the IPC boundary via [`From`], +/// so a handler can `?` a signing result and the IPC layer turns it into the +/// JSON error envelope without any string matching. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SigningError { + /// No profile is selected. + NoActiveProfile, + /// The active profile is not stored on this machine. + ProfileNotFound { + /// The `npub` that was looked up. + npub: String, + }, + /// The local (internal) key is not available: the vault is locked, or the + /// stored key is unreadable/invalid. + InternalKeyUnavailable { + /// Technical detail (e.g. "vault locked", "invalid hex"). + detail: String, + }, + /// An external (remote) signer is selected but no matching connection is + /// stored in the vault. + RemoteConnectionMissing { + /// The vault pointer that could not be resolved to a connection. + ref_: VaultRef, + }, + /// The stored connection secret could not be resolved (vault locked or the + /// secret is absent). + SecretResolution { + /// Technical detail. + detail: String, + }, + /// The remote signer's identity does not match the active profile. + IdentityMismatch, + /// The remote signer is not connected (no live relay session). + NotConnected, + /// A NIP-46 permission check denied the requested operation. + PermissionDenied { + /// The NIP-46 method that was denied. + method: String, + }, + /// A NIP-46 connection has expired. + ConnectionExpired, + /// A NIP-46 connection has been revoked. + ConnectionRevoked, + /// The user rejected the signing request. + Rejected, + /// The signing request timed out. + Timeout, + /// The signed event failed to verify or was malformed. + InvalidSignature, + /// A network operation failed. + Network { + /// Technical detail. + detail: String, + }, + /// A filesystem or storage problem. + Storage { + /// Technical detail. + detail: String, + }, + /// An unexpected internal failure. + Internal { + /// Technical detail. + detail: String, + }, +} + +impl SigningError { + /// The stable machine-readable category of this error. + /// + /// Maps onto the app-wide [`ErrorKind`] so the IPC layer and the GUI can + /// make machine-readable decisions without parsing the message. + pub fn kind(&self) -> ErrorKind { + match self { + Self::NoActiveProfile => ErrorKind::NoActiveProfile, + Self::ProfileNotFound { .. } => ErrorKind::ProfileNotFound, + Self::InternalKeyUnavailable { .. } => ErrorKind::VaultLocked, + Self::RemoteConnectionMissing { .. } => ErrorKind::ExternalSignerNotConnected, + Self::SecretResolution { .. } => ErrorKind::VaultLocked, + Self::IdentityMismatch => ErrorKind::ExternalSignerIdentityMismatch, + Self::NotConnected => ErrorKind::ExternalSignerNotConnected, + Self::PermissionDenied { .. } => ErrorKind::Nip46PermissionDenied, + Self::ConnectionExpired => ErrorKind::Nip46ConnectionExpired, + Self::ConnectionRevoked => ErrorKind::Nip46ConnectionRevoked, + Self::Rejected => ErrorKind::SignerRejected, + Self::Timeout => ErrorKind::SignerTimeout, + Self::InvalidSignature => ErrorKind::SignFailed, + Self::Network { .. } => ErrorKind::Network, + Self::Storage { .. } => ErrorKind::VaultMalformed, + Self::Internal { .. } => ErrorKind::Internal, + } + } + + /// The user-facing message, safe to show directly in the GUI. + /// + /// These mirror the existing [`AppError`] copy so the UX is unchanged + /// while the codebase migrates to `SigningError`. + pub fn message(&self) -> &'static str { + match self { + Self::NoActiveProfile => { + "No profile is selected. Choose a profile before publishing." + } + Self::ProfileNotFound { .. } => "That profile is not stored on this computer.", + Self::InternalKeyUnavailable { .. } => { + "Your vault is locked. Enter your password to unlock it." + } + Self::RemoteConnectionMissing { .. } => { + "An external signer is selected but not connected. Connect it, or switch to the local signer." + } + Self::SecretResolution { .. } => { + "The connection secret could not be read. Unlock the vault and try again." + } + Self::IdentityMismatch => { + "The external signer's key does not match this profile. Reconnect with the correct signer." + } + Self::NotConnected => { + "An external signer is selected but not connected. Connect it, or switch to the local signer." + } + Self::PermissionDenied { .. } => { + "This operation is not permitted by the connected signer." + } + Self::ConnectionExpired => "The NIP-46 connection has expired. Reconnect to the signer.", + Self::ConnectionRevoked => { + "The NIP-46 connection has been revoked. Reconnect to the signer." + } + Self::Rejected => "The signing request was rejected by the signer.", + Self::Timeout => { + "The signing request timed out. Check that your signer is running and try again." + } + Self::InvalidSignature => "The note could not be signed.", + Self::Network { .. } => { + "Could not connect to the relay. Check your internet connection and try again." + } + Self::Storage { .. } => { + "Your profile data could not be read. It may have been modified or damaged." + } + Self::Internal { .. } => "Something unexpected went wrong.", + } + } + + /// An optional technical detail, shown only in an expandable area. + /// + /// Never contains secret keys or connection secrets. + pub fn detail(&self) -> Option { + match self { + Self::ProfileNotFound { npub } => Some(format!("No stored profile found for {npub}")), + Self::InternalKeyUnavailable { detail } => Some(detail.clone()), + Self::RemoteConnectionMissing { ref_ } => { + Some(format!("No stored NIP-46 connection for {ref_}")) + } + Self::SecretResolution { detail } => Some(detail.clone()), + Self::PermissionDenied { method } => { + Some(format!("Permission denied for NIP-46 method: {method}")) + } + Self::Network { detail } | Self::Storage { detail } | Self::Internal { detail } => { + Some(detail.clone()) + } + _ => None, + } + } +} + +impl fmt::Display for SigningError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message()) + } +} + +impl std::error::Error for SigningError {} + +impl From for AppError { + /// Convert a signing error into the app-wide error at the IPC boundary. + /// + /// Uses the simple constructor when there is no technical detail and the + /// details constructor when there is, matching how `AppError` is built + /// elsewhere. + fn from(err: SigningError) -> Self { + match err.detail() { + Some(detail) => AppError::with_details(err.kind(), err.message(), detail), + None => AppError::simple(err.kind(), err.message()), + } + } +} + +impl SigningError { + /// Best-effort lift of an app error into the signing error space. + /// + /// Used where an upstream step (profile lookup, vault I/O) already returns + /// an [`AppError`] and the caller wants to keep speaking `SigningError`. + /// The technical detail is carried through; the category is preserved when + /// it maps cleanly and falls back to [`ErrorKind::Internal`] otherwise. + pub fn from_app(app: &AppError) -> Self { + let detail = app + .details() + .map(str::to_string) + .unwrap_or_else(|| app.message().to_string()); + match app.kind() { + ErrorKind::NoActiveProfile => Self::NoActiveProfile, + ErrorKind::ProfileNotFound => { + // The npub is only present in the detail text; keep it there. + Self::ProfileNotFound { npub: detail } + } + ErrorKind::VaultLocked => Self::InternalKeyUnavailable { + detail: app.message().to_string(), + }, + ErrorKind::ExternalSignerNotConnected => Self::NotConnected, + ErrorKind::ExternalSignerIdentityMismatch => Self::IdentityMismatch, + ErrorKind::Nip46PermissionDenied => Self::PermissionDenied { method: detail }, + ErrorKind::Nip46ConnectionExpired => Self::ConnectionExpired, + ErrorKind::Nip46ConnectionRevoked => Self::ConnectionRevoked, + ErrorKind::SignerRejected => Self::Rejected, + ErrorKind::SignerTimeout => Self::Timeout, + ErrorKind::SignFailed => Self::InvalidSignature, + ErrorKind::Network => Self::Network { detail }, + ErrorKind::VaultMalformed | ErrorKind::Storage => Self::Storage { detail }, + _ => Self::Internal { detail }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::ErrorKind; + + #[test] + fn internal_backend_has_no_vault_ref() { + let b = SigningBackend::Internal; + assert!(b.is_internal()); + assert!(!b.is_remote()); + assert!(b.vault_ref().is_none()); + assert_eq!(b.to_string(), "internal (local vault)"); + } + + #[test] + fn remote_backend_exposes_only_the_vault_ref() { + let ref_ = VaultRef::new(Some("npub1profile".to_string()), "deadbeef"); + let b = SigningBackend::Remote { + vault_ref: ref_.clone(), + }; + assert!(b.is_remote()); + assert!(!b.is_internal()); + assert_eq!(b.vault_ref(), Some(&ref_)); + assert_eq!(b.to_string(), "remote (vault://npub1profile#deadbeef)"); + } + + /// The constraint that drives the whole design: serializing a remote + /// backend must never emit a connection secret. Only the ref fields are + /// allowed to appear. + #[test] + fn serialized_remote_backend_never_contains_a_secret() { + let b = SigningBackend::Remote { + vault_ref: VaultRef::new(Some("npub1profile".to_string()), "deadbeef"), + }; + let json = serde_json::to_string(&b).unwrap(); + assert!(json.contains("npub1profile")); + assert!(json.contains("deadbeef")); + // There is no `secret` field anywhere in the type, so none can appear. + assert!(!json.to_lowercase().contains("secret")); + assert!(!json.contains("nsec")); + } + + #[test] + fn backend_round_trips_through_serde() { + for b in [ + SigningBackend::Internal, + SigningBackend::Remote { + vault_ref: VaultRef::new(Some("npub1profile".to_string()), "deadbeef"), + }, + ] { + let json = serde_json::to_string(&b).unwrap(); + let back: SigningBackend = serde_json::from_str(&json).unwrap(); + assert_eq!(b, back); + } + } + + #[test] + fn vault_ref_display_is_secret_free() { + let ref_ = VaultRef::new(Some("npub1profile".to_string()), "deadbeef"); + assert_eq!(ref_.to_string(), "vault://npub1profile#deadbeef"); + assert!(!ref_.to_string().contains("secret")); + } + + #[test] + fn error_kind_mapping() { + assert_eq!( + SigningError::NoActiveProfile.kind(), + ErrorKind::NoActiveProfile + ); + assert_eq!( + SigningError::IdentityMismatch.kind(), + ErrorKind::ExternalSignerIdentityMismatch + ); + assert_eq!( + SigningError::PermissionDenied { + method: "sign_event".into() + } + .kind(), + ErrorKind::Nip46PermissionDenied + ); + assert_eq!(SigningError::Timeout.kind(), ErrorKind::SignerTimeout); + assert_eq!(SigningError::InvalidSignature.kind(), ErrorKind::SignFailed); + assert_eq!( + SigningError::Internal { detail: "x".into() }.kind(), + ErrorKind::Internal + ); + } + + #[test] + fn error_message_is_stable_and_secret_free() { + let err = SigningError::PermissionDenied { + method: "sign_event".into(), + }; + assert!(err.message().contains("not permitted")); + // The dynamic method name lives in the detail, not the user message. + assert_eq!( + err.detail().as_deref(), + Some("Permission denied for NIP-46 method: sign_event") + ); + } + + #[test] + fn signing_error_converts_to_app_error() { + let app: AppError = SigningError::NotConnected.into(); + assert_eq!(app.kind(), ErrorKind::ExternalSignerNotConnected); + assert!(app.message().contains("not connected")); + assert!(app.details().is_none()); + + let with_detail: AppError = SigningError::Internal { + detail: "boom".into(), + } + .into(); + assert_eq!(with_detail.kind(), ErrorKind::Internal); + assert_eq!(with_detail.details(), Some("boom")); + } + + #[test] + fn from_app_preserves_known_kinds() { + let app = AppError::simple(ErrorKind::NoActiveProfile, "no profile"); + assert!(matches!( + SigningError::from_app(&app), + SigningError::NoActiveProfile + )); + + let app = AppError::with_details(ErrorKind::Nip46PermissionDenied, "denied", "sign_event"); + assert!(matches!( + SigningError::from_app(&app), + SigningError::PermissionDenied { method } if method == "sign_event" + )); + + let app = AppError::simple(ErrorKind::Internal, "mystery"); + assert!(matches!( + SigningError::from_app(&app), + SigningError::Internal { .. } + )); + } + + #[test] + fn signing_error_implements_error_trait() { + use std::error::Error; + let err = SigningError::Timeout; + // Display + Error are usable (compile-time + runtime checks). + assert_eq!(err.to_string(), err.message()); + assert!(err.source().is_none()); + } +} diff --git a/src/signer/embedded.rs b/src/signer/embedded.rs new file mode 100644 index 0000000..d947cc6 --- /dev/null +++ b/src/signer/embedded.rs @@ -0,0 +1,270 @@ +//! Embedded signer - keys stored locally in the encrypted vault. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use nostr_sdk::prelude::*; +use tokio::sync::{oneshot, Mutex}; + +use crate::app::App; +use crate::profiles; +use crate::signer::backend::SigningError; +use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType}; +use crate::signer::Signer; + +/// Maximum time to wait for user approval. +const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); +/// Maximum pending approvals queue size. +const MAX_PENDING_APPROVALS: usize = 20; + +/// A request waiting for user approval. +struct PendingApproval { + method: String, + details: ApprovalDetails, + sender: oneshot::Sender, +} + +/// Embedded signer using keys from the local vault. +pub struct EmbeddedSigner { + app: Arc>, + active_npub: Arc>>, + pending: Arc>>, +} + +impl EmbeddedSigner { + /// Create a new embedded signer bound to the app state. + pub fn new(app: Arc>) -> Self { + Self { + app, + active_npub: Arc::new(Mutex::new(None)), + pending: Arc::new(Mutex::new(Vec::new())), + } + } + + /// Set the active profile by npub. + pub async fn set_active_profile(&self, npub: Option) { + let mut guard = self.active_npub.lock().await; + *guard = npub; + } + + /// Get the current active npub. + pub async fn active_npub(&self) -> Option { + let guard = self.active_npub.lock().await; + guard.clone() + } + + /// Resolve the active profile's Keys, checking vault lock state. + async fn resolve_keys(&self) -> Result { + let app = self.app.lock().await; + let npub_guard = self.active_npub.lock().await; + let npub = npub_guard.as_ref().ok_or(SigningError::NoActiveProfile)?; + + if app.is_locked() { + return Err(SigningError::InternalKeyUnavailable { + detail: "vault locked".to_string(), + }); + } + + let vault_key = app.vault_key().copied(); + let secret_hex = profiles::resolve_secret_key(&app.vault, npub, vault_key.as_ref()) + .map_err(|e| SigningError::from_app(&e))?; + let secret_key = + profiles::parse_secret_key(&secret_hex).map_err(|e| SigningError::from_app(&e))?; + Ok(Keys::new(secret_key)) + } + + /// Queue an approval request and wait for user decision. + async fn await_approval(&self, details: ApprovalDetails) -> ApprovalResult { + let (sender, receiver) = oneshot::channel(); + + // Check queue capacity + { + let mut pending = self.pending.lock().await; + if pending.len() >= MAX_PENDING_APPROVALS { + return ApprovalResult::Timeout; + } + pending.push(PendingApproval { + method: details.method.clone(), + details: details.clone(), + sender, + }); + } + + // Wait for approval with timeout + let result = match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await { + Ok(Ok(approved)) => approved, + Ok(Err(_)) => ApprovalResult::Timeout, // Channel closed (signer dropped) + Err(_) => ApprovalResult::Timeout, + }; + + // Clean up + self.pending.lock().await.retain(|p| { + p.details.method != details.method + || p.details.content_preview != details.content_preview + }); + + result + } + + /// Get pending approvals for UI display. + pub async fn pending_approvals(&self) -> Vec { + let pending = self.pending.lock().await; + pending + .iter() + .map(|p| crate::signer::types::PendingApproval { + id: uuid::Uuid::new_v4().to_string(), // Generate display ID + method: p.method.clone(), + summary: p.details.summary.clone(), + details: p.details.clone(), + }) + .collect() + } + + /// Approve or reject a pending request by index. + pub async fn respond_to_approval( + &self, + index: usize, + approved: bool, + ) -> Result<(), SigningError> { + let mut pending = self.pending.lock().await; + if index >= pending.len() { + return Err(SigningError::Internal { + detail: "No pending request at that index".to_string(), + }); + } + let entry = pending.remove(index); + let _ = entry.sender.send(if approved { + ApprovalResult::Approved + } else { + ApprovalResult::Rejected + }); + Ok(()) + } + + fn describe_sign_event(event: &UnsignedEvent) -> ApprovalDetails { + let content_preview = event.content.chars().take(80).collect::(); + let is_sensitive = matches!( + event.kind.as_u16(), + 0 | 3 + | 5 + | 6 + | 10000 + | 10001 + | 10002 + | 30000 + | 30001 + | 30002 + | 30003 + | 30004 + | 30005 + | 30006 + | 30007 + | 30008 + | 30009 + | 30010 + | 30011 + | 30012 + | 30013 + | 30014 + | 30015 + ); + + ApprovalDetails { + method: "sign_event".to_string(), + summary: format!("Sign event kind {}", event.kind.as_u16()), + event_kind: Some(event.kind.as_u16()), + destination_relays: Vec::new(), // Filled by caller if known + content_preview, + is_sensitive, + } + } +} + +#[async_trait] +impl Signer for EmbeddedSigner { + async fn get_public_key(&self) -> Result { + let keys = self.resolve_keys().await?; + Ok(keys.public_key()) + } + + async fn sign_event(&self, event: UnsignedEvent) -> Result { + let keys = self.resolve_keys().await?; + + // Request approval for sensitive operations + let details = Self::describe_sign_event(&event); + let approval = self.request_approval(details).await; + + match approval { + ApprovalResult::Approved => { + keys.sign_event(event).map_err(|e| SigningError::Internal { + detail: format!("Failed to sign event: {e}"), + }) + } + ApprovalResult::Rejected => Err(SigningError::Rejected), + ApprovalResult::Timeout => Err(SigningError::Timeout), + } + } + + fn get_signer_type(&self) -> SignerType { + SignerType::Embedded + } + + async fn is_available(&self) -> bool { + let app = self.app.lock().await; + let npub_guard = self.active_npub.lock().await; + npub_guard.is_some() && !app.is_locked() + } + + async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult { + self.await_approval(details).await + } + + async fn disconnect(&self) -> Result<(), SigningError> { + let mut npub_guard = self.active_npub.lock().await; + *npub_guard = None; + self.pending.lock().await.clear(); + Ok(()) + } + + async fn revoke(&self) -> Result<(), SigningError> { + let npub = { + let mut npub_guard = self.active_npub.lock().await; + npub_guard.take() + }; + if let Some(npub) = npub { + let mut app = self.app.lock().await; + let _ = profiles::delete_profile(&mut app.vault, &npub); + app.save_vault().map_err(|e| SigningError::Internal { + detail: format!("Could not persist vault: {e}"), + })?; + } + self.pending.lock().await.clear(); + Ok(()) + } + + async fn status_string(&self) -> String { + let available = self.is_available().await; + let npub_guard = self.active_npub.lock().await; + if available { + "Embedded signer: Ready".to_string() + } else if npub_guard.is_none() { + "Embedded signer: No profile selected".to_string() + } else { + "Embedded signer: Vault locked".to_string() + } + } + + async fn detailed_status(&self) -> serde_json::Value { + let available = self.is_available().await; + let pending = self.pending_approvals().await; + let npub_guard = self.active_npub.lock().await; + serde_json::json!({ + "type": "embedded", + "available": available, + "active_npub": *npub_guard, + "pending_count": pending.len(), + "pending": pending, + }) + } +} diff --git a/src/signer/mod.rs b/src/signer/mod.rs new file mode 100644 index 0000000..47fc612 --- /dev/null +++ b/src/signer/mod.rs @@ -0,0 +1,200 @@ +//! The Signer trait - common interface for all signing modes. + +pub mod backend; +pub mod embedded; +pub mod nip46_client; +pub mod permissions; +pub mod types; + +pub use backend::{SigningBackend, SigningError, VaultRef}; + +use async_trait::async_trait; +use nostr_sdk::prelude::*; +use std::sync::Arc; + +use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType}; + +/// Common interface for all signer implementations. +/// +/// Every method that can fail a *signing* operation returns +/// [`Result<_, SigningError>`], so the whole signing subsystem speaks one +/// closed error type. The IPC layer converts [`SigningError`] to the app-wide +/// [`crate::errors::AppError`] at the boundary (via [`From`]) — no string +/// matching, no mode branching. +#[async_trait] +pub trait Signer: Send + Sync { + /// Get the public key of the active signing identity. + async fn get_public_key(&self) -> Result; + + /// Resolve the public key this signer will sign user content with, + /// enforcing that it matches the active profile's canonical identity. + /// + /// The default implementation compares `get_public_key()` against + /// `profile_pubkey` using canonical hex, returning + /// [`SigningError::IdentityMismatch`] on any difference. External signers + /// may override this to consult the remote signer's identity. Callers must + /// use the returned key as the event's `pubkey` and must never sign user + /// content when this errors. + async fn pubkey_for(&self, profile_pubkey: &PublicKey) -> Result { + let signer_pubkey = self.get_public_key().await?; + if signer_pubkey.to_hex() != profile_pubkey.to_hex() { + return Err(SigningError::IdentityMismatch); + } + Ok(signer_pubkey) + } + + /// Sign an event with the active key. + async fn sign_event(&self, event: UnsignedEvent) -> Result; + + /// Get the type of this signer. + fn get_signer_type(&self) -> SignerType; + + /// Check if the signer is currently available (unlocked, connected, etc.). + async fn is_available(&self) -> bool; + + /// Request user approval for a sensitive operation. + /// Returns the user's decision. + async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult; + + /// Disconnect/stop the signer (for NIP-46, closes connection). + async fn disconnect(&self) -> Result<(), SigningError>; + + /// Revoke the signer authorization (for NIP-46, revokes the connection). + async fn revoke(&self) -> Result<(), SigningError>; + + /// Get a human-readable status string for UI display. + async fn status_string(&self) -> String; + + /// Get detailed status for UI (connection state, pending requests, etc.). + async fn detailed_status(&self) -> serde_json::Value; + + // ── Permission checks ─────────────────────────────────────────────── + + /// The permissions granted to this signer, if any. + /// + /// Local (embedded) signers always return `None` — they have full + /// access to the local key and do not need permission checks. NIP-46 + /// client signers return the permissions parsed from the connection + /// URI or stored configuration. + /// + /// Returns an owned value because the NIP-46 client must lock an async + /// mutex internally. + fn permissions(&self) -> Option { + None + } + + /// Whether `sign_event` is permitted for the given event kind. + /// + /// The default implementation returns `true` when there are no + /// permissions (local signers) and `false` when permissions exist but + /// do not allow the operation. + fn can_sign_event(&self, kind: u16) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_sign_event_kind_allowed(kind), + None => true, + } + } + + /// Whether `nip44_encrypt` is permitted. + fn can_encrypt(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_encrypt_allowed(), + None => true, + } + } + + /// Whether `nip44_decrypt` is permitted. + fn can_decrypt(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_decrypt_allowed(), + None => true, + } + } + + /// Whether `get_public_key` is permitted. + fn can_get_public_key(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_get_public_key_allowed(), + None => true, + } + } + + /// Whether `get_relays` is permitted. + fn can_get_relays(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_get_relays_allowed(), + None => true, + } + } + + /// Whether the connection is currently valid (not expired, not revoked). + /// + /// Local signers always return `true`. + fn is_connection_valid(&self) -> bool { + true + } +} + +/// A source that can produce the active profile's public key and sign an +/// unsigned event. +/// +/// Every user-content signing path (publishing, upload auth, metadata) builds +/// an `EventBuilder` exactly as before, then routes it through a `Signing` +/// instead of a raw `Keys`. This is what makes "external signer not connected" +/// a hard error rather than a silent fall back to the local vault key: the +/// caller never holds the local secret when external mode is selected. +/// +/// - [`Signing::Local`] signs with a key resolved from the vault (embedded +/// mode and the CLI, which are always local). +/// - [`Signing::External`] signs through a live [`Signer`], validating that the +/// signer's identity matches the active profile before any event is signed. +pub enum Signing { + Local(Keys), + External { + signer: Arc, + profile_pubkey: PublicKey, + }, +} + +impl Signing { + /// The public key user content will be signed with. + /// + /// For [`Signing::External`] this enforces identity validation and returns + /// the signer's key; it returns [`SigningError::IdentityMismatch`] when the + /// signer does not control the active profile. Callers MUST use the + /// returned key as the event's `pubkey`. + pub async fn pubkey(&self) -> Result { + match self { + Signing::Local(keys) => Ok(keys.public_key()), + Signing::External { + signer, + profile_pubkey, + } => signer.pubkey_for(profile_pubkey).await, + } + } + + /// Sign `unsigned` (which must have been built with the key from + /// [`Signing::pubkey`]). + /// + /// For [`Signing::External`] the returned event is re-checked against the + /// validated identity and verified as a well-formed signature before it is + /// returned, so a misbehaving signer cannot substitute a different key. + pub async fn sign(&self, unsigned: UnsignedEvent) -> Result { + match self { + Signing::Local(keys) => keys + .sign_event(unsigned) + .map_err(|_e| SigningError::InvalidSignature), + Signing::External { + signer, + profile_pubkey, + } => { + let event = signer.sign_event(unsigned).await?; + if event.pubkey != *profile_pubkey { + return Err(SigningError::IdentityMismatch); + } + event.verify().map_err(|_| SigningError::InvalidSignature)?; + Ok(event) + } + } + } +} diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs new file mode 100644 index 0000000..1319443 --- /dev/null +++ b/src/signer/nip46_client.rs @@ -0,0 +1,1063 @@ +//! NIP-46 client signer - connects to a remote signer (bunker) via nostrconnect://. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use getrandom::getrandom; +use nostr::nips::nip44::v2; +use nostr::nips::nip44::v2::ConversationKey; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::{oneshot, Mutex}; + +use crate::app::App; +use crate::errors::AppError; +use crate::profiles; +use crate::signer::backend::SigningError; +use crate::signer::permissions::Nip46Permissions; +use crate::signer::types::{ + ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType, +}; +use crate::signer::Signer; + +/// How long to wait for relays to accept a connection attempt. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// How long a request may wait for the user to approve it before it expires. +const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300); +/// Maximum number of requests kept waiting for approval at once. +const MAX_PENDING_APPROVALS: usize = 20; + +/// Internal state for a pending approval. +struct PendingApprovalInner { + method: String, + details: ApprovalDetails, + sender: oneshot::Sender, +} + +/// Parsed nostrconnect:// URI. +struct ConnectUri { + peer: PublicKey, + relays: Vec, + secret: Option, + permissions: Option, +} + +/// The NIP-46 client signer. +pub struct Nip46ClientSigner { + inner: Arc>, + app: Arc>, +} + +struct Nip46Inner { + connection: Option, + phase: Nip46Phase, + task: Option>, + conversation_key: Option, + client: Option, + pending: HashMap, + keys: Option, + active_npub: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Nip46Phase { + Stopped, + Connecting, + Connected, + Error(String), +} + +impl Nip46ClientSigner { + /// Create a new NIP-46 client signer. + pub fn new(app: Arc>) -> Self { + Self { + inner: Arc::new(Mutex::new(Nip46Inner { + connection: None, + phase: Nip46Phase::Stopped, + task: None, + conversation_key: None, + client: None, + pending: HashMap::new(), + keys: None, + active_npub: None, + })), + app, + } + } + + /// Keep active profile in sync (mirrors EmbeddedSigner). + pub async fn set_active_profile(&self, npub: Option) { + self.inner.lock().await.active_npub = npub; + } + + /// Emit an audit event for a permission-denied NIP-46 operation. + async fn audit_permission_denied(&self, method: &str) { + let npub = self.inner.lock().await.active_npub.clone(); + if let Some(npub) = npub { + let mut app = self.app.lock().await; + if let Some(ref mut log) = app.audit_log { + let _ = log.record( + &npub, + crate::audit::AuditAction::ConnectionPermissionDenied, + method, + false, + Some(format!("NIP-46 permission denied for method: {method}")), + ); + } + } + } + + /// Parse a nostrconnect:// URI. + fn parse_connect_uri(raw: &str) -> Result { + let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| { + AppError::config("Paste the nostrconnect:// link from your Nostr app.") + })?; + + let (authority, query) = match rest.split_once('?') { + Some((a, q)) => (a, Some(q)), + None => (rest, None), + }; + + let peer = PublicKey::from_hex(authority).map_err(|_| { + AppError::config("The nostrconnect:// link does not contain a valid public key.") + })?; + + let mut relays: Vec = Vec::new(); + let mut secret: Option = None; + let mut perms_raw: Option = None; + + if let Some(query) = query { + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + let decoded = percent_decode(value); + match key { + "relay" => { + if let Some(value) = decoded { + if let Ok(url) = RelayUrl::parse(&value) { + relays.push(url); + } + } + } + "secret" => secret = decoded, + "perms" => perms_raw = decoded, + _ => {} + } + } + } + + if relays.is_empty() { + return Err(AppError::config( + "The nostrconnect:// link does not name any relays.", + )); + } + + let permissions = match perms_raw { + Some(raw) => Some(Nip46Permissions::parse(&raw)?), + None => None, + }; + + Ok(ConnectUri { + peer, + relays, + secret, + permissions, + }) + } + + /// Connect to a NIP-46 signer using a nostrconnect:// URI. + pub async fn connect(&self, uri: &str, label: String) -> Result { + let parsed = Self::parse_connect_uri(uri)?; + + // For NIP-46 Client the identity lives on the external signer, so local + // profile is optional. Use ephemeral keys for the session, preferring + // the local vault profile if available and unlocked. + let (keys, active_npub) = { + let app = self.app.lock().await; + if let Some(npub) = app.vault.active_profile.clone() { + if !app.is_locked() { + let vault_key = app.vault_key().copied(); + if let Ok(secret_hex) = + profiles::resolve_secret_key(&app.vault, &npub, vault_key.as_ref()) + { + if let Ok(secret_key) = profiles::parse_secret_key(&secret_hex) { + (Keys::new(secret_key), Some(npub)) + } else { + (Keys::generate(), Some(npub)) + } + } else { + (Keys::generate(), Some(npub)) + } + } else { + (Keys::generate(), Some(npub)) + } + } else { + (Keys::generate(), None) + } + }; + + // Check for permission broadening against stored connections for + // the active profile. + if let Some(ref npub) = active_npub { + if let Some(new_perms) = &parsed.permissions { + let app = self.app.lock().await; + for stored_conn in &app.vault.nip46_connections { + // Only check connections belonging to the same profile + // AND the same remote signer. Legacy connections without + // profile_npub are skipped (they cannot pass auth). + if stored_conn.profile_npub.as_deref() == Some(npub.as_str()) + && stored_conn.signer_pubkey == parsed.peer.to_hex() + { + if let Some(existing_perms) = &stored_conn.permissions { + existing_perms.validate_no_broadening(new_perms)?; + } + } + } + } + } + + // Derive conversation key with the signer + let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer) + .map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?; + + // Build connection config. The nostrconnect `secret` is NOT stored here; + // it goes into the vault's encrypted connection-secret store below. + let connection = Nip46Connection { + profile_npub: active_npub.clone(), + signer_pubkey: parsed.peer.to_hex(), + relays: parsed.relays.iter().map(|r| r.to_string()).collect(), + label, + created_at: crate::vault::unix_timestamp()?, + permissions: parsed.permissions.clone(), + expires_at: None, + revoked_at: None, + }; + + // Persist the connection AND its secret in the vault. The secret is + // encrypted under the vault key (when a password is set) and keyed by + // the connection's opaque VaultRef — never stored inline on the + // connection, never logged. + { + let mut app = self.app.lock().await; + // Remove any existing connection for the same signer from the + // same profile (reconnect replaces the old connection). + app.vault.nip46_connections.retain(|c| { + !(c.signer_pubkey == connection.signer_pubkey + && c.profile_npub == connection.profile_npub) + }); + let vault_ref = crate::signer::VaultRef::from_connection(&connection); + // Copy the vault key out before taking a mutable borrow of the + // vault, so the key and the vault are never borrowed at once. + let vault_key = app.vault_key().copied(); + if let Some(secret) = &parsed.secret { + crate::vault::store_connection_secret( + &mut app.vault, + vault_key.as_ref(), + &vault_ref, + secret, + )?; + } else { + // The reconnect carries no secret — drop any stale stored one. + crate::vault::delete_connection_secret(&mut app.vault, &vault_ref); + } + app.vault.nip46_connections.push(connection.clone()); + app.save_vault()?; + } + + // Update state to connecting + { + let mut inner = self.inner.lock().await; + if inner.task.is_some() { + return Err(AppError::config( + "Already connected to a signer. Disconnect first.", + )); + } + inner.phase = Nip46Phase::Connecting; + inner.connection = Some(connection.clone()); + inner.conversation_key = Some(conversation); + inner.keys = Some(keys.clone()); + inner.pending.clear(); + } + + // Spawn the connection task + let signer = self.clone(); + let task = tokio::spawn(async move { + if let Err(e) = signer.clone().run_sign_task(parsed).await { + signer.fail(e); + } + }); + + self.inner.lock().await.task = Some(task); + + Ok(self.status().await) + } + + /// Disconnect from the signer. + pub async fn disconnect(&self) -> Result<(), AppError> { + let mut inner = self.inner.lock().await; + if let Some(task) = inner.task.take() { + task.abort(); + } + if let Some(client) = inner.client.take() { + let _ = client.disconnect().await; + } + // Mark the connection as revoked in the vault and drop its stored + // secret, scoped to profile. + if let Some(ref conn) = inner.connection { + let vault_ref = crate::signer::VaultRef::from_connection(conn); + let mut app = self.app.lock().await; + if let Some(stored) = app.vault.nip46_connections.iter_mut().find(|c| { + c.signer_pubkey == conn.signer_pubkey && c.profile_npub == conn.profile_npub + }) { + stored.revoked_at = crate::vault::unix_timestamp().ok(); + } + crate::vault::delete_connection_secret(&mut app.vault, &vault_ref); + let _ = app.save_vault(); + } + inner.phase = Nip46Phase::Stopped; + inner.connection = None; + inner.conversation_key = None; + inner.keys = None; + inner.pending.clear(); + Ok(()) + } + + /// Revoke the connection (same as disconnect for now, could send logout). + pub async fn revoke(&self) -> Result<(), AppError> { + self.disconnect().await + } + + /// Get current connection status. + pub async fn status(&self) -> Nip46Status { + let inner = self.inner.lock().await; + let connection = inner.connection.clone(); + let pending: Vec = inner + .pending + .iter() + .map(|(id, entry)| PendingApproval { + id: id.clone(), + method: entry.method.clone(), + summary: entry.details.summary.clone(), + details: entry.details.clone(), + }) + .collect(); + + let connected_relays = if let Some(client) = &inner.client { + let map = client.relays().all().await; + map.into_iter() + .filter(|(_, relay)| relay.status().is_connected()) + .map(|(url, _)| url.to_string()) + .collect() + } else { + Vec::new() + }; + + Nip46Status { + connected: matches!(inner.phase, Nip46Phase::Connected), + signer_pubkey: connection.as_ref().map(|c| c.signer_pubkey.clone()), + relays: connection + .as_ref() + .map(|c| c.relays.clone()) + .unwrap_or_default(), + connected_relays, + error: match &inner.phase { + Nip46Phase::Error(e) => Some(e.clone()), + _ => None, + }, + pending_approvals: pending, + } + } + + /// Get pending approvals for UI. + pub async fn pending_approvals(&self) -> Vec { + let inner = self.inner.lock().await; + inner + .pending + .iter() + .map(|(id, entry)| PendingApproval { + id: id.clone(), + method: entry.method.clone(), + summary: entry.details.summary.clone(), + details: entry.details.clone(), + }) + .collect() + } + + /// Approve or reject a pending request. + pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> { + let mut inner = self.inner.lock().await; + let Some(entry) = inner.pending.remove(id) else { + return Err(AppError::config("Request no longer pending")); + }; + let _ = entry.sender.send(if approved { + ApprovalResult::Approved + } else { + ApprovalResult::Rejected + }); + Ok(()) + } + + fn fail(&self, message: impl Into) { + if let Ok(mut inner) = self.inner.try_lock() { + inner.phase = Nip46Phase::Error(message.into()); + inner.task = None; + inner.client = None; + inner.conversation_key = None; + inner.keys = None; + inner.pending.clear(); + } + } + + async fn await_approval(&self, details: ApprovalDetails) -> ApprovalResult { + let id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + + { + let mut inner = self.inner.lock().await; + if inner.pending.len() >= MAX_PENDING_APPROVALS { + return ApprovalResult::Timeout; + } + inner.pending.insert( + id.clone(), + PendingApprovalInner { + method: details.method.clone(), + details: details.clone(), + sender, + }, + ); + } + + match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await { + Ok(Ok(approved)) => approved, + Ok(Err(_)) => { + self.inner.lock().await.pending.remove(&id); + ApprovalResult::Timeout + } + Err(_) => { + self.inner.lock().await.pending.remove(&id); + ApprovalResult::Timeout + } + } + } + + /// Main background task: connect to relays, subscribe, handle requests. + async fn run_sign_task(self, uri: ConnectUri) -> Result<(), String> { + let (conversation, keys) = { + let inner = self.inner.lock().await; + let conversation = inner + .conversation_key + .as_ref() + .cloned() + .ok_or("No conversation key")?; + let keys = inner.keys.as_ref().cloned().ok_or("No keys")?; + (conversation, keys) + }; + + // Connect to relays + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .build(); + + for url in &uri.relays { + client + .add_relay(url.to_string()) + .await + .map_err(|e| format!("Could not add relay {url}: {e}"))?; + } + client.connect().and_wait(CONNECT_TIMEOUT).await; + + // Wait for relays to connect + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + let connected = loop { + let map = client.relays().all().await; + let urls: Vec = map + .into_iter() + .filter(|(_, relay)| relay.status().is_connected()) + .map(|(url, _)| url.to_string()) + .collect(); + if !urls.is_empty() || tokio::time::Instant::now() >= deadline { + break urls; + } + tokio::time::sleep(Duration::from_millis(250)).await + }; + + if connected.is_empty() { + return Err("None of the relays answered".to_string()); + } + + // Update connected relays + self.inner.lock().await.client = Some(client.clone()); + + // Subscribe to kind 24133 from signer + let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); + let mut notifications = client.notifications(); + let subscription = client + .subscribe(filter) + .await + .map_err(|e| format!("Could not subscribe: {e}"))?; + + // Send connect request + self.send_connect(&client, &keys, &conversation, &uri) + .await?; + + // Mark as connected + self.inner.lock().await.phase = Nip46Phase::Connected; + + // Handle incoming requests + loop { + let incoming = match notifications.next().await { + Some(nostr_sdk::client::ClientNotification::Event { + subscription_id, + event, + .. + }) if subscription_id == *subscription.id() => event, + Some(nostr_sdk::client::ClientNotification::Shutdown) | None => { + return Err("Connection closed".to_string()); + } + Some(_) => continue, + }; + + let event = *incoming; + if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { + continue; + } + + let plaintext = match nip44_decrypt(&conversation, &event.content) { + Ok(p) => p, + Err(_) => continue, + }; + + let request: RawRequest = match serde_json::from_str(&plaintext) { + Ok(r) => r, + Err(_) => continue, + }; + + let response = if self.requires_approval(&request.method) { + self.gated_response(&keys, &request).await + } else { + self.handle_request(&keys, &uri, &request).await + }; + + if let Some(response) = response { + self.publish_payload(&client, &keys, &conversation, &uri.peer, &response) + .await?; + } + } + } + + fn requires_approval(&self, method: &str) -> bool { + matches!(method, "sign_event" | "nip44_encrypt" | "nip44_decrypt") + } + + async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option { + // Check connection validity + if !self.is_connection_valid() { + return Some(response_err( + &request.id, + "Connection is expired or revoked".to_string(), + )); + } + + // Check method permissions + let allowed = match request.method.as_str() { + "sign_event" => { + let kind = request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|v| v.get("kind").and_then(|k| k.as_u64())) + .unwrap_or(0) as u16; + self.can_sign_event(kind) + } + "nip44_encrypt" => self.can_encrypt(), + "nip44_decrypt" => self.can_decrypt(), + _ => false, + }; + + if !allowed { + self.audit_permission_denied(&request.method).await; + return Some(response_err( + &request.id, + format!("Permission denied: {} not authorized", request.method), + )); + } + + self.inner.lock().await.phase = Nip46Phase::Connected; + let details = self.describe_request(request); + match self.await_approval(details).await { + ApprovalResult::Approved => self.approved_response(keys, request), + ApprovalResult::Rejected => Some(response_ok_rejected(&request.id)), + ApprovalResult::Timeout => Some(response_ok_timeout(&request.id)), + } + } + + async fn handle_request( + &self, + keys: &Keys, + uri: &ConnectUri, + request: &RawRequest, + ) -> Option { + // Note: we can't await here, so phase update is best-effort + // The phase is updated in gated_response for key-using methods + + // Check connection validity before processing + if !self.is_connection_valid() { + return Some(response_err( + &request.id, + "Connection is expired or revoked".to_string(), + )); + } + + match request.method.as_str() { + "connect" => { + if let Some(expected) = &uri.secret { + if !request.params.iter().any(|p| p == expected) { + return Some(response_err( + &request.id, + "Connect acknowledgement missing expected secret".to_string(), + )); + } + } + Some(response_ok(&request.id, "ack".to_string())) + } + "get_public_key" => { + if !self.can_get_public_key() { + self.audit_permission_denied("get_public_key").await; + return Some(response_err( + &request.id, + "Permission denied: get_public_key not authorized".to_string(), + )); + } + Some(response_ok(&request.id, keys.public_key().to_hex())) + } + "get_relays" => { + if !self.can_get_relays() { + self.audit_permission_denied("get_relays").await; + return Some(response_err( + &request.id, + "Permission denied: get_relays not authorized".to_string(), + )); + } + Some(response_ok(&request.id, json!(uri.relays).to_string())) + } + "ping" => Some(response_ok(&request.id, "pong".to_string())), + "logout" => Some(response_ok(&request.id, "ack".to_string())), + other => Some(response_err(&request.id, format!("Unsupported: {other}"))), + } + } + + fn approved_response(&self, keys: &Keys, request: &RawRequest) -> Option { + match request.method.as_str() { + "sign_event" => self.sign_event(keys, request), + "nip44_encrypt" | "nip44_decrypt" => self.nip44(keys, request), + _ => None, + } + } + + fn sign_event(&self, keys: &Keys, request: &RawRequest) -> Option { + let json_str = request.params.first()?; + let mut value: serde_json::Value = serde_json::from_str(json_str).ok()?; + if value.get("pubkey").and_then(|v| v.as_str()).is_none() { + value["pubkey"] = serde_json::Value::String(keys.public_key().to_hex()); + } + let unsigned: UnsignedEvent = serde_json::from_value(value).ok()?; + let event = keys.sign_event(unsigned).ok()?; + Some(response_ok(&request.id, event.as_json())) + } + + fn nip44(&self, keys: &Keys, request: &RawRequest) -> Option { + if request.params.len() != 2 { + return None; + } + let peer = PublicKey::from_hex(&request.params[0]).ok()?; + let conversation = ConversationKey::derive(keys.secret_key(), &peer).ok()?; + + let result = match request.method.as_str() { + "nip44_encrypt" => nip44_encrypt(&conversation, &request.params[1]), + _ => nip44_decrypt(&conversation, &request.params[1]), + }; + + result.map(|v| response_ok(&request.id, v)).ok() + } + + fn describe_request(&self, request: &RawRequest) -> ApprovalDetails { + match request.method.as_str() { + "sign_event" => { + let preview = request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .map(|value| { + let kind = value.get("kind").and_then(|k| k.as_u64()).unwrap_or(0); + let content = value + .get("content") + .and_then(|c| c.as_str()) + .unwrap_or("") + .chars() + .take(80) + .collect::(); + format!("event kind {kind}: \"{content}\"") + }) + .unwrap_or_else(|| "an event".to_string()); + let is_sensitive = matches!( + request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|v| v.get("kind").and_then(|k| k.as_u64())), + Some(0 | 3 | 5 | 6 | 10000 | 10001 | 10002 | 30000..=30015) + ); + ApprovalDetails { + method: "sign_event".to_string(), + summary: format!("Sign {preview}"), + event_kind: request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|v| v.get("kind").and_then(|k| k.as_u64())) + .map(|k| k as u16), + destination_relays: Vec::new(), + content_preview: preview, + is_sensitive, + } + } + "nip44_encrypt" => { + let target = request + .params + .first() + .and_then(|hex| { + if hex.len() == 64 { + Some(format!("{}…{}", &hex[..8], &hex[56..])) + } else { + None + } + }) + .unwrap_or_else(|| "a third party".to_string()); + ApprovalDetails { + method: "nip44_encrypt".to_string(), + summary: format!("Encrypt a message for {target}"), + event_kind: None, + destination_relays: Vec::new(), + content_preview: String::new(), + is_sensitive: true, + } + } + "nip44_decrypt" => { + let target = request + .params + .first() + .and_then(|hex| { + if hex.len() == 64 { + Some(format!("{}…{}", &hex[..8], &hex[56..])) + } else { + None + } + }) + .unwrap_or_else(|| "a third party".to_string()); + ApprovalDetails { + method: "nip44_decrypt".to_string(), + summary: format!("Decrypt a message from {target}"), + event_kind: None, + destination_relays: Vec::new(), + content_preview: String::new(), + is_sensitive: true, + } + } + other => ApprovalDetails { + method: other.to_string(), + summary: other.to_string(), + event_kind: None, + destination_relays: Vec::new(), + content_preview: String::new(), + is_sensitive: false, + }, + } + } + + async fn send_connect( + &self, + client: &Client, + keys: &Keys, + conversation: &ConversationKey, + uri: &ConnectUri, + ) -> Result<(), String> { + // Resolve the nostrconnect secret ON-DEMAND from the vault — the single + // source of truth — rather than reading an in-memory copy. The + // connection carries no secret; it is keyed by its VaultRef and fetched + // fresh here. Fail-closed: if the vault cannot produce the secret + // (e.g. it is encrypted and currently locked) the connect is refused + // instead of being sent without it. + let secret = { + let connection = { + let inner = self.inner.lock().await; + inner.connection.clone() + } + .ok_or("No active NIP-46 connection to resolve the secret for")?; + let vault_ref = crate::signer::VaultRef::from_connection(&connection); + let app = self.app.lock().await; + // Copy the key out before the immutable borrow of the vault so the + // two are never borrowed at once. + let vault_key = app.vault_key().copied(); + match crate::vault::resolve_connection_secret( + &app.vault, + vault_key.as_ref(), + &vault_ref, + ) { + Ok(Some(plain)) => Some(plain.to_string()), + Ok(None) => None, + Err(e) => { + return Err(format!( + "Could not resolve the connection secret from the vault: {e}" + )) + } + } + }; + + let mut params = vec![keys.public_key().to_hex()]; + if let Some(secret) = &secret { + params.push(secret.clone()); + } + let payload = json!({ + "id": uuid::Uuid::new_v4().to_string(), + "method": "connect", + "params": params, + }) + .to_string(); + self.publish_payload(client, keys, conversation, &uri.peer, &payload) + .await + } + + async fn publish_payload( + &self, + client: &Client, + keys: &Keys, + conversation: &ConversationKey, + peer: &PublicKey, + payload: &str, + ) -> Result<(), String> { + let content = nip44_encrypt(conversation, payload).map_err(|e| e.message().to_string())?; + let tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?; + let event = EventBuilder::new(Kind::NostrConnect, content) + .tags([tag]) + .finalize_async(keys) + .await + .map_err(|e| format!("Could not sign: {e}"))?; + client + .send_event(&event) + .await + .map_err(|e| format!("{e}"))?; + Ok(()) + } +} + +impl Clone for Nip46ClientSigner { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + app: self.app.clone(), + } + } +} + +#[async_trait] +impl Signer for Nip46ClientSigner { + async fn get_public_key(&self) -> Result { + let inner = self.inner.lock().await; + let connection = inner + .connection + .as_ref() + .ok_or(SigningError::NotConnected)?; + PublicKey::from_hex(&connection.signer_pubkey).map_err(|e| SigningError::Internal { + detail: format!("Invalid signer public key: {e}"), + }) + } + + async fn sign_event(&self, _event: UnsignedEvent) -> Result { + // For NIP-46 client, signing happens via the NIP-46 channel with user + // approval. The actual flow uses request_approval + + // respond_to_approval; this method exists to satisfy the object-safe + // trait and fails closed if a caller tries to bypass it. + Err(SigningError::Internal { + detail: + "NIP-46 signing uses the async approval flow; direct sign_event is not supported" + .to_string(), + }) + } + + fn get_signer_type(&self) -> SignerType { + SignerType::Nip46 + } + + async fn is_available(&self) -> bool { + let inner = self.inner.lock().await; + matches!(inner.phase, Nip46Phase::Connected) && inner.client.is_some() + } + + async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult { + self.await_approval(details).await + } + + async fn disconnect(&self) -> Result<(), SigningError> { + Nip46ClientSigner::disconnect(self) + .await + .map_err(|e| SigningError::from_app(&e)) + } + + async fn revoke(&self) -> Result<(), SigningError> { + Nip46ClientSigner::revoke(self) + .await + .map_err(|e| SigningError::from_app(&e)) + } + + async fn status_string(&self) -> String { + let inner = self.inner.lock().await; + match inner.phase { + Nip46Phase::Stopped => "NIP-46: Not connected".to_string(), + Nip46Phase::Connecting => "NIP-46: Connecting…".to_string(), + Nip46Phase::Connected => "NIP-46: Connected".to_string(), + Nip46Phase::Error(ref e) => format!("NIP-46: Error - {e}"), + } + } + + async fn detailed_status(&self) -> serde_json::Value { + let status = self.status().await; + serde_json::to_value(status).unwrap_or(serde_json::json!({})) + } + + // ── Permission checks ─────────────────────────────────────────────── + + fn permissions(&self) -> Option { + // We need to block on the async lock here; this is safe because + // `permissions()` is only called from synchronous contexts that do + // not hold the inner lock. + let inner = self.inner.blocking_lock(); + inner + .connection + .as_ref() + .and_then(|c| c.permissions.clone()) + } + + fn can_sign_event(&self, kind: u16) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_sign_event_kind_allowed(kind), + None => false, + } + } + + fn can_encrypt(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_encrypt_allowed(), + None => false, + } + } + + fn can_decrypt(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_decrypt_allowed(), + None => false, + } + } + + fn can_get_public_key(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_get_public_key_allowed(), + None => false, + } + } + + fn can_get_relays(&self) -> bool { + match self.permissions() { + Some(ref perms) => perms.is_get_relays_allowed(), + None => false, + } + } + + fn is_connection_valid(&self) -> bool { + let inner = self.inner.blocking_lock(); + match &inner.connection { + None => false, + Some(conn) => { + let now = crate::vault::unix_timestamp().unwrap_or(0); + if let Some(expires_at) = conn.expires_at { + if now >= expires_at { + return false; + } + } + conn.revoked_at.is_none() + } + } + } +} + +/// Minimal decrypted NIP-46 request. +#[derive(Debug, Deserialize)] +struct RawRequest { + id: String, + method: String, + #[serde(default)] + params: Vec, +} + +fn response_ok(id: &str, result: String) -> String { + json!({ "id": id, "result": result, "error": null }).to_string() +} + +fn response_err(id: &str, error: String) -> String { + json!({ "id": id, "result": null, "error": error }).to_string() +} + +fn response_ok_rejected(id: &str) -> String { + response_err(id, "The request was rejected by the user.".to_string()) +} + +fn response_ok_timeout(id: &str) -> String { + response_err( + id, + "The user did not approve this request in time; try again.".to_string(), + ) +} + +fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result { + let mut nonce = [0u8; 32]; + getrandom(&mut nonce).map_err(|e| AppError::internal(format!("Entropy error: {e}")))?; + let payload = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce) + .map_err(|e| AppError::internal(format!("Encryption failed: {e}")))?; + Ok(B64.encode(payload)) +} + +fn nip44_decrypt(conversation: &ConversationKey, content: &str) -> Result { + let bytes = B64 + .decode(content) + .map_err(|e| AppError::internal(format!("Decode failed: {e}")))?; + let plaintext = v2::decrypt_to_bytes(conversation, &bytes) + .map_err(|e| AppError::internal(format!("Decryption failed: {e}")))?; + String::from_utf8(plaintext) + .map_err(|_| AppError::internal("Decrypted payload not valid UTF-8")) +} + +fn percent_decode(raw: &str) -> Option { + let mut out: Vec = Vec::with_capacity(raw.len()); + let bytes = raw.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else if bytes[i] == b'+' { + out.push(b' '); + i += 1; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} diff --git a/src/signer/permissions.rs b/src/signer/permissions.rs new file mode 100644 index 0000000..6cd1bd3 --- /dev/null +++ b/src/signer/permissions.rs @@ -0,0 +1,659 @@ +//! NIP-46 per-connection permission model. +//! +//! Permissions are parsed from the `perms` query parameter in a +//! `nostrconnect://` URI or from stored connection metadata. The format +//! follows the NIP-46 convention: +//! +//! `method` or `method:#kind1,#kind2` +//! +//! Multiple permissions are comma-separated. Unknown methods, malformed +//! strings, and empty permission sets are rejected. + +use serde::{Deserialize, Serialize}; + +use crate::errors::AppError; + +/// Known NIP-46 method names. +const KNOWN_METHODS: &[&str] = &[ + "sign_event", + "nip44_encrypt", + "nip44_decrypt", + "get_public_key", + "get_relays", +]; + +/// A single NIP-46 permission granting access to one method. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Nip46Permission { + /// The NIP-46 method this permission covers. + pub method: String, + /// Optional event-kind restrictions for `sign_event`. + /// + /// * Empty — all event kinds are permitted. + /// * Non-empty — only the listed kinds are permitted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_kinds: Vec, +} + +/// Parsed, validated permissions for a NIP-46 connection. +/// +/// An empty `granted` list means **no** operations are allowed (deny-by- +/// default). Permissions can only be narrowed after creation, never broadened. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Nip46Permissions { + /// All granted permissions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub granted: Vec, +} + +impl Nip46Permissions { + /// Parse a raw permission string. + /// + /// Format: `"sign_event:#1,#3; nip44_encrypt; get_public_key"` + /// + /// Permissions are semicolon-separated. Within a single permission, + /// event kinds follow a `:` and are themselves comma-separated with + /// optional `#` prefixes. An empty or blank string is treated as *no + /// permissions* and returns an empty list. + pub fn parse(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(Self::default()); + } + + let mut granted = Vec::new(); + let mut seen_methods = std::collections::HashSet::new(); + for part in trimmed.split(';') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let perm = Self::parse_one(part)?; + if !seen_methods.insert(perm.method.clone()) { + return Err(AppError::config(format!( + "Duplicate NIP-46 permission method: {}", + perm.method, + ))); + } + granted.push(perm); + } + Ok(Self { granted }) + } + + /// Parse a single permission token like `"sign_event:#1,#3"`. + /// + /// The method name comes before the first `:` (if any). Everything + /// after that colon is treated as a comma-separated list of event + /// kinds (with optional `#` prefixes). + fn parse_one(token: &str) -> Result { + let (method_part, kinds_part) = match token.split_once(':') { + Some((m, k)) => (m.trim(), Some(k.trim())), + None => (token.trim(), None), + }; + + if !KNOWN_METHODS.contains(&method_part) { + return Err(AppError::config(format!( + "Unknown NIP-46 permission method: {method_part}" + ))); + } + + let allowed_kinds = match kinds_part { + Some(kinds_str) if !kinds_str.is_empty() => { + let mut kinds = Vec::new(); + for k in kinds_str.split(',') { + let k = k.trim().trim_start_matches('#'); + if k.is_empty() { + continue; + } + let kind: u16 = k.parse().map_err(|_| { + AppError::config(format!("Invalid event kind in NIP-46 permission: {k}")) + })?; + kinds.push(kind); + } + kinds + } + _ => Vec::new(), + }; + + Ok(Nip46Permission { + method: method_part.to_string(), + allowed_kinds, + }) + } + + /// Whether the given method is permitted at all. + pub fn is_method_allowed(&self, method: &str) -> bool { + self.granted.iter().any(|p| p.method == method) + } + + /// Whether the given event kind is allowed for `sign_event`. + /// + /// Returns `false` if `sign_event` is not permitted. If permitted with + /// no kind restrictions (empty `allowed_kinds`) returns `true`. If + /// permitted with specific kinds, returns `true` only when `kind` is in + /// the list. + pub fn is_sign_event_kind_allowed(&self, kind: u16) -> bool { + match self.granted.iter().find(|p| p.method == "sign_event") { + Some(p) if p.allowed_kinds.is_empty() => true, + Some(p) => p.allowed_kinds.contains(&kind), + None => false, + } + } + + /// Whether `nip44_encrypt` is permitted. + pub fn is_encrypt_allowed(&self) -> bool { + self.is_method_allowed("nip44_encrypt") + } + + /// Whether `nip44_decrypt` is permitted. + pub fn is_decrypt_allowed(&self) -> bool { + self.is_method_allowed("nip44_decrypt") + } + + /// Whether `get_public_key` is permitted. + pub fn is_get_public_key_allowed(&self) -> bool { + self.is_method_allowed("get_public_key") + } + + /// Whether `get_relays` is permitted. + pub fn is_get_relays_allowed(&self) -> bool { + self.is_method_allowed("get_relays") + } + + /// Check whether `other` can be added to these permissions without + /// broadening them. Returns `Ok(())` if the addition is safe, or an + /// error describing which permission would be expanded. + pub fn validate_no_broadening(&self, other: &Nip46Permissions) -> Result<(), AppError> { + for new_perm in &other.granted { + match self.granted.iter().find(|p| p.method == new_perm.method) { + Some(existing) => { + // If the existing permission has kind restrictions and + // the new one does not, that broadens access. + if !existing.allowed_kinds.is_empty() && new_perm.allowed_kinds.is_empty() { + return Err(AppError::config(format!( + "Cannot broaden permission for {}: \ + existing restriction to kinds {:?} would be removed", + new_perm.method, existing.allowed_kinds, + ))); + } + // If both have kind restrictions, check that the new + // set is a subset of the existing one. + if !existing.allowed_kinds.is_empty() && !new_perm.allowed_kinds.is_empty() { + for &k in &new_perm.allowed_kinds { + if !existing.allowed_kinds.contains(&k) { + return Err(AppError::config(format!( + "Cannot broaden permission for {}: \ + kind {k} is not in the existing allowed kinds", + new_perm.method, + ))); + } + } + } + } + None => { + // Method was not previously granted — adding it broadens. + return Err(AppError::config(format!( + "Cannot grant new permission for {}: \ + method was not previously authorized", + new_perm.method, + ))); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::ErrorKind; + + #[test] + fn parse_empty_string() { + let perms = Nip46Permissions::parse("").unwrap(); + assert!(perms.granted.is_empty()); + } + + #[test] + fn parse_blank_string() { + let perms = Nip46Permissions::parse(" ").unwrap(); + assert!(perms.granted.is_empty()); + } + + #[test] + fn parse_single_method() { + let perms = Nip46Permissions::parse("sign_event").unwrap(); + assert_eq!(perms.granted.len(), 1); + assert_eq!(perms.granted[0].method, "sign_event"); + assert!(perms.granted[0].allowed_kinds.is_empty()); + } + + #[test] + fn parse_method_with_kinds() { + let perms = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap(); + assert_eq!(perms.granted.len(), 1); + assert_eq!(perms.granted[0].method, "sign_event"); + assert_eq!(perms.granted[0].allowed_kinds, vec![1, 3, 5]); + } + + #[test] + fn parse_multiple_methods() { + let perms = + Nip46Permissions::parse("sign_event:#1; nip44_encrypt; get_public_key").unwrap(); + assert_eq!(perms.granted.len(), 3); + assert!(perms.is_method_allowed("sign_event")); + assert!(perms.is_method_allowed("nip44_encrypt")); + assert!(perms.is_method_allowed("get_public_key")); + } + + #[test] + fn parse_with_whitespace() { + let perms = Nip46Permissions::parse(" sign_event : #1 , #3 ; nip44_encrypt ").unwrap(); + assert_eq!(perms.granted.len(), 2); + assert_eq!(perms.granted[0].allowed_kinds, vec![1, 3]); + } + + #[test] + fn parse_unknown_method_rejected() { + let err = Nip46Permissions::parse("unknown_method").unwrap_err(); + assert!(err.message().contains("Unknown NIP-46 permission method")); + } + + #[test] + fn parse_invalid_kind_rejected() { + let err = Nip46Permissions::parse("sign_event:#abc").unwrap_err(); + assert!(err.message().contains("Invalid event kind")); + } + + #[test] + fn parse_trailing_semicolon_ignored() { + let perms = Nip46Permissions::parse("sign_event;").unwrap(); + assert_eq!(perms.granted.len(), 1); + } + + #[test] + fn is_method_allowed() { + let perms = Nip46Permissions::parse("sign_event; nip44_encrypt").unwrap(); + assert!(perms.is_method_allowed("sign_event")); + assert!(perms.is_method_allowed("nip44_encrypt")); + assert!(!perms.is_method_allowed("nip44_decrypt")); + assert!(!perms.is_method_allowed("get_public_key")); + } + + #[test] + fn sign_event_kind_allowed_no_restrictions() { + let perms = Nip46Permissions::parse("sign_event").unwrap(); + assert!(perms.is_sign_event_kind_allowed(1)); + assert!(perms.is_sign_event_kind_allowed(9999)); + } + + #[test] + fn sign_event_kind_allowed_with_restrictions() { + let perms = Nip46Permissions::parse("sign_event:#1,#3").unwrap(); + assert!(perms.is_sign_event_kind_allowed(1)); + assert!(perms.is_sign_event_kind_allowed(3)); + assert!(!perms.is_sign_event_kind_allowed(5)); + } + + #[test] + fn sign_event_not_permitted() { + let perms = Nip46Permissions::parse("nip44_encrypt").unwrap(); + assert!(!perms.is_sign_event_kind_allowed(1)); + } + + #[test] + fn encrypt_decrypt_checks() { + let perms = Nip46Permissions::parse("nip44_encrypt").unwrap(); + assert!(perms.is_encrypt_allowed()); + assert!(!perms.is_decrypt_allowed()); + } + + #[test] + fn get_public_key_check() { + let perms = Nip46Permissions::parse("get_public_key").unwrap(); + assert!(perms.is_get_public_key_allowed()); + assert!(!perms.is_get_relays_allowed()); + } + + #[test] + fn get_relays_check() { + let perms = Nip46Permissions::parse("get_relays").unwrap(); + assert!(perms.is_get_relays_allowed()); + assert!(!perms.is_get_public_key_allowed()); + } + + #[test] + fn deny_by_default_empty_permissions() { + let perms = Nip46Permissions::default(); + assert!(!perms.is_method_allowed("sign_event")); + assert!(!perms.is_encrypt_allowed()); + assert!(!perms.is_decrypt_allowed()); + assert!(!perms.is_get_public_key_allowed()); + assert!(!perms.is_get_relays_allowed()); + assert!(!perms.is_sign_event_kind_allowed(1)); + } + + #[test] + fn validate_no_broadening_adds_method() { + let existing = Nip46Permissions::parse("sign_event").unwrap(); + let proposed = Nip46Permissions::parse("nip44_encrypt").unwrap(); + assert!(existing.validate_no_broadening(&proposed).is_err()); + } + + #[test] + fn validate_no_broadening_removes_kind_restriction() { + let existing = Nip46Permissions::parse("sign_event:#1").unwrap(); + let proposed = Nip46Permissions::parse("sign_event").unwrap(); + let err = existing.validate_no_broadening(&proposed).unwrap_err(); + assert!(err.message().contains("broaden")); + } + + #[test] + fn validate_no_broadening_adds_kind() { + let existing = Nip46Permissions::parse("sign_event:#1").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1,#5").unwrap(); + let err = existing.validate_no_broadening(&proposed).unwrap_err(); + assert!(err.message().contains("kind 5")); + } + + #[test] + fn validate_no_broadening_narrows_is_ok() { + let existing = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1").unwrap(); + assert!(existing.validate_no_broadening(&proposed).is_ok()); + } + + #[test] + fn validate_no_broadening_same_is_ok() { + let existing = Nip46Permissions::parse("sign_event:#1,#3").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap(); + assert!(existing.validate_no_broadening(&proposed).is_ok()); + } + + #[test] + fn round_trip_serialization() { + let perms = Nip46Permissions::parse("sign_event:#1,#3; nip44_encrypt").unwrap(); + let json = serde_json::to_string(&perms).unwrap(); + let restored: Nip46Permissions = serde_json::from_str(&json).unwrap(); + assert_eq!(perms, restored); + } + + #[test] + fn round_trip_empty() { + let perms = Nip46Permissions::default(); + let json = serde_json::to_string(&perms).unwrap(); + let restored: Nip46Permissions = serde_json::from_str(&json).unwrap(); + assert_eq!(perms, restored); + } + + #[test] + fn connection_with_permissions_serializes() { + use crate::signer::types::Nip46Connection; + + let conn = Nip46Connection { + profile_npub: Some("npub1test".to_string()), + signer_pubkey: "abc123".to_string(), + relays: vec!["wss://relay.example.com".to_string()], + label: "Test".to_string(), + created_at: 1700000000, + permissions: Some(Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap()), + expires_at: Some(1700003600), + revoked_at: None, + }; + + let json = serde_json::to_string(&conn).unwrap(); + let restored: Nip46Connection = serde_json::from_str(&json).unwrap(); + assert!(restored.permissions.is_some()); + let perms = restored.permissions.unwrap(); + assert!(perms.is_method_allowed("sign_event")); + assert!(perms.is_sign_event_kind_allowed(1)); + assert!(!perms.is_sign_event_kind_allowed(99)); + assert!(perms.is_encrypt_allowed()); + assert_eq!(restored.expires_at, Some(1700003600)); + assert!(restored.revoked_at.is_none()); + } + + #[test] + fn connection_without_permissions_serializes() { + use crate::signer::types::Nip46Connection; + + let conn = Nip46Connection { + profile_npub: Some("npub1test".to_string()), + signer_pubkey: "abc123".to_string(), + relays: vec![], + label: "Test".to_string(), + created_at: 1700000000, + permissions: None, + expires_at: None, + revoked_at: None, + }; + + let json = serde_json::to_string(&conn).unwrap(); + let restored: Nip46Connection = serde_json::from_str(&json).unwrap(); + assert!(restored.permissions.is_none()); + } + + #[test] + fn empty_permissions_deny_all_operations() { + let perms = Nip46Permissions::default(); + assert!(!perms.is_sign_event_kind_allowed(1)); + assert!(!perms.is_encrypt_allowed()); + assert!(!perms.is_decrypt_allowed()); + assert!(!perms.is_get_public_key_allowed()); + assert!(!perms.is_get_relays_allowed()); + } + + #[test] + fn permission_broadening_rejected_when_adding_method() { + let existing = Nip46Permissions::parse("sign_event").unwrap(); + let proposed = Nip46Permissions::parse("sign_event; nip44_encrypt").unwrap(); + let err = existing.validate_no_broadening(&proposed).unwrap_err(); + assert!(err.message().contains("nip44_encrypt")); + } + + #[test] + fn permission_broadening_rejected_when_removing_kind_restriction() { + let existing = Nip46Permissions::parse("sign_event:#1").unwrap(); + let proposed = Nip46Permissions::parse("sign_event").unwrap(); + let err = existing.validate_no_broadening(&proposed).unwrap_err(); + assert!(err.message().contains("broaden")); + } + + #[test] + fn permission_broadening_rejected_when_adding_kind() { + let existing = Nip46Permissions::parse("sign_event:#1").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1,#5").unwrap(); + let err = existing.validate_no_broadening(&proposed).unwrap_err(); + assert!(err.message().contains("kind 5")); + } + + #[test] + fn permission_narrowing_is_allowed() { + let existing = Nip46Permissions::parse("sign_event:#1,#3,#5").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1").unwrap(); + assert!(existing.validate_no_broadening(&proposed).is_ok()); + } + + #[test] + fn permission_same_is_allowed() { + let existing = Nip46Permissions::parse("sign_event:#1,#3").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap(); + assert!(existing.validate_no_broadening(&proposed).is_ok()); + } + + #[test] + fn connection_expiry_check() { + use crate::signer::types::Nip46Connection; + + let conn = Nip46Connection { + profile_npub: Some("npub1test".to_string()), + signer_pubkey: "abc123".to_string(), + relays: vec![], + label: "Test".to_string(), + created_at: 1700000000, + permissions: None, + expires_at: Some(1700000001), // Expired immediately + revoked_at: None, + }; + + let now = crate::vault::unix_timestamp().unwrap_or(0); + if now >= conn.expires_at.unwrap() { + assert!(conn.expires_at.unwrap() <= now, "connection is expired"); + } + } + + #[test] + fn connection_revocation_check() { + use crate::signer::types::Nip46Connection; + + let conn = Nip46Connection { + profile_npub: Some("npub1test".to_string()), + signer_pubkey: "abc123".to_string(), + relays: vec![], + label: "Test".to_string(), + created_at: 1700000000, + permissions: None, + expires_at: None, + revoked_at: Some(1700000001), + }; + + assert!(conn.revoked_at.is_some(), "connection is revoked"); + } + + #[test] + fn parser_rejects_empty_method_name() { + let err = Nip46Permissions::parse(":1;").expect_err("empty method should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_unknown_method() { + let err = + Nip46Permissions::parse("sign_event:#1; unknown_method").expect_err("unknown method"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_invalid_kind_format() { + let err = + Nip46Permissions::parse("sign_event:abc").expect_err("non-numeric kind should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_negative_kind() { + let err = Nip46Permissions::parse("sign_event:#-1").expect_err("negative kind should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_overflowing_kind() { + let err = Nip46Permissions::parse("sign_event:#99999999999999") + .expect_err("overflowing kind should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_duplicate_method() { + let err = Nip46Permissions::parse("sign_event:#1; sign_event:#3") + .expect_err("duplicate method should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + assert!(err.message().contains("Duplicate NIP-46 permission method")); + } + + #[test] + fn parser_rejects_duplicate_method_no_spaces() { + let err = Nip46Permissions::parse("sign_event:#1;sign_event:#3") + .expect_err("duplicate method should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_duplicate_method_same_kinds() { + let err = Nip46Permissions::parse("sign_event:#1; sign_event:#1") + .expect_err("duplicate method should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_duplicate_method_encrypt() { + let err = Nip46Permissions::parse("nip44_encrypt;nip44_encrypt") + .expect_err("duplicate method should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_duplicate_method_get_public_key() { + let err = Nip46Permissions::parse("get_public_key; get_public_key") + .expect_err("duplicate method should fail"); + assert!(matches!(err.kind(), ErrorKind::Config)); + } + + #[test] + fn parser_rejects_duplicate_method_first_wins() { + // Error should mention the duplicated method name + let err = Nip46Permissions::parse("nip44_decrypt; nip44_decrypt; get_public_key") + .expect_err("duplicate method should fail"); + assert!(err.message().contains("nip44_decrypt")); + } + + #[test] + fn parser_allows_trailing_semicolons() { + // Trailing semicolons produce empty parts which are skipped. + let perms = Nip46Permissions::parse("sign_event:#1;").unwrap(); + assert!(perms.is_method_allowed("sign_event")); + assert!(perms.is_sign_event_kind_allowed(1)); + } + + #[test] + fn parser_allows_leading_semicolons() { + // Leading semicolons produce empty parts which are skipped. + let perms = Nip46Permissions::parse(";sign_event:#1").unwrap(); + assert!(perms.is_method_allowed("sign_event")); + assert!(perms.is_sign_event_kind_allowed(1)); + } + + #[test] + fn serialization_roundtrip_canonical() { + let perms = + Nip46Permissions::parse("get_public_key;nip44_decrypt;sign_event:#1,#4").unwrap(); + let json = serde_json::to_string(&perms).unwrap(); + let restored: Nip46Permissions = serde_json::from_str(&json).unwrap(); + assert_eq!(perms, restored); + } + + #[test] + fn broadening_rejected_when_adding_kind() { + let existing = Nip46Permissions::parse("sign_event:#1").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1,#3").unwrap(); + existing + .validate_no_broadening(&proposed) + .expect_err("adding kind should fail"); + } + + #[test] + fn broadening_rejected_when_adding_encryption_to_signonly() { + let existing = Nip46Permissions::parse("sign_event:#1").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap(); + existing + .validate_no_broadening(&proposed) + .expect_err("adding encrypt should fail"); + } + + #[test] + fn broadening_accepted_when_restricting() { + let existing = Nip46Permissions::parse("sign_event:#1,#3; nip44_encrypt").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1").unwrap(); + existing.validate_no_broadening(&proposed).unwrap(); + } + + #[test] + fn broadening_accepted_when_removing_method() { + // validate_no_broadening only checks for broadening, not narrowing. + // Removing a method is narrowing and is accepted. + let existing = Nip46Permissions::parse("sign_event:#1; nip44_encrypt").unwrap(); + let proposed = Nip46Permissions::parse("sign_event:#1").unwrap(); + existing.validate_no_broadening(&proposed).unwrap(); + } +} diff --git a/src/signer/types.rs b/src/signer/types.rs new file mode 100644 index 0000000..26c6672 --- /dev/null +++ b/src/signer/types.rs @@ -0,0 +1,105 @@ +//! Common types for the Signer abstraction. + +use serde::{Deserialize, Serialize}; + +/// The type of signer being used. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignerType { + /// Keys stored locally in the encrypted vault. + Embedded, + /// Keys held by a remote NIP-46 signer (bunker). + Nip46, +} + +/// Details about a signing request, for user approval. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalDetails { + /// The NIP-46 method being requested. + pub method: String, + /// Human-readable summary of what will be done. + pub summary: String, + /// Event kind for `sign_event` requests. + pub event_kind: Option, + /// Destination relays for the signed event. + pub destination_relays: Vec, + /// Truncated preview of event content. + pub content_preview: String, + /// Whether this is a sensitive operation requiring extra confirmation. + pub is_sensitive: bool, +} + +/// Result of a user approval prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalResult { + Approved, + Rejected, + Timeout, +} + +/// Configuration for a NIP-46 connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Nip46Connection { + /// The profile npub this connection belongs to. + /// + /// `None` indicates a legacy connection from before profile ownership + /// tracking was added. These connections cannot pass authorization + /// checks and must be re-created to regain access. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_npub: Option, + /// The signer's public key (hex). + pub signer_pubkey: String, + /// Relays to use for the connection. + pub relays: Vec, + /// Human-readable label for this connection. + /// + /// **The nostrconnect `secret` is intentionally NOT stored here.** It is a + /// credential: it lives in the vault's encrypted `connection_secrets` store, + /// keyed by this connection's [`crate::signer::VaultRef`] (profile npub + + /// signer pubkey), and is resolved only at the vault boundary while the + /// vault is unlocked. Keeping it out of `Nip46Connection` is what lets a + /// serialized connection (or a `SigningBackend::Remote`) carry zero secret + /// material. Legacy vaults that still carry an inline `secret` deserialize + /// fine — the field is ignored and the dead secret is dropped on the next + /// save. + pub label: String, + /// When this connection was created (unix timestamp). + pub created_at: u64, + /// Parsed per-connection permissions. + /// + /// When absent the connection carries no permissions and all operations + /// are denied (deny-by-default). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// When this connection expires (unix timestamp). + /// + /// `None` means the connection does not expire. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + /// When this connection was revoked (unix timestamp). + /// + /// `None` means the connection is still active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revoked_at: Option, +} + +/// Status of a NIP-46 connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Nip46Status { + pub connected: bool, + pub signer_pubkey: Option, + pub relays: Vec, + pub connected_relays: Vec, + pub error: Option, + pub pending_approvals: Vec, +} + +/// A pending approval request from the signer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingApproval { + pub id: String, + pub method: String, + pub summary: String, + pub details: ApprovalDetails, +} diff --git a/src/vault.rs b/src/vault.rs index e4b078c..24d0702 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -9,15 +9,33 @@ use std::time::{SystemTime, UNIX_EPOCH}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; use crate::errors::AppError; +/// Active signer mode per profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignerMode { + Embedded, + Nip46Bunker, + Nip46Client, +} + +/// Serde default for `StoredProfile::signer_mode`: legacy profiles without +/// the field are treated as local (embedded) signers. +fn default_embedded() -> SignerMode { + SignerMode::Embedded +} + /// Current vault schema version. -pub const VAULT_VERSION: u32 = 2; +pub const VAULT_VERSION: u32 = 3; /// Filename of the profiles vault. pub const VAULT_FILE_NAME: &str = "profiles_vault.json"; /// Filename of the settings file. pub const SETTINGS_FILE_NAME: &str = "settings.json"; +/// Filename of the last publish report. +pub const LAST_PUBLISH_FILE_NAME: &str = "last_publish.json"; /// A profile stored on disk. /// @@ -44,6 +62,10 @@ pub struct StoredProfile { /// part of kind 0 metadata so clients show a human handle. #[serde(default)] pub nip05: Option, + /// Per-profile signer mode. Defaults to `Embedded` for legacy profiles + /// that predate signer-mode tracking. + #[serde(default = "default_embedded")] + pub signer_mode: SignerMode, } /// KDF parameters that encrypted a vault. Stored so future key-derivation @@ -84,6 +106,39 @@ pub struct Vault { #[serde(default, skip_serializing_if = "Option::is_none")] pub crypto: Option, pub profiles: Vec, + /// Stored NIP-46 connections, keyed by the profile npub they belong to. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub nip46_connections: Vec, + /// Encrypted NIP-46 connection secrets, one per connection. + /// + /// Keyed by [`crate::signer::VaultRef`] (profile npub + remote signer + /// pubkey) and encrypted under the vault key — exactly like profile + /// secrets. The nostrconnect `secret` is a credential, so it is never kept + /// inline on `Nip46Connection` (which can be serialized and shown to the + /// UI); it lives here, in the vault, encrypted. An empty vault (no + /// password) stores these in plaintext, matching how profile secrets are + /// handled; a password-protected vault encrypts them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connection_secrets: Vec, +} + +/// An encrypted NIP-46 connection secret, keyed by its +/// [`crate::signer::VaultRef`] (profile npub + remote signer pubkey). +/// +/// The `secret` is the nostrconnect credential. It is plaintext when the vault +/// has no password (matching how profile secrets are stored), and a base64 +/// AES-256-GCM blob (nonce || ciphertext || tag) under the vault key when the +/// vault is password-protected. See [`Vault::connection_secrets`]. +/// +/// The key is a `VaultRef` itself (not two loose strings) so the store can +/// never disagree with the `SigningBackend::Remote { vault_ref }` that points +/// at it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionSecret { + /// The opaque reference (profile npub + remote signer pubkey). + pub ref_: crate::signer::VaultRef, + /// The nostrconnect secret — plaintext or encrypted, per the vault. + pub secret: String, } impl Vault { @@ -95,6 +150,8 @@ impl Vault { active_profile: None, crypto: None, profiles: Vec::new(), + nip46_connections: Vec::new(), + connection_secrets: Vec::new(), } } @@ -180,6 +237,41 @@ pub fn settings_path() -> PathBuf { data_dir().join(SETTINGS_FILE_NAME) } +pub fn last_publish_path() -> PathBuf { + data_dir().join(LAST_PUBLISH_FILE_NAME) +} + +/// A minimal publish report persisted across restarts so the Home screen can +/// show the most recent publication result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredPublishReport { + pub event_id: String, + pub succeeded: Vec, + pub failed: Vec, + #[serde(default)] + pub content: String, +} + +/// Load the last publish report, returning `None` when absent or unreadable. +pub fn load_last_publish() -> Option { + let path = last_publish_path(); + if !path.exists() { + return None; + } + let content = fs::read_to_string(&path).ok()?; + if content.trim().is_empty() { + return None; + } + serde_json::from_str(&content).ok() +} + +/// Persist the last publish report with restrictive permissions. +pub fn save_last_publish(report: &StoredPublishReport) -> Result<(), AppError> { + let content = serde_json::to_string_pretty(report) + .map_err(|e| AppError::json("Could not prepare the last publish report for saving", e))?; + write_restricted(&last_publish_path(), &content) +} + /// Candidate locations for a legacy vault created by the old CLI version. /// /// The old application wrote `profiles_vault.json` in its working directory. @@ -250,12 +342,124 @@ pub fn parse_vault(content: &str) -> Result { active_profile: None, crypto: None, profiles, + nip46_connections: Vec::new(), + connection_secrets: Vec::new(), }); } serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}"))) } +/// Migrate all profiles in the vault to have an explicit `signer_mode`. +/// +/// This is idempotent: profiles that already have a `signer_mode` are +/// left untouched. Only profiles with the legacy `None` value (or +/// missing the field entirely) are assigned `Embedded`. +/// +/// Returns `true` if any profiles were migrated (i.e. the vault should +/// be re-saved). +pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool { + let mut changed = false; + for _profile in &mut vault.profiles { + // The serde default already handles missing fields during + // deserialization, but once loaded, profiles that were stored + // before signer_mode was introduced will have the default value. + // We write it explicitly so the on-disk format is canonical. + // + // After the first save, every profile will have an explicit + // signer_mode and this becomes a no-op. + // + // We cannot distinguish "user explicitly set Embedded" from + // "serde defaulted to Embedded", so we always write it — this is + // safe because Embedded is the correct default and the write is + // idempotent. + changed = true; + } + // Also ensure the nip46_connections vector exists (serde default + // handles this during deserialization, but we normalise here too). + if vault.version < VAULT_VERSION { + vault.version = VAULT_VERSION; + changed = true; + } + // Legacy connections without profile_npub (None) are left as-is. + // Ownership cannot be reliably inferred from active_profile, so these + // connections remain unusable until the user re-creates them. + changed +} + +/// Store (or replace) a NIP-46 connection secret in the vault, keyed by an +/// opaque [`crate::signer::VaultRef`]. +/// +/// Encrypts under `key` when the vault is password-protected, otherwise stores +/// the secret in plaintext — exactly mirroring how profile secrets are handled. +/// A reference that already has a secret is replaced in place so reconnecting +/// a signer never leaves a stale secret behind. +/// +/// `key` is required when the vault is encrypted; a locked encrypted vault +/// fails closed rather than silently storing a plaintext secret that would +/// not match once the vault is unlocked. +pub fn store_connection_secret( + vault: &mut Vault, + key: Option<&crate::crypto::VaultKey>, + ref_: &crate::signer::VaultRef, + secret: &str, +) -> Result<(), AppError> { + let stored = match &vault.crypto { + Some(_) => { + let key = key.ok_or_else(AppError::vault_locked)?; + crate::crypto::encrypt_secret(key, secret)? + } + None => secret.to_string(), + }; + if let Some(entry) = vault + .connection_secrets + .iter_mut() + .find(|c| c.ref_ == *ref_) + { + entry.secret = stored; + } else { + vault.connection_secrets.push(ConnectionSecret { + ref_: ref_.clone(), + secret: stored, + }); + } + Ok(()) +} + +/// Resolve (decrypt) a stored NIP-46 connection secret for a reference. +/// +/// Returns `Ok(None)` when no secret is stored for the reference. When the +/// vault is encrypted but locked (no `key`) it is the fail-closed case and +/// returns `Err(vault_locked)`, which maps to `SigningError::SecretResolution`. +/// On success the plaintext is [`Zeroizing`]: shredded when it goes out of scope. +pub fn resolve_connection_secret( + vault: &Vault, + key: Option<&crate::crypto::VaultKey>, + ref_: &crate::signer::VaultRef, +) -> Result>, AppError> { + let entry = vault.connection_secrets.iter().find(|c| c.ref_ == *ref_); + let Some(entry) = entry else { + return Ok(None); + }; + match &vault.crypto { + Some(_) => { + let key = key.ok_or_else(AppError::vault_locked)?; + let plain = crate::crypto::decrypt_secret(key, &entry.secret)?; + Ok(Some(plain)) + } + None => Ok(Some(Zeroizing::new(entry.secret.clone()))), + } +} + +/// Remove a stored NIP-46 connection secret (e.g. on disconnect). +/// +/// Returns `true` when an entry was removed. +pub fn delete_connection_secret(vault: &mut Vault, ref_: &crate::signer::VaultRef) -> bool { + let before = vault.connection_secrets.len(); + vault.connection_secrets.retain(|c| c.ref_ != *ref_); + vault.connection_secrets.len() != before +} + /// Persist the vault to the stable application-data location with /// restrictive permissions. pub fn save_vault(vault: &Vault) -> Result<(), AppError> { @@ -463,6 +667,7 @@ mod tests { created_at: 1_700_000_000, picture: None, nip05: None, + signer_mode: SignerMode::Embedded, } } @@ -617,4 +822,189 @@ mod tests { fs::write(&path, encrypted).unwrap(); assert!(is_populated_vault_file(&path)); } + + #[test] + fn legacy_profile_without_signer_mode_loads_as_embedded() { + // A vault written before signer_mode was introduced has no + // signer_mode field. The serde default must produce Embedded. + let json = r#"{ + "version": 2, + "profiles": [ + { "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef", "created_at": 1700000000 } + ] + }"#; + let vault = parse_vault(json).expect("should parse"); + assert_eq!(vault.profiles.len(), 1); + assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded); + } + + #[test] + fn migrate_vault_signer_modes_is_idempotent() { + let mut vault = Vault::empty(); + vault.profiles.push(StoredProfile { + label: "Alice".to_string(), + public_key: "npub1abc".to_string(), + secret_key: "deadbeef".to_string(), + created_at: 1700000000, + picture: None, + nip05: None, + signer_mode: SignerMode::Embedded, + }); + + let changed1 = migrate_vault_signer_modes(&mut vault); + assert!(changed1, "first migration should report change"); + + let _changed2 = migrate_vault_signer_modes(&mut vault); + // The function always returns true because it normalises the version. + // The important thing is that running it twice doesn't corrupt data. + assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded); + assert_eq!(vault.version, VAULT_VERSION); + } + + #[test] + fn migrate_vault_signer_modes_bumps_version() { + let mut vault = Vault::empty(); + vault.version = 1; // Simulate an old vault + let changed = migrate_vault_signer_modes(&mut vault); + assert!(changed); + assert_eq!(vault.version, VAULT_VERSION); + } + + #[test] + fn nip46_connections_serialization_roundtrip() { + use crate::signer::types::Nip46Connection; + + let mut vault = Vault::empty(); + vault.nip46_connections.push(Nip46Connection { + profile_npub: Some("npub1test".to_string()), + signer_pubkey: "abc123".to_string(), + relays: vec!["wss://relay.example.com".to_string()], + label: "Test Bunker".to_string(), + created_at: 1700000000, + permissions: None, + expires_at: None, + revoked_at: None, + }); + + let json = serde_json::to_string(&vault).unwrap(); + let restored: Vault = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.nip46_connections.len(), 1); + assert_eq!(restored.nip46_connections[0].signer_pubkey, "abc123"); + assert_eq!(restored.nip46_connections[0].label, "Test Bunker"); + } + + #[test] + fn nip46_connections_absent_in_legacy_vault() { + // A vault without nip46_connections should deserialize with an + // empty vector. + let json = r#"{ + "version": 2, + "profiles": [] + }"#; + let vault: Vault = serde_json::from_str(json).unwrap(); + assert!(vault.nip46_connections.is_empty()); + } + + #[test] + fn unknown_signer_mode_value_fails_deserialization() { + let json = r#"{ + "version": 3, + "profiles": [ + { "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef", + "created_at": 1700000000, "signer_mode": "unknown_value" } + ] + }"#; + let err = parse_vault(json).expect_err("unknown signer_mode must fail"); + assert_eq!(err.kind(), ErrorKind::VaultMalformed); + } + + #[test] + fn connection_without_profile_npub_deserializes() { + // Old connections without profile_npub should deserialize with + // an empty string (serde default). + let json = r#"{ + "signer_pubkey": "abc123", + "relays": ["wss://relay.example.com"], + "secret": null, + "label": "Test", + "created_at": 1700000000, + "permissions": null, + "expires_at": null, + "revoked_at": null + }"#; + let conn: crate::signer::types::Nip46Connection = serde_json::from_str(json).unwrap(); + assert!(conn.profile_npub.is_none()); + assert_eq!(conn.signer_pubkey, "abc123"); + } + + #[test] + fn legacy_connection_without_profile_npub_is_none() { + // Connections from old vaults without profile_npub deserialize as None. + // None connections fail authorization checks. + let mut vault = Vault::empty(); + vault.active_profile = Some("npub1alice".to_string()); + vault + .nip46_connections + .push(crate::signer::types::Nip46Connection { + profile_npub: None, // Legacy connection + signer_pubkey: "abc123".to_string(), + relays: vec![], + label: "Legacy".to_string(), + created_at: 1700000000, + permissions: None, + expires_at: None, + revoked_at: None, + }); + + let _changed = migrate_vault_signer_modes(&mut vault); + // Legacy connection remains None - ownership cannot be inferred + assert!(vault.nip46_connections[0].profile_npub.is_none()); + } + + #[test] + fn migration_preserves_existing_profile_npub() { + let mut vault = Vault::empty(); + vault.active_profile = Some("npub1alice".to_string()); + vault + .nip46_connections + .push(crate::signer::types::Nip46Connection { + profile_npub: Some("npub1bob".to_string()), + signer_pubkey: "abc123".to_string(), + relays: vec![], + label: "Bob's".to_string(), + created_at: 1700000000, + permissions: None, + expires_at: None, + revoked_at: None, + }); + + let _changed = migrate_vault_signer_modes(&mut vault); + // Already-owned connection is not modified + assert_eq!( + vault.nip46_connections[0].profile_npub.as_deref(), + Some("npub1bob") + ); + } + + #[test] + fn migration_legacy_connection_without_active_profile() { + let mut vault = Vault::empty(); + // No active profile set + vault + .nip46_connections + .push(crate::signer::types::Nip46Connection { + profile_npub: None, + signer_pubkey: "abc123".to_string(), + relays: vec![], + label: "Legacy".to_string(), + created_at: 1700000000, + permissions: None, + expires_at: None, + revoked_at: None, + }); + + let _changed = migrate_vault_signer_modes(&mut vault); + // Connection remains None - cannot infer ownership + assert!(vault.nip46_connections[0].profile_npub.is_none()); + } }