Compare commits
No commits in common. "1d5940fb820f51f37e6d200d8a48f4edf1fc62d4" and "2604cf9d1e3c50d66dde60051d9f5098309fa4e2" have entirely different histories.
1d5940fb82
...
2604cf9d1e
|
|
@ -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).
|
||||
|
|
@ -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
|
|
@ -17,6 +17,3 @@ base64 = "0.22"
|
|||
getrandom = "0.2"
|
||||
zeroize = "1"
|
||||
rpassword = "7"
|
||||
sha2 = "0.10"
|
||||
async-trait = "0.1"
|
||||
keyring = "4.2"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
143
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 11 KiB |
|
|
@ -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)} />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.3 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
188
frontend/src/components/ShowSecretKeyModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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">
|
||||
{item.relays.map((url) => (
|
||||
{report.succeeded.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>}
|
||||
{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">
|
||||
<li key={step.title}>
|
||||
<strong>{step.title}</strong>
|
||||
<p>{step.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
|
|||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSecret('');
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setSecret(''); setError(null); setSaving(false);
|
||||
requestAnimationFrame(() => labelRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
|
@ -23,45 +21,23 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
|
|||
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}>
|
||||
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>
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
</Modal>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'}
|
||||
>
|
||||
status.relays.map((relay) => (
|
||||
<span key={relay} className="mono signer-relay">
|
||||
{relay}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{status.phase === 'connecting' && status.connectedRelays.length === 0 && (
|
||||
<span className="muted signer-relay-hint">
|
||||
Waiting for a relay to answer…
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
<span className="muted">None</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
87
frontend/src/test/ShowSecretKey.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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)}` };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
134
src/app.rs
|
|
@ -1,17 +1,13 @@
|
|||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::audit::AuditLog;
|
||||
use crate::crypto::{self, VaultKey};
|
||||
use crate::errors::AppError;
|
||||
use crate::profiles::{self, ProfileSummary};
|
||||
use crate::settings::Settings;
|
||||
use crate::vault::{
|
||||
self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto,
|
||||
};
|
||||
use crate::vault::{self, KdfParams, StoredProfile, Vault, VaultCrypto};
|
||||
|
||||
/// Minimum password length accepted when encrypting the vault.
|
||||
pub const MIN_PASSWORD_LEN: usize = 8;
|
||||
|
|
@ -24,29 +20,8 @@ pub struct App {
|
|||
unlock_key: Option<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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
476
src/audit.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
346
src/ipc.rs
|
|
@ -12,11 +12,8 @@ use crate::profiles;
|
|||
use crate::publish;
|
||||
use crate::relays;
|
||||
use crate::settings::Theme;
|
||||
use crate::signer::embedded::EmbeddedSigner;
|
||||
use crate::signer::nip46_client::Nip46ClientSigner;
|
||||
use crate::signer::Signer as SignerTrait;
|
||||
use crate::signer::Signer;
|
||||
use crate::updates;
|
||||
use crate::vault::SignerMode;
|
||||
|
||||
/// How long to wait for a relay connection test.
|
||||
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
|
@ -40,10 +37,6 @@ pub enum Request {
|
|||
CreateProfile {
|
||||
label: String,
|
||||
},
|
||||
ImportProfile {
|
||||
label: String,
|
||||
secret: String,
|
||||
},
|
||||
SelectProfile {
|
||||
npub: String,
|
||||
},
|
||||
|
|
@ -132,58 +125,20 @@ pub enum Request {
|
|||
RevealSecretKey {
|
||||
npub: String,
|
||||
},
|
||||
/// Export a profile's secret key with fresh re-authentication and audit logging.
|
||||
/// Always requires the vault passphrase, even if already unlocked.
|
||||
ExportSecretKey {
|
||||
npub: String,
|
||||
password: String,
|
||||
reason: String,
|
||||
},
|
||||
/// Sign a NIP-98 auth event for the active profile, for uploading media.
|
||||
UploadAuth {
|
||||
url: String,
|
||||
http_method: String,
|
||||
},
|
||||
/// ===== SIGNER MODE MANAGEMENT =====
|
||||
/// Get the current signer mode.
|
||||
SignerModeGet,
|
||||
/// Set the signer mode (embedded or nip46).
|
||||
SignerModeSet {
|
||||
mode: SignerMode,
|
||||
},
|
||||
/// ===== EMBEDDED SIGNER =====
|
||||
/// Get embedded signer status.
|
||||
EmbeddedSignerStatus,
|
||||
/// Approve/reject a pending embedded signer request.
|
||||
EmbeddedSignerApprove {
|
||||
index: usize,
|
||||
approved: bool,
|
||||
},
|
||||
/// ===== NIP-46 CLIENT SIGNER =====
|
||||
/// Connect to a NIP-46 signer using a nostrconnect:// URI.
|
||||
Nip46Connect {
|
||||
uri: String,
|
||||
label: String,
|
||||
},
|
||||
/// Disconnect from the NIP-46 signer.
|
||||
Nip46Disconnect,
|
||||
/// Get NIP-46 connection status.
|
||||
Nip46Status,
|
||||
/// Approve/reject a pending NIP-46 request.
|
||||
Nip46Approve {
|
||||
id: String,
|
||||
approved: bool,
|
||||
},
|
||||
/// ===== LEGACY NIP-46 BUNKER (server mode) =====
|
||||
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
|
||||
/// Start the NIP-46 remote signer for a `nostrconnect://` link.
|
||||
SignerConnect {
|
||||
uri: String,
|
||||
},
|
||||
/// Stop the NIP-46 remote signer (bunker mode).
|
||||
/// Stop the NIP-46 remote signer.
|
||||
SignerDisconnect,
|
||||
/// Report the remote signer's current status (bunker mode).
|
||||
/// Report the remote signer's current status.
|
||||
SignerStatus,
|
||||
/// Approve or reject a NIP-46 request that is waiting for a decision (bunker mode).
|
||||
/// Approve or reject a NIP-46 request that is waiting for a decision.
|
||||
SignerApprove {
|
||||
/// The internal id of the pending request, as reported by
|
||||
/// `SignerStatus.pending`.
|
||||
|
|
@ -237,6 +192,7 @@ pub async fn serve() -> Result<(), AppError> {
|
|||
// Shared state, so the NIP-46 signer's background task and the request loop
|
||||
// both see the same vault (including its unlock key) without racing writes.
|
||||
let app = Arc::new(Mutex::new(App::load()?));
|
||||
let signer = Arc::new(Signer::new());
|
||||
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
|
|
@ -266,9 +222,10 @@ pub async fn serve() -> Result<(), AppError> {
|
|||
};
|
||||
|
||||
let task_app = app.clone();
|
||||
let task_signer = signer.clone();
|
||||
let task_stdout = stdout.clone();
|
||||
tasks.spawn(async move {
|
||||
let reply = handle(task_app, envelope.request).await;
|
||||
let reply = handle(task_app, task_signer, envelope.request).await;
|
||||
let _ = write_line(
|
||||
&task_stdout,
|
||||
ReplyEnvelope {
|
||||
|
|
@ -307,8 +264,12 @@ async fn write_line(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle(app: Arc<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));
|
||||
signer.approve(&id, approved)?;
|
||||
Ok(json!(signer.status()))
|
||||
}
|
||||
}
|
||||
Err(AppError::config("Not in NIP-46 client mode"))
|
||||
}
|
||||
|
||||
// Network-only requests (no shared state lock)
|
||||
Request::RelayTest { url } => {
|
||||
// Pure network probe against the given URL; no shared state.
|
||||
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
|
||||
Ok(json!(result))
|
||||
}
|
||||
Request::UpdateCheck => {
|
||||
// Long-running package-manager scan; never touches shared state.
|
||||
let report = updates::check().await?;
|
||||
Ok(json!(report))
|
||||
}
|
||||
Request::UpdateApply => {
|
||||
// Installs updates on disk; a rebuild + restart picks them up.
|
||||
let report = updates::apply().await?;
|
||||
Ok(json!(report))
|
||||
}
|
||||
|
|
@ -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.")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
pub mod app;
|
||||
pub mod audit;
|
||||
pub mod bunker;
|
||||
pub mod crypto;
|
||||
pub mod errors;
|
||||
pub mod feed;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
392
src/vault.rs
|
|
@ -9,33 +9,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::errors::AppError;
|
||||
|
||||
/// Active signer mode per profile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SignerMode {
|
||||
Embedded,
|
||||
Nip46Bunker,
|
||||
Nip46Client,
|
||||
}
|
||||
|
||||
/// Serde default for `StoredProfile::signer_mode`: legacy profiles without
|
||||
/// the field are treated as local (embedded) signers.
|
||||
fn default_embedded() -> SignerMode {
|
||||
SignerMode::Embedded
|
||||
}
|
||||
|
||||
/// Current vault schema version.
|
||||
pub const VAULT_VERSION: u32 = 3;
|
||||
pub const VAULT_VERSION: u32 = 2;
|
||||
/// Filename of the profiles vault.
|
||||
pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
|
||||
/// Filename of the settings file.
|
||||
pub const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
/// Filename of the last publish report.
|
||||
pub const LAST_PUBLISH_FILE_NAME: &str = "last_publish.json";
|
||||
|
||||
/// A profile stored on disk.
|
||||
///
|
||||
|
|
@ -62,10 +44,6 @@ pub struct StoredProfile {
|
|||
/// part of kind 0 metadata so clients show a human handle.
|
||||
#[serde(default)]
|
||||
pub nip05: Option<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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||