Compare commits

..

No commits in common. "1d5940fb820f51f37e6d200d8a48f4edf1fc62d4" and "2604cf9d1e3c50d66dde60051d9f5098309fa4e2" have entirely different histories.

53 changed files with 555 additions and 8772 deletions

View file

@ -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
(`<tmp>/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).

View file

@ -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<AppState>` 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<ConnectionSecret>` store (encrypted under the vault
key when password-protected, plaintext when the vault has no password — exactly
mirroring how profile secrets are handled). Three helpers:
- `store_connection_secret(vault, key, ref_, secret)` — upserts by `VaultRef`;
encrypts when the vault is password-protected, else stores plaintext. A locked
encrypted vault fails closed (`vault_locked`) rather than silently writing a
plaintext secret that would not match once unlocked. Replacing an existing
`VaultRef` never leaves a stale secret behind.
- `resolve_connection_secret(vault, key, ref_)` — decrypts (or returns plaintext)
for a `VaultRef`; `Ok(None)` when no secret is stored, `Err(vault_locked)` when
the vault is encrypted but locked. On success the plaintext is `Zeroizing`
(shredded on scope exit).
- `delete_connection_secret(vault, ref_)` — removes an entry (on disconnect).
- **`src/signer/types.rs`** — the inline `secret: Option<String>` field is **removed**
from `Nip46Connection`. Legacy vaults that still carry an inline `secret`
deserialize fine (serde ignores the absent field) and the dead secret is dropped on
the next save.
- **`src/signer/backend.rs`** — `VaultRef::from_connection(&Nip46Connection)` is the
canonical way a `SigningBackend::Remote` (and the secret store) is keyed by a
connection; `profile_npub` is now `Option<String>` (a connection may be created with
no local profile active).
- **`src/signer/nip46_client.rs`** — on `connect`, the parsed nostrconnect secret is
written to the vault store (via `VaultRef::from_connection`) instead of being kept in
memory (`Nip46Inner.connect_secret` removed). On `disconnect` the stored secret is
dropped. In `run_sign_task`/`send_connect`, the connect secret is now resolved
**on-demand from the vault** (the single source of truth) rather than read from an
in-memory copy — a locked vault refuses the connect instead of sending it without the
secret.
- **`src/publish.rs`** — `publish_with_keys` now takes `&Signing` and signs through the
`Signing` trait (routes to `Keys::sign_event` for `Local`, or the remote signer for
`External`), instead of calling `Keys::sign_event` directly. `publish_active` /
`publish_as` wrap their keys in `Signing::Local`. (External signing in the publish
path is not wired end-to-end yet — it returns a clear "not yet supported" error —
but the routing pattern is in place for the IPC reroute.)
- **`src/signer/permissions.rs`** — test fixtures updated for the removed inline
`secret` field.
## 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 <nostrconnect://...>`.
- 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<SigningError>`. Also wire end-to-end
external (remote) signing in the publish path (`publish_with_keys` currently
returns "not yet supported" for `Signing::External`).
Prior state that motivated the API: `src/signer/mod.rs` already had a `Signer` trait
and `Signing` enum (`Local(Keys)` / `External{signer, profile_pubkey}`); `SignerMode`
(`Embedded`/`Nip46Bunker`/`Nip46Client`) lives on `StoredProfile` (per-profile) *and*
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.

978
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,3 @@ base64 = "0.22"
getrandom = "0.2"
zeroize = "1"
rpassword = "7"
sha2 = "0.10"
async-trait = "0.1"
keyring = "4.2"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

@ -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<string, string> {
const env: Record<string, string> = { 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<string, string>): 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<string> = 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<string> = 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<typeof setTimeout> | 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();
}

View file

@ -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",

View file

@ -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"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

@ -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' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />}
{screen === 'signer' && <SignerScreen />}
{screen === 'signer-mode' && <SignerModeScreen />}
{screen === 'settings' && <SettingsScreen />}
</main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

@ -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<Phase>('form');
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
const [password, setPassword] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null);
const passwordRef = useRef<HTMLInputElement>(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 (
<Modal open={open} title={title} onClose={handleClose}>
{phase === 'form' && (
<form onSubmit={onSubmit} noValidate>
<Alert tone="warning" title="This action is logged">
Exporting a secret key creates an audit entry. The key itself is never stored in logs.
</Alert>
<div className="field">
<label htmlFor="export-secret-password">Vault password</label>
<input
ref={passwordRef}
id="export-secret-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
disabled={busy}
aria-describedby={error ? 'export-password-error' : undefined}
aria-invalid={error ? true : undefined}
/>
{error && <ErrorText id="export-password-error">{error}</ErrorText>}
</div>
<div className="field">
<label htmlFor="export-secret-reason">Reason for export</label>
<input
id="export-secret-reason"
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. backup, migration, device transfer"
disabled={busy}
/>
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={handleClose} disabled={busy}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
{busy ? 'Exporting…' : 'Export'}
</Button>
</div>
</form>
)}
{phase === 'error' && fatal && (
<div>
<Alert tone="error" title="Could not export the secret key" details={fatal.details}>
{fatal.message}
</Alert>
<div className="modal-actions">
<Button variant="secondary" onClick={handleClose}>
Close
</Button>
</div>
</div>
)}
{phase === 'revealed' && revealed && (
<div>
<Alert tone="error" title="Keep this key safe">
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.
</Alert>
<div className="path-row">
<div>
<span className="field-label">Private key (hex)</span>
<code className="mono path-value">{revealed.hex}</code>
</div>
<CopyButton text={revealed.hex} label="hex key" />
</div>
<div className="path-row">
<div>
<span className="field-label">Private key (nsec)</span>
<code className="mono path-value">{revealed.nsec}</code>
</div>
<CopyButton text={revealed.nsec} label="nsec key" />
</div>
<p className="hint">
The <code>nsec1…</code> form is what most Nostr wallets and clients import. It encodes
exactly the same key as the hex form above.
</p>
<div className="modal-actions">
<Button variant="secondary" onClick={handleClose}>
Done
</Button>
</div>
</div>
)}
</Modal>
);
}

View file

@ -16,8 +16,7 @@ export type IconName =
| 'shield'
| 'publish'
| 'external'
| 'key'
| 'server';
| 'key';
const PATHS: Record<IconName, ReactNode> = {
home: (
@ -102,12 +101,6 @@ const PATHS: Record<IconName, ReactNode> = {
<path d="M18 6l2 2" />
</>
),
server: (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 9h6M9 12h6M9 15h6" />
</>
),
};
interface IconProps {

View file

@ -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<Phase>('loading');
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<Modal open={open} title={title} onClose={onClose}>
{phase === 'loading' && <Spinner label="Revealing secret key…" />}
{phase === 'unlock' && (
<form onSubmit={onUnlock} noValidate>
<Alert tone="warning" title="Vault is locked">
This profile's keys are password-protected. Enter the vault password to reveal the
secret key. The password itself is never saved.
</Alert>
<div className="field">
<label htmlFor="show-secret-password">Vault password</label>
<input
ref={inputRef}
id="show-secret-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
autoFocus
aria-describedby={error ? unlockErrorId : undefined}
aria-invalid={error ? true : undefined}
disabled={busy}
/>
{error && <ErrorText id={unlockErrorId}>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
{busy ? 'Unlocking…' : 'Unlock'}
</Button>
</div>
</form>
)}
{phase === 'error' && fatal && (
<div>
<Alert tone="error" title="Could not reveal the secret key" details={fatal.details}>
{fatal.message}
</Alert>
<div className="modal-actions">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</div>
</div>
)}
{phase === 'revealed' && revealed && (
<div>
<Alert tone="error" title="Keep this key safe">
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.
</Alert>
<div className="path-row">
<div>
<span className="field-label">Private key (hex)</span>
<code className="mono path-value">{revealed.hex}</code>
</div>
<CopyButton text={revealed.hex} label="hex key" />
</div>
<div className="path-row">
<div>
<span className="field-label">Private key (nsec)</span>
<code className="mono path-value">{revealed.nsec}</code>
</div>
<CopyButton text={revealed.nsec} label="nsec key" />
</div>
<p className="hint">
The <code>nsec1…</code> form is what most Nostr wallets and clients import. It encodes
exactly the same key as the hex form above.
</p>
<div className="modal-actions">
<Button variant="secondary" onClick={onClose}>
Done
</Button>
</div>
</div>
)}
</Modal>
);
}

View file

@ -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' },
];

View file

@ -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<AppState>('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<AppState>('select_profile', { npub }),
publishProfileMetadata: (npub: string) =>
call<MetadataPublishReport>('publish_profile_metadata', { npub }),
@ -101,29 +96,10 @@ export const api = {
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
lockVault: () => call<AppState>('lock_vault'),
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
exportSecretKey: (npub: string, password: string, reason: string) =>
call<RevealedKey>('export_secret_key', { npub, password, reason }),
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
// Signer mode management
signerModeGet: () => call<{ mode: SignerMode }>('signer_mode_get'),
signerModeSet: (mode: SignerMode) => call<AppState>('signer_mode_set', { mode }),
// Embedded signer
embeddedSignerStatus: () => call<EmbeddedSignerStatus>('embedded_signer_status'),
embeddedSignerApprove: (index: number, approved: boolean) =>
call<EmbeddedSignerStatus>('embedded_signer_approve', { index, approved }),
// NIP-46 client signer
nip46Connect: (uri: string, label: string) =>
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'),

View file

@ -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<Screen, string> = {
home: 'Home',
@ -7,7 +6,6 @@ export const SCREEN_TITLES: Record<Screen, string> = {
profiles: 'Profiles',
compose: 'Compose',
relays: 'Relays',
signer: 'Signer (Bunker)',
'signer-mode': 'Signer Mode',
signer: 'Signer',
settings: 'Settings',
};

View file

@ -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<ProfilePublication[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}

View file

@ -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<ArrayBufferLike>;
publicKey: Uint8Array<ArrayBufferLike>;
}
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<string, { pubkey: string; relays: string[] }>;
};
/** Client mode (this app connects to external signer like Amber) */
client: {
isConnected: boolean;
signerPubkey: string | null;
relays: string[];
pendingRequests: Map<string, PendingSignRequest>;
};
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<StateListener> = 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<SignerState> {
return Object.freeze({ ...this.state });
}
/** Import a keypair from nsec or generate new one */
async importKeypair(nsecOrPrivateKey?: string): Promise<Keypair> {
let pk: Uint8Array<ArrayBufferLike>;
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<void> {
this.state.isVaultUnlocked = unlocked;
if (!unlocked) {
await this.stopAll();
}
this.notify();
}
/** Switch signer mode with full validation */
async setMode(mode: SignerMode): Promise<void> {
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<void> {
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<void> {
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<NostrConnectURI> {
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<void> {
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<void> {
// Simplified - real impl would use websocket + NIP-44
await new Promise<void>((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<ExternalSignerConnection> {
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<void> {
// 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<string> {
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<string> {
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<string>((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<void> {
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<void> {
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<string> {
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<SignerState> {
const manager = useSignerManager();
return manager.getState();
}

View file

@ -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. */

View file

@ -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<string | null>(null);
const onSelect = async (npub: string) => {
@ -75,7 +72,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</div>
<Button variant="primary" onClick={() => onNavigate('compose')}>
<Icon name="edit" size={18} />
Compose
Compose note
</Button>
</header>
@ -94,15 +91,11 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</Button>
</header>
<div className="card-body">
<ul className="home-profile-list" role="listbox" aria-label="Profiles">
<ul className="home-profile-list">
{state?.profiles.map((profile) => (
<li
key={profile.npub}
className={`home-profile-row${profile.is_active ? ' is-active' : ''}`}
role={profile.is_active ? undefined : 'option'}
aria-selected={profile.is_active}
tabIndex={profile.is_active ? undefined : 0}
aria-label={`${profile.label}${profile.is_active ? ' (active)' : ''} — select profile`}
onClick={
profile.is_active
? undefined
@ -113,19 +106,6 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
void onSelect(profile.npub);
}
}
onKeyDown={
profile.is_active
? undefined
: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if ((event.target as HTMLElement).closest('button, a, input')) {
return;
}
void onSelect(profile.npub);
}
}
}
>
<Avatar
npub={profile.npub}
@ -185,10 +165,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</header>
<div className="card-body">
<PublicationResult
publications={publications}
fullyPublished={fullyPublished}
loading={loading}
error={error}
lastPublish={lastPublish}
onNavigateCompose={() => onNavigate('compose')}
/>
</div>
@ -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<typeof useApp>['lastPublish'];
onNavigateCompose: () => void;
}) {
if (loading) {
return <p className="muted">Loading publications…</p>;
if (!lastPublish) {
return (
<p className="muted">
You haven't published anything yet.{' '}
<button type="button" className="linklike" onClick={onNavigateCompose}>
Compose your first note
</button>
.
</p>
);
}
if (error) {
if (lastPublish.error) {
return (
<Alert tone="error" title="Could not load publications">
{error}
<Alert tone="error" title="Publication failed" details={lastPublish.details}>
{lastPublish.error}
</Alert>
);
}
if (publications.length === 0) {
return (
<div className="home-publish-empty">
<p className="muted">You haven't published anything yet.</p>
<Button variant="secondary" size="sm" onClick={onNavigateCompose}>
<Icon name="edit" size={16} />
Compose your first note
</Button>
</div>
);
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 (
<div className="home-publish-empty">
<p className="muted">No fully published publications found.</p>
</div>
);
}
if (!newest) {
return (
<div className="home-publish-empty">
<p className="muted">No fully published publications found.</p>
{newestPartial && <PartialPublicationDetails item={newestPartial} />}
</div>
);
}
return (
<>
<div className="publish-result success">
<Badge tone="success">
<Icon name="check" size={14} /> Published
</Badge>
<span className="mono" title={newest.id}>
{shortenNpub(newest.id, true)}
<span className="mono" title={report.event_id}>
{shortenNpub(report.event_id, true)}
</span>
<CopyButton text={newest.id} label="event ID" />
<CopyButton text={report.event_id} label="event ID" />
</div>
{newest.content && <p className="publish-preview">{newest.content}</p>}
{newestPartial && newestPartial.id !== newest.id && (
<PartialPublicationDetails item={newestPartial} />
)}
</>
);
}
);
}
function PartialPublicationDetails({ item }: { item: ProfilePublication }) {
const totalRelays = item.relays.length;
return (
<details className="alert-details">
<summary>Relay results</summary>
<ul className="relay-result-list">
{item.relays.map((url) => (
<li key={url} className="ok">
<span className="mono">{url}</span> — accepted
</li>
))}
{totalRelays === 0 && <li className="bad">No relays returned this event.</li>}
</ul>
</details>
<div className="publish-result">
<Alert tone="warning" title="Partially published">
The note reached {report.succeeded.length} of{' '}
{report.succeeded.length + report.failed.length} enabled relays. Event ID:{' '}
<code className="mono" title={report.event_id}>
{shortenNpub(report.event_id, true)}
</code>
</Alert>
<CopyButton text={report.event_id} label="event ID" />
<details className="alert-details">
<summary>Relay results</summary>
<ul className="relay-result-list">
{report.succeeded.map((url) => (
<li key={url} className="ok">
<span className="mono">{url}</span> — accepted
</li>
))}
{report.failed.map((failure) => (
<li key={failure.url} className="bad">
<span className="mono">{failure.url}</span> — {failure.error}
</li>
))}
</ul>
</details>
</div>
);
}
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() {
<h2>How it works</h2>
<ol>
{steps.map((step) => (
<li key={step.title} className="first-run-step">
<span className="first-run-step-indicator" aria-hidden="true">
<Icon name={step.icon as any} size={16} />
</span>
<div className="first-run-step-content">
<strong>{step.title}</strong>
<p>{step.body}</p>
</div>
<li key={step.title}>
<strong>{step.title}</strong>
<p>{step.body}</p>
</li>
))}
</ol>

View file

@ -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 (
<Modal open={open} title="Add an existing account" onClose={onClose}>
<form onSubmit={submit} noValidate>
<p className="muted">
Import an account using its private key. The key stays in your local vault and is never
displayed.
</p>
<div className="field">
<label htmlFor="import-profile-secret">Private key</label>
<input
id="import-profile-secret"
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder="nsec1... or 64-character hex"
autoComplete="off"
/>
{error && <ErrorText>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>
{saving ? 'Adding…' : 'Add account'}
</Button>
</div>
</form>
</Modal>
);
return <Modal open={open} title="Add an existing account" onClose={onClose}>
<form onSubmit={submit} noValidate>
<p className="muted">Import an account using its private key. The key stays in your local vault and is never displayed.</p>
<div className="field">
<label htmlFor="import-profile-secret">Private key</label>
<input id="import-profile-secret" type="password" value={secret} onChange={(e) => setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" />
{error && <ErrorText>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>{saving ? 'Adding…' : 'Add account'}</Button>
</div>
</form>
</Modal>;
}

View file

@ -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={
<div className="modal-actions">
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create Profile
</Button>
<Button variant="secondary" onClick={() => setImportOpen(true)}>
Add existing account
</Button>
</div>
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create Profile
</Button>
}
/>
</div>
@ -165,9 +158,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
<Icon name="plus" size={18} />
Create Profile
</Button>
<Button variant="secondary" onClick={() => setImportOpen(true)}>
Add existing account
</Button>
</header>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
@ -347,7 +337,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
))}
</div>
<ExportSecretKeyModal
<ShowSecretKeyModal
open={revealTarget !== null}
profile={revealTarget}
onClose={() => setRevealTarget(null)}
@ -404,7 +394,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
onError={setError}
/>
)}
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
</div>
</div>
);

View file

@ -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<EmbeddedSignerStatus | null>(null);
const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null);
const [uri, setUri] = useState('');
const [label, setLabel] = useState('Remote Signer');
const [error, setError] = useState<string | null>(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 ? (
<Badge tone="success">NIP-46 Client (Connected)</Badge>
) : (
<Badge tone="neutral">NIP-46 Client (Most Secure)</Badge>
);
}
if (mode === 'nip46_bunker') {
return isNip46Active ? (
<Badge tone="success">NIP-46 Bunker (Running)</Badge>
) : (
<Badge tone="warning">NIP-46 Bunker (Moderate)</Badge>
);
}
return isEmbeddedActive ? (
<Badge tone="success">Embedded (Least Secure)</Badge>
) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>Embedded {vaultLocked ? '(Vault Locked)' : ''}</Badge>
);
};
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 (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<h1>Signer Mode</h1>
<p className="page-subtitle">
Choose how your keys are managed and where signing happens.
<br />
<span className="subtitle-hint">Ordered by security: most secure → least secure</span>
</p>
</header>
<section className="card">
<header className="card-header">
<h2>Key Status</h2>
</header>
<div className="card-body">
<div className="status-grid">
<div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}>
<span className="status-label">Keypair</span>
<span className="status-value">{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}</span>
</div>
<div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}>
<span className="status-label">Vault</span>
<span className="status-value">{vaultLocked ? 'Locked' : 'Unlocked / No password'}</span>
</div>
<div className="status-item">
<span className="status-label">Current Mode</span>
<span className="status-value">{mode}</span>
</div>
</div>
{!hasProfile && (
<Button variant="primary" onClick={() => void handleImportKey()} style={{ marginTop: 12 }}>
<Icon name="key" size={16} /> Import / Generate Key
</Button>
)}
{hasProfile && vaultLocked && (
<Button variant="secondary" onClick={() => void handleUnlockVault()} style={{ marginTop: 12 }}>
<Icon name="shield" size={16} /> Unlock Vault
</Button>
)}
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Current Mode</h2>
<div className="signer-badge">{modeBadge()}</div>
</header>
<div className="card-body">
<div className="mode-options">
{/* 1. Most Secure */}
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}>
<input
type="radio"
name="signer-mode"
value="nip46_client"
checked={mode === 'nip46_client'}
onChange={() => handleModeSwitch('nip46_client')}
disabled={loading}
/>
<span className="security-badge most-secure">Most Secure</span>
<div className="mode-option-content">
<h3>NIP-46 Client (Connect to External Signer)</h3>
<p className="security-desc">
<strong>Most Secure:</strong> Private key never touches this device. Connects to an
external signer (Amber, Nostr Connect, hardware wallet). Every signing request is
approved on the external device.
</p>
<ul className="mode-features">
<li>✓ Private key NEVER on this device</li>
<li>✓ Sign with hardware wallet / mobile app</li>
<li>✓ Every request approved externally</li>
<li>✓ Key cannot be extracted if this app is compromised</li>
</ul>
{mode === 'nip46_client' && isNip46Active && <span className="mode-badge active">Connected</span>}
</div>
</label>
{/* 2. Moderately Secure */}
<label className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}>
<input
type="radio"
name="signer-mode"
value="nip46_bunker"
checked={mode === 'nip46_bunker'}
onChange={() => handleModeSwitch('nip46_bunker')}
disabled={loading}
/>
<span className="security-badge moderate-secure">Moderately Secure</span>
<div className="mode-option-content">
<h3>NIP-46 Bunker (This App Signs)</h3>
<p className="security-desc">
<strong>Moderately Secure:</strong> This app acts as a signer for other clients. Key
stays in this app&apos;s encrypted vault; other clients connect via{' '}
<code>nostrconnect://</code>.
</p>
<ul className="mode-features">
<li>✓ Private key stays in encrypted vault</li>
<li>✓ Approve each request from client apps</li>
<li>✓ Works with Amber, Nostr Connect, etc.</li>
<li>⚠ Key in memory when vault unlocked</li>
</ul>
{mode === 'nip46_bunker' && isNip46Active && <span className="mode-badge active">Running</span>}
</div>
</label>
{/* 3. Least Secure */}
<label className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}>
<input
type="radio"
name="signer-mode"
value="embedded"
checked={mode === 'embedded'}
onChange={() => handleModeSwitch('embedded')}
disabled={loading}
/>
<span className="security-badge least-secure">Least Secure</span>
<div className="mode-option-content">
<h3>Embedded Signer (Local Keys)</h3>
<p className="security-desc">
<strong>Least Secure:</strong> Keys stored locally, signing on this device. Convenient
but key exists in memory when vault unlocked.
</p>
<ul className="mode-features">
<li>✓ Keys never leave this device</li>
<li>✓ Works offline</li>
<li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li>
<li>⚠ Vulnerable to device compromise</li>
</ul>
{mode === 'embedded' && isEmbeddedActive && <span className="mode-badge active">Active</span>}
</div>
</label>
</div>
{mode === 'nip46_bunker' && !canSwitchToBunker && (
<Alert tone="warning" title="Cannot enable bunker">
{hasProfile ? 'Unlock vault to enable bunker mode.' : 'No keypair found: Please import a key first.'}
</Alert>
)}
{mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
<Alert tone="warning" title="Vault locked">
Unlock vault to use embedded signer.
</Alert>
)}
{!hasProfile && mode !== 'nip46_client' && (
<Alert tone="warning" title="No keypair">
No keypair found: Please import a key first. (NIP-46 Client can be selected without a local key.)
</Alert>
)}
{error && <ErrorText>{error}</ErrorText>}
</div>
</section>
{/* Embedded pending */}
{mode === 'embedded' && (embeddedStatus?.pending?.length ?? 0) > 0 && (
<section className="card">
<header className="card-header">
<h2>Pending Approvals</h2>
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
</header>
<div className="card-body">
{(embeddedStatus!.pending).map((req, idx) => (
<div key={req.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono">{req.method}</code>
<p>{req.summary}</p>
{req.details?.is_sensitive && <span className="sensitive-badge">Sensitive</span>}
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
Approve
</Button>
<Button variant="danger" onClick={() => void handleEmbeddedApprove(idx, false)}>
Reject
</Button>
</div>
</div>
))}
</div>
</section>
)}
{/* NIP-46 Client config */}
{(mode === 'nip46_client' || mode === 'nip46_bunker') && (
<section className="card">
<header className="card-header">
<h2>{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}</h2>
</header>
<div className="card-body">
{isNip46Active && nip46StatusState ? (
<div className="signer-actions">
<p className="hint">
Connected to <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code> via{' '}
{(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays
</p>
{nip46StatusState.error && <Alert tone="error" title="Connection error">{nip46StatusState.error}</Alert>}
<Button variant="danger" onClick={() => void handleNip46Disconnect()}>
<Icon name="trash" size={16} /> Disconnect
</Button>
{(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && (
<div className="signer-pending">
<h3>Pending ({nip46StatusState!.pending_approvals!.length})</h3>
{(nip46StatusState!.pending_approvals ?? []).map((r) => (
<div key={r.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono">{r.method}</code>
<p>{r.summary}</p>
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}>
Approve
</Button>
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}>
Reject
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : (
<div>
<div className="field">
<input
type="text"
placeholder={mode === 'nip46_client' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'}
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<p className="hint">
{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.'}
</p>
</div>
<div className="field">
<label>Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Remote Signer" />
</div>
{error && <ErrorText>{error}</ErrorText>}
<div className="settings-inline">
<Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}>
<Icon name="key" size={16} /> Connect
</Button>
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
<Icon name="refresh" size={16} /> Refresh
</Button>
</div>
</div>
)}
</div>
</section>
)}
<section className="card">
<header className="card-header">
<h2>Security Notes</h2>
</header>
<div className="card-body">
<ul className="security-notes">
<li>
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External
signer (hardware wallet / Amber) holds key.
</li>
<li>
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve each
remote request.
</li>
<li>
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when unlocked.
</li>
</ul>
</div>
</section>
</div>
</div>
);
}

View file

@ -12,7 +12,6 @@ const EMPTY_STATUS: SignerStatus = {
phase: 'stopped',
peer: null,
relays: [],
connectedRelays: [],
error: null,
pending: [],
};
@ -145,25 +144,11 @@ export function SignerScreen() {
<dt>Relays</dt>
<dd>
{status.relays.length > 0 ? (
<>
{status.relays.map((relay) => {
const connected = status.connectedRelays.includes(relay);
return (
<span
key={relay}
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
title={connected ? 'Connected' : 'No connection yet'}
>
{relay}
</span>
);
})}
{status.phase === 'connecting' && status.connectedRelays.length === 0 && (
<span className="muted signer-relay-hint">
Waiting for a relay to answer…
</span>
)}
</>
status.relays.map((relay) => (
<span key={relay} className="mono signer-relay">
{relay}
</span>
))
) : (
<span className="muted">None</span>
)}

View file

@ -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<void>;
createProfile: (label: string) => Promise<ProfileSummary>;
importProfile: (label: string, secret: string) => Promise<ProfileSummary>;
selectProfile: (npub: string) => Promise<void>;
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
@ -67,22 +63,10 @@ interface AppContextValue {
unlockVault: (password: string) => Promise<AppState>;
lockVault: () => Promise<AppState>;
removeVaultPassword: (password: string) => Promise<AppState>;
exportSecretKey: (npub: string, password: string, reason: string) => Promise<RevealedKey>;
revealSecretKey: (npub: string) => Promise<RevealedKey>;
pickImages: () => Promise<PickedImage[]>;
uploadImage: (token: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>;
// Signer mode management
signerModeGet: () => Promise<{ mode: SignerMode }>;
signerModeSet: (mode: SignerMode) => Promise<AppState>;
// Embedded signer
embeddedSignerStatus: () => Promise<EmbeddedSignerStatus>;
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
// NIP-46 client signer
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
@ -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<ProfileSummary> => {
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,

View file

@ -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);
}

View file

@ -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<typeof userEvent.setup>) {
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
// 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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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();
});
});

View file

@ -23,12 +23,10 @@ describe('HomeScreen', () => {
const { onNavigate } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
// 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(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
// 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);

View file

@ -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<typeof userEvent.setup>) {
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openReveal(user);
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
expect(within(dialog).queryByLabelText('Vault password')).not.toBeInTheDocument();
});
});

View file

@ -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(<SignerScreen />);
// 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(<SignerScreen />);
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' }],
});

View file

@ -4,7 +4,6 @@ import type {
ProfileSummary,
RelayTestResult,
Settings,
SignerMode,
SignerStatus,
} from '../lib/types';
@ -44,8 +43,6 @@ export function makeState(overrides?: Partial<AppState>): 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<RelayTestResult>)
}
export function makeSignerStatus(overrides?: Partial<SignerStatus>): 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<typeof vi.fn>;
lockVault: ReturnType<typeof vi.fn>;
removeVaultPassword: ReturnType<typeof vi.fn>;
exportSecretKey: ReturnType<typeof vi.fn>;
revealSecretKey: ReturnType<typeof vi.fn>;
pickImages: ReturnType<typeof vi.fn>;
uploadImage: ReturnType<typeof vi.fn>;
linkPreview: ReturnType<typeof vi.fn>;
@ -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: [],
}),

View file

@ -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)}` };

