diff --git a/CHECKPOINT-display-and-icons.md b/CHECKPOINT-display-and-icons.md deleted file mode 100644 index ac1f133..0000000 --- a/CHECKPOINT-display-and-icons.md +++ /dev/null @@ -1,103 +0,0 @@ -# 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 352d9bf..ffbb89c 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -1,203 +1,54 @@ -# Checkpoint — NIP-46 Connection Secrets in the Vault (2026-09-04) +# Checkpoint — Release packages with profile metadata fix (2026-09-01) ## Where things are - Project: `/home/avi/Projects/Keynctr` -- 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. +- 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. -## What was completed (this session) +## 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`. -**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. +## 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 -- **`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. +## 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. -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`. +## 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`. -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. +## 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. diff --git a/Cargo.lock b/Cargo.lock index 8dd71c3..b3bd988 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 0.1.7", + "crypto-common", "generic-array", ] @@ -19,19 +19,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "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", + "cipher", + "cpufeatures", ] [[package]] @@ -41,33 +30,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes 0.8.4", - "cipher 0.4.4", + "aes", + "cipher", "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" @@ -76,7 +45,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures", "password-hash", ] @@ -86,137 +55,6 @@ 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" @@ -249,18 +87,6 @@ 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" @@ -350,7 +176,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -362,15 +188,6 @@ 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" @@ -380,28 +197,6 @@ 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" @@ -426,16 +221,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "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", + "cipher", ] [[package]] @@ -461,8 +247,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "cipher", + "cpufeatures", ] [[package]] @@ -473,7 +259,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20", - "cipher 0.4.4", + "cipher", "poly1305", "zeroize", ] @@ -484,64 +270,11 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", + "crypto-common", + "inout", "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" @@ -551,21 +284,6 @@ 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" @@ -577,31 +295,13 @@ 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 0.4.4", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", + "cipher", ] [[package]] @@ -616,23 +316,11 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "crypto-common", "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" @@ -650,39 +338,6 @@ 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" @@ -693,26 +348,6 @@ 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" @@ -723,12 +358,6 @@ 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" @@ -792,19 +421,6 @@ 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" @@ -921,12 +537,6 @@ 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" @@ -937,12 +547,6 @@ 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" @@ -967,24 +571,6 @@ 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" @@ -1001,15 +587,6 @@ 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" @@ -1114,36 +691,16 @@ 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 0.3.3", + "block-padding", "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" @@ -1167,55 +724,25 @@ 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" @@ -1249,15 +776,6 @@ 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" @@ -1285,7 +803,7 @@ dependencies = [ "bech32", "bip39", "bitcoin_hashes 1.2.0", - "cbc 0.1.2", + "cbc", "chacha20", "chacha20poly1305", "faster-hex", @@ -1343,78 +861,6 @@ 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" @@ -1433,22 +879,6 @@ 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" @@ -1495,38 +925,13 @@ 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 0.2.17", + "cpufeatures", "opaque-debug", "universal-hash", ] @@ -1538,7 +943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "opaque-debug", "universal-hash", ] @@ -1561,15 +966,6 @@ 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" @@ -1684,35 +1080,6 @@ 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" @@ -1748,19 +1115,6 @@ 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" @@ -1827,48 +1181,6 @@ 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" @@ -1912,17 +1224,6 @@ 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" @@ -1930,30 +1231,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "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", + "cpufeatures", + "digest", ] [[package]] @@ -2039,19 +1318,6 @@ 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" @@ -2217,36 +1483,6 @@ 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" @@ -2254,21 +1490,9 @@ 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" @@ -2303,17 +1527,6 @@ 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" @@ -2335,7 +1548,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "subtle", ] @@ -2384,7 +1597,6 @@ checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "wasm-bindgen", ] @@ -2498,19 +1710,6 @@ 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" @@ -2602,15 +1801,6 @@ 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" @@ -2646,87 +1836,6 @@ 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" @@ -2812,44 +1921,3 @@ 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 6212a43..f30d41b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,3 @@ 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 deleted file mode 100644 index 18ff052..0000000 Binary files a/deferred/original-icons/icon-public-512-white.png and /dev/null differ diff --git a/deferred/original-icons/logo-sidebar-338-white.png b/deferred/original-icons/logo-sidebar-338-white.png deleted file mode 100644 index fcb48ba..0000000 Binary files a/deferred/original-icons/logo-sidebar-338-white.png and /dev/null differ diff --git a/frontend/build/icon.png b/frontend/build/icon.png index 174f479..18ff052 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 b8be8f2..d973028 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 { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; import * as net from 'node:net'; import * as path from 'node:path'; @@ -28,306 +28,6 @@ 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. * @@ -338,12 +38,12 @@ if (process.platform === 'linux') { * (HMR websocket included). */ const CSP_PROD = - "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'; " + + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "connect-src 'self'; img-src 'self' data: https:; 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' https://fonts.googleapis.com; " + - "connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; " + + "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:; " + "object-src 'none'; base-uri 'none'; form-action 'none'"; /** The CSP for a URL this window may load, or `null` for anywhere else. */ @@ -495,7 +195,6 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'init', 'get_state', 'create_profile', - 'import_profile', 'select_profile', 'publish_profile_metadata', 'set_profile_picture', @@ -518,26 +217,10 @@ 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. */ @@ -832,7 +515,6 @@ function createWindow(): void { icon: resolveWindowIcon(), backgroundColor: '#f6f4f0', autoHideMenuBar: true, - show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -840,29 +522,6 @@ 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); @@ -989,11 +648,6 @@ app.whenReady().then(() => { createWindow(); - if (pendingGiveUpDialog) { - dialog.showErrorBox('Keynctr — display problems', pendingGiveUpDialog); - pendingGiveUpDialog = null; - } - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); @@ -1002,7 +656,6 @@ 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 88f4e9c..6e127ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "keynectr", "version": "0.1.0", "dependencies": { - "nostr-tools": "^2.25.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -863,45 +862,6 @@ "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", @@ -1242,66 +1202,6 @@ "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", @@ -5107,47 +5007,6 @@ "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", @@ -6390,7 +6249,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 83ec6e5..8fa43e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,12 +22,10 @@ "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 a4925df..18ff052 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 4646a00..be6814f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,6 @@ 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'; @@ -75,7 +74,6 @@ 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 d366bf7..fcb48ba 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 deleted file mode 100644 index 6aba1c4..0000000 --- a/frontend/src/components/ExportSecretKeyModal.tsx +++ /dev/null @@ -1,209 +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'; - -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 ef787ca..318d3d6 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -16,8 +16,7 @@ export type IconName = | 'shield' | 'publish' | 'external' - | 'key' - | 'server'; + | 'key'; const PATHS: Record = { home: ( @@ -102,12 +101,6 @@ const PATHS: Record = { ), - server: ( - <> - - - - ), }; interface IconProps { diff --git a/frontend/src/components/ShowSecretKeyModal.tsx b/frontend/src/components/ShowSecretKeyModal.tsx new file mode 100644 index 0000000..ae1eb40 --- /dev/null +++ b/frontend/src/components/ShowSecretKeyModal.tsx @@ -0,0 +1,188 @@ +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 db3d22e..9a50167 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -11,8 +11,7 @@ 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-mode', label: 'Signer Mode', icon: 'key' }, - { id: 'signer', label: 'Signer (Bunker)', icon: 'server' }, + { id: 'signer', label: 'Signer', icon: 'key' }, { id: 'settings', label: 'Settings', icon: 'settings' }, ]; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1bfc078..8f0f7d6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,18 +1,15 @@ import type { AppState, BackendResponse, - EmbeddedSignerStatus, FeedItem, LinkPreview, MetadataPublishReport, - Nip46SignerStatus, PickedImage, ProfileSummary, PublishReport, RelayTestResult, RevealedKey, Settings, - SignerMode, SignerStatus, UpdateApplyReport, UpdateCheckReport, @@ -55,8 +52,6 @@ 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 }), @@ -101,29 +96,10 @@ export const api = { unlockVault: (password: string) => call('unlock_vault', { password }), lockVault: () => call('lock_vault'), removeVaultPassword: (password: string) => call('remove_vault_password', { password }), - exportSecretKey: (npub: string, password: string, reason: string) => - call('export_secret_key', { npub, password, reason }), + revealSecretKey: (npub: string) => call('reveal_secret_key', { npub }), 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 e625286..ee97e85 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,5 +1,4 @@ -export type Screen = - 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'signer-mode' | 'settings'; +export type Screen = 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings'; export const SCREEN_TITLES: Record = { home: 'Home', @@ -7,7 +6,6 @@ export const SCREEN_TITLES: Record = { profiles: 'Profiles', compose: 'Compose', relays: 'Relays', - signer: 'Signer (Bunker)', - 'signer-mode': 'Signer Mode', + signer: 'Signer', settings: 'Settings', }; diff --git a/frontend/src/lib/publications.ts b/frontend/src/lib/publications.ts deleted file mode 100644 index 4b97ae0..0000000 --- a/frontend/src/lib/publications.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 063e451..0000000 --- a/frontend/src/lib/signer/SignerManager.ts +++ /dev/null @@ -1,536 +0,0 @@ -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 b3d8cf4..f64bd77 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,22 +1,8 @@ -export type Theme = - 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; - -/** Active signer mode. */ -export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client'; +export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; /** 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. */ @@ -25,8 +11,6 @@ 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. */ @@ -36,38 +20,12 @@ 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; @@ -80,8 +38,6 @@ 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 { @@ -109,8 +65,6 @@ 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. */ @@ -119,9 +73,6 @@ 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. */ @@ -188,15 +139,6 @@ 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 54e9cf1..0bf3db6 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -8,8 +8,6 @@ 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 { @@ -18,8 +16,7 @@ interface HomeScreenProps { } export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { - const { state, selectProfile } = useApp(); - const { publications, fullyPublished, loading, error } = useProfilePublications(); + const { state, lastPublish, selectProfile } = useApp(); const [selecting, setSelecting] = useState(null); const onSelect = async (npub: string) => { @@ -75,7 +72,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { @@ -94,15 +91,11 @@ 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')} />
      @@ -199,114 +176,92 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { } function PublicationResult({ - publications, - fullyPublished, - loading, - error, + lastPublish, onNavigateCompose, }: { - publications: ProfilePublication[]; - fullyPublished: ProfilePublication[]; - loading: boolean; - error: string | null; + lastPublish: ReturnType['lastPublish']; onNavigateCompose: () => void; }) { - if (loading) { - return

      Loading publications…

      ; + if (!lastPublish) { + return ( +

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

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

      You haven't published anything yet.

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

      No fully published publications found.

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

      No fully published publications found.

      - {newestPartial && } -
      - ); - } - - return ( - <>
      Published - - {shortenNpub(newest.id, true)} + + {shortenNpub(report.event_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.
      • } -
      -
      +
      + + 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} +
      • + ))} +
      +
      +
      ); } function FirstRunGuide() { - const steps: { icon: string; title: string; body: string }[] = [ + const steps: { title: string; body: string }[] = [ { - icon: 'users', - title: 'Create a profile', + title: '1 · Create a profile', body: 'The app generates a public npub address and a private key for you. They are stored only on this computer.', }, { - icon: 'copy', - title: 'Share your npub', + title: '2 · Share your npub', body: 'Your npub is public and safe to share. Never share your private key with anyone.', }, { - icon: 'edit', - title: 'Publish a note', + title: '3 · Publish a note', body: 'Write a note in Compose and publish it. Your note is signed locally and sent to the enabled relays.', }, ]; @@ -315,14 +270,9 @@ 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 d5018d8..09430fb 100644 --- a/frontend/src/screens/ImportProfileModal.tsx +++ b/frontend/src/screens/ImportProfileModal.tsx @@ -13,55 +13,31 @@ 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 60c292b..fdd796b 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -8,8 +8,7 @@ import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; import { Modal } from '../components/Modal'; import { ProfileEditModal } from '../components/ProfileEditModal'; -import { ImportProfileModal } from './ImportProfileModal'; -import { ExportSecretKeyModal } from '../components/ExportSecretKeyModal'; +import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal'; import { formatDate, shortenNpub } from '../lib/format'; import type { MetadataPublishReport } from '../lib/types'; import { useApp } from '../state/AppProvider'; @@ -42,7 +41,6 @@ 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; @@ -122,15 +120,10 @@ 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={ -
      - - -
      + } />
@@ -165,9 +158,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { Create Profile - {error && {error}} @@ -347,7 +337,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { ))} - setRevealTarget(null)} @@ -404,7 +394,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { onError={setError} /> )} - setImportOpen(false)} /> ); diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx deleted file mode 100644 index dcabe76..0000000 --- a/frontend/src/screens/SignerModeScreen.tsx +++ /dev/null @@ -1,501 +0,0 @@ -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 4d514f2..1b3c1ac 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -12,7 +12,6 @@ const EMPTY_STATUS: SignerStatus = { phase: 'stopped', peer: null, relays: [], - connectedRelays: [], error: null, pending: [], }; @@ -145,25 +144,11 @@ export function SignerScreen() {
Relays
{status.relays.length > 0 ? ( - <> - {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… - - )} - + status.relays.map((relay) => ( + + {relay} + + )) ) : ( None )} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 602369a..473d782 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -10,18 +10,15 @@ 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, @@ -43,7 +40,6 @@ 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; @@ -67,22 +63,10 @@ interface AppContextValue { unlockVault: (password: string) => Promise; lockVault: () => Promise; removeVaultPassword: (password: string) => Promise; - exportSecretKey: (npub: string, password: string, reason: string) => Promise; + revealSecretKey: (npub: 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; @@ -113,14 +97,6 @@ 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) { @@ -146,15 +122,6 @@ 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); @@ -243,32 +210,6 @@ 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)), @@ -283,11 +224,7 @@ export function AppProvider({ children }: { children: ReactNode }) { (password: string) => applyState(api.removeVaultPassword(password)), [applyState], ); - const exportSecretKey = useCallback( - (npub: string, password: string, reason: string) => - api.exportSecretKey(npub, password, reason), - [], - ); + const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []); const pickImages = useCallback(() => api.pickImages(), []); const uploadImage = useCallback((token: string) => api.uploadImage(token), []); const linkPreview = useCallback((url: string) => api.linkPreview(url), []); @@ -324,7 +261,6 @@ export function AppProvider({ children }: { children: ReactNode }) { lastPublish, refresh, createProfile, - importProfile, selectProfile, publishNote, recordPublishFailure, @@ -342,18 +278,10 @@ export function AppProvider({ children }: { children: ReactNode }) { unlockVault, lockVault, removeVaultPassword, - exportSecretKey, + revealSecretKey, pickImages, uploadImage, linkPreview, - signerModeGet, - signerModeSet, - embeddedSignerStatus, - embeddedSignerApprove, - nip46Connect, - nip46Disconnect, - nip46Status, - nip46Approve, signerConnect, signerDisconnect, signerStatus, @@ -374,7 +302,6 @@ export function AppProvider({ children }: { children: ReactNode }) { lastPublish, refresh, createProfile, - importProfile, selectProfile, publishProfileMetadata, setProfilePicture, @@ -399,18 +326,10 @@ export function AppProvider({ children }: { children: ReactNode }) { unlockVault, lockVault, removeVaultPassword, - exportSecretKey, + revealSecretKey, 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 4294cb6..5ecf371 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -709,7 +709,7 @@ a { } .page-subtitle { - margin: 6px 0 0; + margin: 4px 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; - /* The logo PNG now carries a real alpha channel: no tile background, - border, or mask — the artwork composites directly on the sidebar. */ + background: #fff; overflow: hidden; } .sidebar-logo img { width: 100%; height: 100%; - object-fit: contain; + object-fit: cover; display: block; } -/* The artwork is black ink; flip it in dark themes so it stays visible on - dark sidebars. */ +/* 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. */ html[data-theme='dark'] .sidebar-logo img, html[data-theme='neon'] .sidebar-logo img, html[data-theme='glass'] .sidebar-logo img { @@ -1408,9 +1408,16 @@ 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; @@ -1465,7 +1472,7 @@ select { padding: 0; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; } .home-profile-row { @@ -1473,28 +1480,19 @@ select { align-items: center; gap: 14px; padding: 8px; - border-radius: var(--radius-sm); + border-radius: 8px; border: 1px solid transparent; } .home-profile-row.is-active { - background: var(--surface-2); - border-color: var(--border); + background: var(--token-item-bg, rgba(0, 0, 0, 0.04)); + border-color: var(--token-border, rgba(0, 0, 0, 0.12)); } .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; @@ -1513,17 +1511,7 @@ select { } .home-add-profile { - 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); + margin-top: 14px; } .relay-status-list { @@ -1587,18 +1575,6 @@ 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; @@ -1620,45 +1596,16 @@ select { text-align: left; } -.first-run-guide h2 { - margin-bottom: 4px; -} - .first-run-guide ol { margin: 12px 0 0; - padding: 0; - list-style: none; + padding-left: 20px; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; } -.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; +.first-run-guide p { + margin: 2px 0 0; color: var(--text-muted); font-size: 14px; } @@ -2213,16 +2160,6 @@ 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; @@ -2323,320 +2260,3 @@ 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 deleted file mode 100644 index cfbb87b..0000000 --- a/frontend/src/test/ExportSecretKey.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -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 9eec91b..9d56d97 100644 --- a/frontend/src/test/HomeScreen.test.tsx +++ b/frontend/src/test/HomeScreen.test.tsx @@ -23,12 +23,10 @@ describe('HomeScreen', () => { const { onNavigate } = renderHome(backend); renderWithApp(); - // 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(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(screen.getByText(/npub1alice\.\.\./)).toBeInTheDocument(); - const compose = screen.getByRole('button', { name: 'Compose' }); + const compose = screen.getByRole('button', { name: /Compose note/i }); await userEvent.setup().click(compose); expect(onNavigate).toHaveBeenCalledWith('compose'); }); @@ -38,11 +36,7 @@ describe('HomeScreen', () => { renderHome(backend); renderWithApp(); - // 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; + const aliceRow = (await screen.findByText('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 new file mode 100644 index 0000000..93337fa --- /dev/null +++ b/frontend/src/test/ShowSecretKey.test.tsx @@ -0,0 +1,87 @@ +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 45345c6..ea9ef27 100644 --- a/frontend/src/test/SignerScreen.test.tsx +++ b/frontend/src/test/SignerScreen.test.tsx @@ -48,47 +48,6 @@ 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); @@ -121,7 +80,6 @@ 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”' }, @@ -157,7 +115,6 @@ 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 f616efc..21b89ee 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -4,7 +4,6 @@ import type { ProfileSummary, RelayTestResult, Settings, - SignerMode, SignerStatus, } from '../lib/types'; @@ -44,8 +43,6 @@ export function makeState(overrides?: Partial): AppState { active_profile: alice, profiles: [alice, bob], settings, - last_publish: null, - signer_mode: 'embedded' as SignerMode, ...overrides, }; } @@ -74,15 +71,7 @@ export function makeRelayTest(url: string, overrides?: Partial) } export function makeSignerStatus(overrides?: Partial): SignerStatus { - return { - phase: 'stopped', - peer: null, - relays: [], - connectedRelays: [], - error: null, - pending: [], - ...overrides, - }; + return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides }; } /** @@ -106,7 +95,7 @@ export interface ApiMock { unlockVault: ReturnType; lockVault: ReturnType; removeVaultPassword: ReturnType; - exportSecretKey: ReturnType; + revealSecretKey: ReturnType; pickImages: ReturnType; uploadImage: ReturnType; linkPreview: ReturnType; @@ -213,7 +202,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock { encrypted_storage: false, vault_locked: false, })), - exportSecretKey: vi.fn(async (npub: string) => ({ + revealSecretKey: vi.fn(async (npub: string) => ({ hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64), nsec: `nsec1${npub.slice(5)}`, })), @@ -236,7 +225,6 @@ 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 f4f2551..ccef5e5 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -324,7 +324,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend { phase: 'connected', peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', relays: ['wss://relay.damus.io'], - connectedRelays: ['wss://relay.damus.io'], error: null, pending: [], }; @@ -437,48 +436,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return next; } - case 'export_secret_key': { + case 'reveal_secret_key': { + if (state.encrypted_storage && state.vault_locked) { + throw Object.assign( + new Error('Your vault is locked. Enter your password to unlock it.'), + { code: 'vault_locked' }, + ); + } 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('That profile is not stored on this computer.'), - { code: 'profile_not_found' }, - ); - } - - // 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' }, - ); + if (!state.profiles.some((p) => p.npub === npub)) { + throw new Error('That profile is not stored on this computer.'); } 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 deleted file mode 100644 index 90d62c8..0000000 --- a/frontend/src/test/publications.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -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 b415c36..53ea87c 100644 --- a/frontend/src/test/publishFlow.test.tsx +++ b/frontend/src/test/publishFlow.test.tsx @@ -1,31 +1,19 @@ -import { render, screen, within } from '@testing-library/react'; +import { render, screen } 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' }); - const main = screen.getByRole('main'); - await user.click(within(main).getByRole('button', { name: 'Compose' })); + await user.click(screen.getByRole('button', { name: /Compose note/i })); await screen.findByRole('heading', { name: 'Compose' }); await user.type(screen.getByLabelText('Note content'), 'Hello from the flow test'); @@ -35,7 +23,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.getByText(/Hello from the flow test/)).toBeInTheDocument(); + expect(await screen.findByText('Published')).toBeInTheDocument(); + expect(screen.getByTitle(backend.publishReport.event_id)).toBeInTheDocument(); }); }); diff --git a/src/app.rs b/src/app.rs index b6f9372..88b569a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,13 @@ 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, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto, -}; +use crate::vault::{self, KdfParams, StoredProfile, Vault, VaultCrypto}; /// Minimum password length accepted when encrypting the vault. pub const MIN_PASSWORD_LEN: usize = 8; @@ -24,29 +20,8 @@ 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 { @@ -63,35 +38,16 @@ 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: vault::load_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(), }) } @@ -138,83 +94,6 @@ 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 { @@ -236,7 +115,6 @@ 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 @@ -362,8 +240,6 @@ 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, } } } @@ -426,12 +302,6 @@ 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 deleted file mode 100644 index 1e7512f..0000000 --- a/src/audit.rs +++ /dev/null @@ -1,476 +0,0 @@ -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/errors.rs b/src/errors.rs index 1736444..effad16 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -42,20 +42,6 @@ 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. @@ -205,65 +191,6 @@ 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 cfa1bed..f5554d9 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -12,11 +12,8 @@ use crate::profiles; use crate::publish; use crate::relays; use crate::settings::Theme; -use crate::signer::embedded::EmbeddedSigner; -use crate::signer::nip46_client::Nip46ClientSigner; -use crate::signer::Signer as SignerTrait; +use crate::signer::Signer; 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); @@ -40,10 +37,6 @@ pub enum Request { CreateProfile { label: String, }, - ImportProfile { - label: String, - secret: String, - }, SelectProfile { npub: String, }, @@ -132,58 +125,20 @@ 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, }, - /// ===== 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). + /// Start the NIP-46 remote signer for a `nostrconnect://` link. SignerConnect { uri: String, }, - /// Stop the NIP-46 remote signer (bunker mode). + /// Stop the NIP-46 remote signer. SignerDisconnect, - /// Report the remote signer's current status (bunker mode). + /// Report the remote signer's current status. SignerStatus, - /// Approve or reject a NIP-46 request that is waiting for a decision (bunker mode). + /// Approve or reject a NIP-46 request that is waiting for a decision. SignerApprove { /// The internal id of the pending request, as reported by /// `SignerStatus.pending`. @@ -237,6 +192,7 @@ 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(); @@ -266,9 +222,10 @@ 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, envelope.request).await; + let reply = handle(task_app, task_signer, envelope.request).await; let _ = write_line( &task_stdout, ReplyEnvelope { @@ -307,8 +264,12 @@ async fn write_line( Ok(()) } -async fn handle(app: Arc>, request: Request) -> Reply { - let result = run(&app, request).await; +async fn handle( + app: Arc>, + signer: Arc, + request: Request, +) -> Reply { + let result = run(&app, &signer, request).await; match result { Ok(value) => Reply::Ok { data: value }, Err(err) => Reply::Error { @@ -331,167 +292,43 @@ fn error_code(err: &AppError) -> String { .unwrap_or_else(|_| "error".to_string()) } -/// Main request dispatcher. -async fn run(app: &Arc>, request: Request) -> Result { +/// 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 { 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 } => { - 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.", - )) + signer.connect(app.clone(), &uri)?; + Ok(json!(signer.status())) } Request::SignerDisconnect => { - 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")) + signer.disconnect(); + Ok(json!(signer.status())) } + Request::SignerStatus => Ok(json!(signer.status())), Request::SignerApprove { id, approved } => { - 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")) + signer.approve(&id, approved)?; + Ok(json!(signer.status())) } - - // 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)) } @@ -502,10 +339,14 @@ async fn run(app: &Arc>, request: Request) -> Result { 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() { @@ -531,8 +372,6 @@ async fn run(app: &Arc>, request: Request) -> Result { let mut guard = app.lock().await; run_with_app(&mut guard, other).await @@ -552,54 +391,12 @@ 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())) } @@ -649,17 +446,7 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { @@ -698,32 +485,11 @@ 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())) } @@ -734,33 +500,7 @@ 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)) } @@ -799,11 +539,13 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { - app.undo_delete()?; + let restored = app.undo_delete()?; app.save_vault()?; Ok(json!(app.state_view())) } - _ => Err(AppError::internal("Unexpected request.")), + // 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.")), } } diff --git a/src/lib.rs b/src/lib.rs index 45c78f4..7ca39ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,4 @@ 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 c578b63..920afe9 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,7 +682,6 @@ 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 ec6ad14..77da462 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -75,7 +75,6 @@ pub fn create_profile( created_at, picture: None, nip05: None, - signer_mode: crate::vault::SignerMode::Embedded, }; let is_active = vault.active_profile.is_none(); @@ -158,7 +157,6 @@ 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); @@ -249,6 +247,7 @@ pub fn set_profile_picture( stored.picture.clone(), stored.nip05.clone(), ); + drop(stored); let summary = ProfileSummary { label, npub, @@ -451,18 +450,6 @@ 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 @@ -781,7 +768,6 @@ mod tests { created_at: 1, picture: None, nip05: None, - signer_mode: crate::vault::SignerMode::Embedded, }); vault.profiles.push(StoredProfile { label: "Bob".to_string(), @@ -790,7 +776,6 @@ 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 de3fbad..76196b3 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -2,14 +2,13 @@ use std::collections::HashSet; use std::time::Duration; use nostr_sdk::prelude::*; -use serde::{Deserialize, Serialize}; +use serde::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 @@ -17,7 +16,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, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct RelayFailure { pub url: String, /// Concise, user-facing reason. @@ -57,8 +56,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 signing = Signing::Local(Keys::new(secret_key)); - publish_with_keys(settings, content, &signing).await + let keys = Keys::new(secret_key); + publish_with_keys(settings, content, &keys).await } /// Publish a text note as a specific profile (used by the CLI). @@ -74,8 +73,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 signing = Signing::Local(Keys::new(secret_key)); - publish_with_keys(settings, content, &signing).await + let keys = Keys::new(secret_key); + publish_with_keys(settings, content, &keys).await } /// Reject empty notes before any key or network work happens. @@ -152,7 +151,7 @@ fn image_tags(content: &str) -> Vec { async fn publish_with_keys( settings: &Settings, content: &str, - signing: &Signing, + keys: &Keys, ) -> Result { let content = content.trim(); if content.is_empty() { @@ -164,43 +163,21 @@ async fn publish_with_keys( return Err(AppError::no_enabled_relays()); } - // 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. + // Sign locally before touching the network so a signing failure is + // reported as such rather than as a network error. let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content)); - let unsigned = builder + let event = builder .finalize_async(keys) .await .map_err(|e| AppError::sign_failed(format!("{e}")))?; - // 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 + let event_id = event .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, &signed, "note").await; + let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await; if succeeded.is_empty() { return Err(AppError::publish_failed(failed)); diff --git a/src/bunker.rs b/src/signer.rs similarity index 90% rename from src/bunker.rs rename to src/signer.rs index 307f69a..56fa85e 100644 --- a/src/bunker.rs +++ b/src/signer.rs @@ -73,10 +73,6 @@ 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. @@ -93,7 +89,6 @@ 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 @@ -123,7 +118,6 @@ impl Signer { phase: SignerPhase::Stopped, peer: None, relays: Vec::new(), - connected_relays: Vec::new(), error: None, task: None, pending: HashMap::new(), @@ -148,7 +142,6 @@ 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, } @@ -164,7 +157,6 @@ impl Signer { inner.phase = SignerPhase::Stopped; inner.peer = None; inner.relays.clear(); - inner.connected_relays.clear(); inner.error = None; inner.pending.clear(); } @@ -267,7 +259,6 @@ 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; } @@ -281,7 +272,6 @@ impl Signer { inner.phase = SignerPhase::Stopped; inner.error = Some(message.into()); inner.task = None; - inner.connected_relays.clear(); inner.pending.clear(); } @@ -608,26 +598,6 @@ 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) { @@ -676,33 +646,6 @@ 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 @@ -1120,63 +1063,6 @@ 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/signer/backend.rs b/src/signer/backend.rs deleted file mode 100644 index 2700c8c..0000000 --- a/src/signer/backend.rs +++ /dev/null @@ -1,509 +0,0 @@ -//! 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 deleted file mode 100644 index d947cc6..0000000 --- a/src/signer/embedded.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! 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 deleted file mode 100644 index 47fc612..0000000 --- a/src/signer/mod.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! 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 deleted file mode 100644 index 1319443..0000000 --- a/src/signer/nip46_client.rs +++ /dev/null @@ -1,1063 +0,0 @@ -//! 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 deleted file mode 100644 index 6cd1bd3..0000000 --- a/src/signer/permissions.rs +++ /dev/null @@ -1,659 +0,0 @@ -//! 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 deleted file mode 100644 index 26c6672..0000000 --- a/src/signer/types.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! 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 24d0702..e4b078c 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -9,33 +9,15 @@ 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 = 3; +pub const VAULT_VERSION: u32 = 2; /// 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. /// @@ -62,10 +44,6 @@ 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 @@ -106,39 +84,6 @@ 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 { @@ -150,8 +95,6 @@ impl Vault { active_profile: None, crypto: None, profiles: Vec::new(), - nip46_connections: Vec::new(), - connection_secrets: Vec::new(), } } @@ -237,41 +180,6 @@ 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. @@ -342,124 +250,12 @@ 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> { @@ -667,7 +463,6 @@ mod tests { created_at: 1_700_000_000, picture: None, nip05: None, - signer_mode: SignerMode::Embedded, } } @@ -822,189 +617,4 @@ 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()); - } }