View file

@ -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<FeedItem> & { id: string; relays: string[] }): FeedItem {
return {
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
author_npub: ALICE,
content: '',
created_at: 1700000000,
...overrides,
};
}
function renderHome(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
}
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);
});
});

View file

@ -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(<App />);
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();
});
});

View file

@ -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<VaultKey>,
/// Stack of deleted profiles for undo functionality.
pub undo_history: Vec<ProfileSummary>,
/// The most recent publish report, persisted across restarts.
pub last_publish: Option<StoredPublishReport>,
/// Active signer mode.
pub signer_mode: SignerMode,
/// Embedded signer instance.
pub embedded_signer: Option<EmbeddedSignerHandle>,
/// NIP-46 client signer instance.
pub nip46_signer: Option<Nip46ClientSignerHandle>,
/// NIP-46 bunker signer instance (legacy).
pub nip46_bunker_signer: Option<Nip46BunkerSignerHandle>,
/// Audit log for security-sensitive operations. May be absent in test environments.
pub audit_log: Option<AuditLog>,
}
/// Handle for the embedded signer (type-erased for App storage).
pub type EmbeddedSignerHandle = Arc<crate::signer::embedded::EmbeddedSigner>;
/// Handle for the NIP-46 client signer (type-erased for App storage).
pub type Nip46ClientSignerHandle = Arc<crate::signer::nip46_client::Nip46ClientSigner>;
/// Handle for the NIP-46 bunker signer (type-erased for App storage).
pub type Nip46BunkerSignerHandle = Arc<crate::bunker::Signer>;
/// 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<ProfileSummary>,
/// The most recent publish report, persisted across restarts.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_publish: Option<StoredPublishReport>,
/// Active signer mode.
pub signer_mode: SignerMode,
}
impl App {
/// Load the vault (migrating a legacy vault if needed) and settings.
pub fn load() -> Result<Self, AppError> {
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<profiles::RevealedKey, AppError> {
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<ProfileSummary, AppError> {
@ -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,
}
}

View file

@ -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<String>,
/// 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<String>,
}
impl AuditLog {
/// Open or create the audit log, returning the last hash for chaining.
pub fn open() -> Result<Self, AppError> {
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<String, AppError> {
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<String>,
) -> Result<AuditEntry, AppError> {
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<bool, AppError> {
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<String> = 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);
}
}

View file

@ -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 {

View file

@ -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<Mutex<App>>, request: Request) -> Reply<serde_json::Value> {
let result = run(&app, request).await;
async fn handle(
app: Arc<Mutex<App>>,
signer: Arc<Signer>,
request: Request,
) -> Reply<serde_json::Value> {
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<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
/// 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<Mutex<App>>,
signer: &Signer,
request: Request,
) -> Result<serde_json::Value, AppError> {
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<Mutex<App>>, request: Request) -> Result<serde_json::Valu
} => {
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<Mutex<App>>, request: Request) -> Result<serde_json::Valu
};
Ok(json!(items))
}
// Vault state requests (require lock)
other => {
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<serde_json::Val
let key = app.vault_key().copied();
let summary =
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
// Update active signer profile
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::ImportProfile { label, secret } => {
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<serde_json::Val
let report =
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
.await?;
let stored = crate::vault::StoredPublishReport {
event_id: report.event_id.clone(),
succeeded: report.succeeded.clone(),
failed: report.failed.clone(),
content: content.trim().to_string(),
};
if let Err(e) = crate::vault::save_last_publish(&stored) {
eprintln!("Could not save last publish report: {e}");
}
app.last_publish = Some(stored.clone());
Ok(json!(stored))
Ok(json!(report))
}
Request::RelayAdd { url } => {
@ -698,32 +485,11 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Request::UnlockVault { password } => {
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<serde_json::Val
}
Request::RevealSecretKey { npub } => {
// 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<serde_json::Val
Ok(json!(app.state_view()))
}
Request::UndoDelete => {
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.")),
}
}

View file

@ -1,6 +1,4 @@
pub mod app;
pub mod audit;
pub mod bunker;
pub mod crypto;
pub mod errors;
pub mod feed;

View file

@ -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<String, AppError> {
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() {

View file

@ -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
}

View file

@ -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<Tag> {
async fn publish_with_keys(
settings: &Settings,
content: &str,
signing: &Signing,
keys: &Keys,
) -> Result<PublishReport, AppError> {
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));

View file

@ -73,10 +73,6 @@ pub struct SignerStatus {
pub peer: Option<String>,
/// Relays used for the connection.
pub relays: Vec<String>,
/// 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<String>,
/// A user-facing error if the signer stopped because of one.
pub error: Option<String>,
/// Requests currently waiting for the user to approve or reject them.
@ -93,7 +89,6 @@ struct SignerInner {
phase: SignerPhase,
peer: Option<PublicKey>,
relays: Vec<String>,
connected_relays: Vec<String>,
error: Option<String>,
task: Option<tokio::task::JoinHandle<()>>,
/// 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<String, String> {
}
}
/// 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<String> {
let mut urls: Vec<String> = loop {
let map = client.relays().all().await;
let urls: Vec<String> = 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<tokio::sync::Mutex<App>>, uri: ConnectUri) {
@ -676,33 +646,6 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, 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::<Vec<_>>()
.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();

View file

@ -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<String>,
/// 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<String>, signer_pubkey: impl Into<String>) -> 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<String> {
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<SigningError> 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());
}
}

View file

@ -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<ApprovalResult>,
}
/// Embedded signer using keys from the local vault.
pub struct EmbeddedSigner {
app: Arc<Mutex<App>>,
active_npub: Arc<Mutex<Option<String>>>,
pending: Arc<Mutex<Vec<PendingApproval>>>,
}
impl EmbeddedSigner {
/// Create a new embedded signer bound to the app state.
pub fn new(app: Arc<Mutex<App>>) -> 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<String>) {
let mut guard = self.active_npub.lock().await;
*guard = npub;
}
/// Get the current active npub.
pub async fn active_npub(&self) -> Option<String> {
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<Keys, SigningError> {
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<crate::signer::types::PendingApproval> {
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::<String>();
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<PublicKey, SigningError> {
let keys = self.resolve_keys().await?;
Ok(keys.public_key())
}
async fn sign_event(&self, event: UnsignedEvent) -> Result<Event, SigningError> {
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,
})
}
}

View file

@ -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<PublicKey, SigningError>;
/// 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<PublicKey, SigningError> {
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<Event, SigningError>;
/// 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<permissions::Nip46Permissions> {
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<dyn Signer>,
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<PublicKey, SigningError> {
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<Event, SigningError> {
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)
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<u16>,
}
/// 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<Nip46Permission>,
}
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<Self, AppError> {
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<Nip46Permission, AppError> {
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();
}
}

View file

@ -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<u16>,
/// Destination relays for the signed event.
pub destination_relays: Vec<String>,
/// 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<String>,
/// The signer's public key (hex).
pub signer_pubkey: String,
/// Relays to use for the connection.
pub relays: Vec<String>,
/// 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<super::permissions::Nip46Permissions>,
/// 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<u64>,
/// 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<u64>,
}
/// Status of a NIP-46 connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip46Status {
pub connected: bool,
pub signer_pubkey: Option<String>,
pub relays: Vec<String>,
pub connected_relays: Vec<String>,
pub error: Option<String>,
pub pending_approvals: Vec<PendingApproval>,
}
/// 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,
}

View file

@ -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<String>,
/// 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<VaultCrypto>,
pub profiles: Vec<StoredProfile>,
/// Stored NIP-46 connections, keyed by the profile npub they belong to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nip46_connections: Vec<crate::signer::types::Nip46Connection>,
/// 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<ConnectionSecret>,
}
/// 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<String>,
pub failed: Vec<crate::publish::RelayFailure>,
#[serde(default)]
pub content: String,
}
/// Load the last publish report, returning `None` when absent or unreadable.
pub fn load_last_publish() -> Option<StoredPublishReport> {
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<Vault, AppError> {
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<Option<Zeroizing<String>>, 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());
}
}