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
|
## Where things are
|
||||||
- Project: `/home/avi/Projects/Keynctr`
|
- Project: `/home/avi/Projects/Keynctr`
|
||||||
- Branch: `master` @ **`510cb65`** ("feat(signer): store NIP-46 connection secrets in
|
- Git repo: `master` @ `3107508` ("fix: query imported metadata by relay").
|
||||||
the vault, keyed by VaultRef").
|
- Working tree: intended profile metadata fix is committed; unrelated UI/branding changes remain uncommitted and untracked.
|
||||||
- Working tree: **clean for tracked files.** No tracked file is modified or staged.
|
|
||||||
The only untracked entries are the pre-existing hygiene leftovers and the source
|
|
||||||
JPEG (all intentionally untracked — see "Still untracked"). Build artifacts
|
|
||||||
(`release/`, `dist/`) are gitignored.
|
|
||||||
|
|
||||||
## What was completed (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
|
## Commits added in this session (newest first)
|
||||||
nostrconnect `secret` is a credential, so it no longer lives inline on
|
- `3107508` fix: query imported metadata by relay
|
||||||
`Nip46Connection` (which can be serialized and shown to the UI). It is now stored in
|
- `da33652` fix: query imported metadata by public key
|
||||||
the vault's **encrypted `connection_secrets` store**, keyed by the opaque
|
- `bde35bc` fix: import existing profile metadata
|
||||||
`VaultRef` (profile npub + remote signer pubkey), encrypted under the vault key, and
|
- `d2d773a` fix: remove Stardust background dots
|
||||||
resolved **only at the vault boundary while the vault is unlocked** (fail-closed when
|
- `7605f51` fix: keep NIP-46 signer subscription open
|
||||||
locked). This is where the `vault_ref` constraint becomes load-bearing.
|
- `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
|
## Verification commands run
|
||||||
`Vault.connection_secrets: Vec<ConnectionSecret>` store (encrypted under the vault
|
- 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`.
|
||||||
key when password-protected, plaintext when the vault has no password — exactly
|
- 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.
|
||||||
mirroring how profile secrets are handled). Three helpers:
|
- Packaging: `npx electron-builder --linux AppImage deb` passed; AppImage and Debian files verified with `file`.
|
||||||
- `store_connection_secret(vault, key, ref_, secret)` — upserts by `VaultRef`;
|
- 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.
|
||||||
encrypts when the vault is password-protected, else stores plaintext. A locked
|
- Frontend build no longer reports the Cosmic font `@import` ordering warning; standard Vite/ESM and ESLint module warnings remain.
|
||||||
encrypted vault fails closed (`vault_locked`) rather than silently writing a
|
- Wayland `--ozone-platform` / `has no handler` messages on `electron:build` are harmless.
|
||||||
plaintext secret that would not match once unlocked. Replacing an existing
|
- NIP-46 fix: use a persistent subscription instead of the auto-closing `stream_events` helper, so clients can send requests after EOSE.
|
||||||
`VaultRef` never leaves a stale secret behind.
|
- 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.
|
||||||
- `resolve_connection_secret(vault, key, ref_)` — decrypts (or returns plaintext)
|
- 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.
|
||||||
for a `VaultRef`; `Ok(None)` when no secret is stored, `Err(vault_locked)` when
|
- 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.
|
||||||
the vault is encrypted but locked. On success the plaintext is `Zeroizing`
|
- Corrected metadata lookup to query with the derived public-key type directly, preventing silent fallback when importing accounts.
|
||||||
(shredded on scope exit).
|
- 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.
|
||||||
- `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.
|
|
||||||
|
|
||||||
Security properties: no secret material is logged; the connect secret is resolved
|
## How to resume / reproduce
|
||||||
fresh from the vault at send time (fail-closed on a locked vault); a serialized
|
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`.
|
||||||
`Nip46Connection` or `SigningBackend::Remote` carries zero secret material; the
|
- 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`.
|
||||||
decrypted plaintext is `Zeroizing`.
|
- 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`,
|
## Outstanding / next-step items
|
||||||
`b2755d8` `Signer` trait returning `SigningError`) is unchanged by this commit — it is
|
- Undo restores with empty `secret_key` (stores `ProfileSummary`); needs `StoredProfile` in `undo_history` for full secret recovery.
|
||||||
what this sub-step wires up.
|
- `.opencode/`, `COSMIC_THEME.md`, and `KeynectrAppIconPossibility02.jpeg` remain untracked; no commit was created in this session.
|
||||||
|
- Installer artifacts are local build outputs under `frontend/release/` and are not committed.
|
||||||
---
|
|
||||||
The previously-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.
|
|
||||||
|
|
|
||||||
978
Cargo.lock
generated
|
|
@ -17,6 +17,3 @@ base64 = "0.22"
|
||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
rpassword = "7"
|
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 { lookup } from 'node:dns/promises';
|
||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
import { randomBytes } from 'node:crypto';
|
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 { createInterface } from 'node:readline';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
@ -28,306 +28,6 @@ protocol.registerSchemesAsPrivileged([
|
||||||
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
{ 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.
|
* Content-Security-Policy applied to every page this app loads.
|
||||||
*
|
*
|
||||||
|
|
@ -338,12 +38,12 @@ if (process.platform === 'linux') {
|
||||||
* (HMR websocket included).
|
* (HMR websocket included).
|
||||||
*/
|
*/
|
||||||
const CSP_PROD =
|
const CSP_PROD =
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||||
"connect-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; " +
|
"connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " +
|
||||||
"base-uri 'none'; form-action 'none'";
|
"base-uri 'none'; form-action 'none'";
|
||||||
const CSP_DEV =
|
const CSP_DEV =
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.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:; font-src 'self' https://fonts.gstatic.com; " +
|
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " +
|
||||||
"object-src 'none'; base-uri 'none'; form-action 'none'";
|
"object-src 'none'; base-uri 'none'; form-action 'none'";
|
||||||
|
|
||||||
/** The CSP for a URL this window may load, or `null` for anywhere else. */
|
/** 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',
|
'init',
|
||||||
'get_state',
|
'get_state',
|
||||||
'create_profile',
|
'create_profile',
|
||||||
'import_profile',
|
|
||||||
'select_profile',
|
'select_profile',
|
||||||
'publish_profile_metadata',
|
'publish_profile_metadata',
|
||||||
'set_profile_picture',
|
'set_profile_picture',
|
||||||
|
|
@ -518,26 +217,10 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
||||||
'lock_vault',
|
'lock_vault',
|
||||||
'remove_vault_password',
|
'remove_vault_password',
|
||||||
'reveal_secret_key',
|
'reveal_secret_key',
|
||||||
'export_secret_key',
|
|
||||||
// Legacy bunker
|
|
||||||
'signer_connect',
|
'signer_connect',
|
||||||
'signer_disconnect',
|
'signer_disconnect',
|
||||||
'signer_status',
|
'signer_status',
|
||||||
'signer_approve',
|
'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. */
|
/** True when `method` may be dispatched. Unknown methods never reach the backend. */
|
||||||
|
|
@ -832,7 +515,6 @@ function createWindow(): void {
|
||||||
icon: resolveWindowIcon(),
|
icon: resolveWindowIcon(),
|
||||||
backgroundColor: '#f6f4f0',
|
backgroundColor: '#f6f4f0',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
show: false,
|
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
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;
|
const devServer = process.env.NOSTR_GUI_DEV_URL;
|
||||||
if (devServer) {
|
if (devServer) {
|
||||||
void window.loadURL(devServer);
|
void window.loadURL(devServer);
|
||||||
|
|
@ -989,11 +648,6 @@ app.whenReady().then(() => {
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
if (pendingGiveUpDialog) {
|
|
||||||
dialog.showErrorBox('Keynctr — display problems', pendingGiveUpDialog);
|
|
||||||
pendingGiveUpDialog = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
@ -1002,7 +656,6 @@ app.whenReady().then(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
markStartupCleanExit();
|
|
||||||
if (backend) {
|
if (backend) {
|
||||||
backend.kill();
|
backend.kill();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
143
frontend/package-lock.json
generated
|
|
@ -8,7 +8,6 @@
|
||||||
"name": "keynectr",
|
"name": "keynectr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nostr-tools": "^2.25.1",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|
@ -863,45 +862,6 @@
|
||||||
"node": ">=10"
|
"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": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
||||||
|
|
@ -1242,66 +1202,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||||
|
|
@ -5107,47 +5007,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/nwsapi": {
|
||||||
"version": "2.2.24",
|
"version": "2.2.24",
|
||||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||||
|
|
@ -6390,7 +6249,7 @@
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,10 @@
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"electron:build": "tsc -p tsconfig.electron.json",
|
"electron:build": "tsc -p tsconfig.electron.json",
|
||||||
"start": "npm run electron:build && electron .",
|
"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",
|
"desktop:install": "bash scripts/install-desktop-entry.sh",
|
||||||
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nostr-tools": "^2.25.1",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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 { ComposeScreen } from './screens/ComposeScreen';
|
||||||
import { RelaysScreen } from './screens/RelaysScreen';
|
import { RelaysScreen } from './screens/RelaysScreen';
|
||||||
import { SignerScreen } from './screens/SignerScreen';
|
import { SignerScreen } from './screens/SignerScreen';
|
||||||
import { SignerModeScreen } from './screens/SignerModeScreen';
|
|
||||||
import { SettingsScreen } from './screens/SettingsScreen';
|
import { SettingsScreen } from './screens/SettingsScreen';
|
||||||
import { CreateProfileModal } from './screens/CreateProfileModal';
|
import { CreateProfileModal } from './screens/CreateProfileModal';
|
||||||
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
|
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
|
||||||
|
|
@ -75,7 +74,6 @@ function Shell() {
|
||||||
{screen === 'compose' && <ComposeScreen />}
|
{screen === 'compose' && <ComposeScreen />}
|
||||||
{screen === 'relays' && <RelaysScreen />}
|
{screen === 'relays' && <RelaysScreen />}
|
||||||
{screen === 'signer' && <SignerScreen />}
|
{screen === 'signer' && <SignerScreen />}
|
||||||
{screen === 'signer-mode' && <SignerModeScreen />}
|
|
||||||
{screen === 'settings' && <SettingsScreen />}
|
{screen === 'settings' && <SettingsScreen />}
|
||||||
</main>
|
</main>
|
||||||
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
<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'
|
| 'shield'
|
||||||
| 'publish'
|
| 'publish'
|
||||||
| 'external'
|
| 'external'
|
||||||
| 'key'
|
| 'key';
|
||||||
| 'server';
|
|
||||||
|
|
||||||
const PATHS: Record<IconName, ReactNode> = {
|
const PATHS: Record<IconName, ReactNode> = {
|
||||||
home: (
|
home: (
|
||||||
|
|
@ -102,12 +101,6 @@ const PATHS: Record<IconName, ReactNode> = {
|
||||||
<path d="M18 6l2 2" />
|
<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 {
|
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: 'feed', label: 'Feed', icon: 'list' },
|
||||||
{ id: 'compose', label: 'Compose', icon: 'edit' },
|
{ id: 'compose', label: 'Compose', icon: 'edit' },
|
||||||
{ id: 'relays', label: 'Relays', icon: 'relay' },
|
{ id: 'relays', label: 'Relays', icon: 'relay' },
|
||||||
{ id: 'signer-mode', label: 'Signer Mode', icon: 'key' },
|
{ id: 'signer', label: 'Signer', icon: 'key' },
|
||||||
{ id: 'signer', label: 'Signer (Bunker)', icon: 'server' },
|
|
||||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BackendResponse,
|
BackendResponse,
|
||||||
EmbeddedSignerStatus,
|
|
||||||
FeedItem,
|
FeedItem,
|
||||||
LinkPreview,
|
LinkPreview,
|
||||||
MetadataPublishReport,
|
MetadataPublishReport,
|
||||||
Nip46SignerStatus,
|
|
||||||
PickedImage,
|
PickedImage,
|
||||||
ProfileSummary,
|
ProfileSummary,
|
||||||
PublishReport,
|
PublishReport,
|
||||||
RelayTestResult,
|
RelayTestResult,
|
||||||
RevealedKey,
|
RevealedKey,
|
||||||
Settings,
|
Settings,
|
||||||
SignerMode,
|
|
||||||
SignerStatus,
|
SignerStatus,
|
||||||
UpdateApplyReport,
|
UpdateApplyReport,
|
||||||
UpdateCheckReport,
|
UpdateCheckReport,
|
||||||
|
|
@ -55,8 +52,6 @@ export const api = {
|
||||||
getState: () => call<AppState>('get_state'),
|
getState: () => call<AppState>('get_state'),
|
||||||
createProfile: (label: string, settings?: Settings) =>
|
createProfile: (label: string, settings?: Settings) =>
|
||||||
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, 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 }),
|
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
|
||||||
publishProfileMetadata: (npub: string) =>
|
publishProfileMetadata: (npub: string) =>
|
||||||
call<MetadataPublishReport>('publish_profile_metadata', { npub }),
|
call<MetadataPublishReport>('publish_profile_metadata', { npub }),
|
||||||
|
|
@ -101,29 +96,10 @@ export const api = {
|
||||||
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
|
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
|
||||||
lockVault: () => call<AppState>('lock_vault'),
|
lockVault: () => call<AppState>('lock_vault'),
|
||||||
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
|
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
|
||||||
exportSecretKey: (npub: string, password: string, reason: string) =>
|
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
|
||||||
call<RevealedKey>('export_secret_key', { npub, password, reason }),
|
|
||||||
pickImages: () => call<PickedImage[]>('pick_image'),
|
pickImages: () => call<PickedImage[]>('pick_image'),
|
||||||
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
|
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
|
||||||
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
|
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 }),
|
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
|
||||||
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
|
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
|
||||||
signerStatus: () => call<SignerStatus>('signer_status'),
|
signerStatus: () => call<SignerStatus>('signer_status'),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
export type Screen =
|
export type Screen = 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings';
|
||||||
'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'signer-mode' | 'settings';
|
|
||||||
|
|
||||||
export const SCREEN_TITLES: Record<Screen, string> = {
|
export const SCREEN_TITLES: Record<Screen, string> = {
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
|
|
@ -7,7 +6,6 @@ export const SCREEN_TITLES: Record<Screen, string> = {
|
||||||
profiles: 'Profiles',
|
profiles: 'Profiles',
|
||||||
compose: 'Compose',
|
compose: 'Compose',
|
||||||
relays: 'Relays',
|
relays: 'Relays',
|
||||||
signer: 'Signer (Bunker)',
|
signer: 'Signer',
|
||||||
'signer-mode': 'Signer Mode',
|
|
||||||
settings: 'Settings',
|
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 =
|
export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
|
||||||
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
|
|
||||||
|
|
||||||
/** Active signer mode. */
|
|
||||||
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
|
|
||||||
|
|
||||||
/** Lifecycle of the NIP-46 remote signer. */
|
/** Lifecycle of the NIP-46 remote signer. */
|
||||||
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
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. */
|
/** A NIP-46 request waiting for the user to approve or reject it. */
|
||||||
export interface PendingApproval {
|
export interface PendingApproval {
|
||||||
/** Internal id used to answer this request. */
|
/** Internal id used to answer this request. */
|
||||||
|
|
@ -25,8 +11,6 @@ export interface PendingApproval {
|
||||||
method: string;
|
method: string;
|
||||||
/** A short human-readable description of what will be done. */
|
/** A short human-readable description of what will be done. */
|
||||||
summary: string;
|
summary: string;
|
||||||
/** Detailed approval information. */
|
|
||||||
details?: ApprovalDetails;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Non-secret snapshot of the NIP-46 remote signer for display. */
|
/** Non-secret snapshot of the NIP-46 remote signer for display. */
|
||||||
|
|
@ -36,38 +20,12 @@ export interface SignerStatus {
|
||||||
peer: string | null;
|
peer: string | null;
|
||||||
/** Relays used for the connection. */
|
/** Relays used for the connection. */
|
||||||
relays: string[];
|
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. */
|
/** A user-facing error if the signer stopped because of one. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** Requests currently waiting for the user's approval. */
|
/** Requests currently waiting for the user's approval. */
|
||||||
pending: PendingApproval[];
|
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. */
|
/** A safe view of a profile with no secret key material. */
|
||||||
export interface ProfileSummary {
|
export interface ProfileSummary {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -80,8 +38,6 @@ export interface ProfileSummary {
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
|
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
|
||||||
nip05?: string | null;
|
nip05?: string | null;
|
||||||
/** Per-profile signer mode. Absent for legacy profiles; defaults to embedded. */
|
|
||||||
signer_mode?: SignerMode | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RelayConfig {
|
export interface RelayConfig {
|
||||||
|
|
@ -109,8 +65,6 @@ export interface PublishReport {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
succeeded: string[];
|
succeeded: string[];
|
||||||
failed: RelayFailure[];
|
failed: RelayFailure[];
|
||||||
/** The note content that was published. */
|
|
||||||
content?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-relay outcome of publishing a profile's name as kind 0 metadata. */
|
/** Per-relay outcome of publishing a profile's name as kind 0 metadata. */
|
||||||
|
|
@ -119,9 +73,6 @@ export interface MetadataPublishReport {
|
||||||
failed: RelayFailure[];
|
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. */
|
/** A single note shown in the aggregated feed. */
|
||||||
export interface FeedItem {
|
export interface FeedItem {
|
||||||
/** Bech32 note id. */
|
/** Bech32 note id. */
|
||||||
|
|
@ -188,15 +139,6 @@ export interface AppState {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
/** Recently deleted profiles, newest last, for undo. */
|
/** Recently deleted profiles, newest last, for undo. */
|
||||||
undo_history?: ProfileSummary[];
|
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. */
|
/** A secret key revealed after the vault is unlocked. */
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { EmptyState } from '../components/EmptyState';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { shortenNpub } from '../lib/format';
|
import { shortenNpub } from '../lib/format';
|
||||||
import type { Screen } from '../lib/navigation';
|
import type { Screen } from '../lib/navigation';
|
||||||
import type { ProfilePublication } from '../lib/publications';
|
|
||||||
import { useProfilePublications } from '../lib/publications';
|
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
||||||
interface HomeScreenProps {
|
interface HomeScreenProps {
|
||||||
|
|
@ -18,8 +16,7 @@ interface HomeScreenProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
const { state, selectProfile } = useApp();
|
const { state, lastPublish, selectProfile } = useApp();
|
||||||
const { publications, fullyPublished, loading, error } = useProfilePublications();
|
|
||||||
const [selecting, setSelecting] = useState<string | null>(null);
|
const [selecting, setSelecting] = useState<string | null>(null);
|
||||||
|
|
||||||
const onSelect = async (npub: string) => {
|
const onSelect = async (npub: string) => {
|
||||||
|
|
@ -75,7 +72,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" onClick={() => onNavigate('compose')}>
|
<Button variant="primary" onClick={() => onNavigate('compose')}>
|
||||||
<Icon name="edit" size={18} />
|
<Icon name="edit" size={18} />
|
||||||
Compose
|
Compose note
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -94,15 +91,11 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<ul className="home-profile-list" role="listbox" aria-label="Profiles">
|
<ul className="home-profile-list">
|
||||||
{state?.profiles.map((profile) => (
|
{state?.profiles.map((profile) => (
|
||||||
<li
|
<li
|
||||||
key={profile.npub}
|
key={profile.npub}
|
||||||
className={`home-profile-row${profile.is_active ? ' is-active' : ''}`}
|
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={
|
onClick={
|
||||||
profile.is_active
|
profile.is_active
|
||||||
? undefined
|
? undefined
|
||||||
|
|
@ -113,19 +106,6 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
void onSelect(profile.npub);
|
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
|
<Avatar
|
||||||
npub={profile.npub}
|
npub={profile.npub}
|
||||||
|
|
@ -185,10 +165,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
</header>
|
</header>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<PublicationResult
|
<PublicationResult
|
||||||
publications={publications}
|
lastPublish={lastPublish}
|
||||||
fullyPublished={fullyPublished}
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
onNavigateCompose={() => onNavigate('compose')}
|
onNavigateCompose={() => onNavigate('compose')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -199,114 +176,92 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function PublicationResult({
|
function PublicationResult({
|
||||||
publications,
|
lastPublish,
|
||||||
fullyPublished,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
onNavigateCompose,
|
onNavigateCompose,
|
||||||
}: {
|
}: {
|
||||||
publications: ProfilePublication[];
|
lastPublish: ReturnType<typeof useApp>['lastPublish'];
|
||||||
fullyPublished: ProfilePublication[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
onNavigateCompose: () => void;
|
onNavigateCompose: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (!lastPublish) {
|
||||||
return <p className="muted">Loading publications…</p>;
|
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 (
|
return (
|
||||||
<Alert tone="error" title="Could not load publications">
|
<Alert tone="error" title="Publication failed" details={lastPublish.details}>
|
||||||
{error}
|
{lastPublish.error}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (publications.length === 0) {
|
const report = lastPublish.report;
|
||||||
return (
|
if (!report) {
|
||||||
<div className="home-publish-empty">
|
return null;
|
||||||
<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 newest = fullyPublished[0] ?? null;
|
if (report.failed.length === 0) {
|
||||||
const newestPartial =
|
|
||||||
publications.find((p) => p.publicationStatus === 'partially_published') ?? null;
|
|
||||||
|
|
||||||
if (!newest && !newestPartial) {
|
|
||||||
return (
|
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">
|
<div className="publish-result success">
|
||||||
<Badge tone="success">
|
<Badge tone="success">
|
||||||
<Icon name="check" size={14} /> Published
|
<Icon name="check" size={14} /> Published
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="mono" title={newest.id}>
|
<span className="mono" title={report.event_id}>
|
||||||
{shortenNpub(newest.id, true)}
|
{shortenNpub(report.event_id, true)}
|
||||||
</span>
|
</span>
|
||||||
<CopyButton text={newest.id} label="event ID" />
|
<CopyButton text={report.event_id} label="event ID" />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<details className="alert-details">
|
<div className="publish-result">
|
||||||
<summary>Relay results</summary>
|
<Alert tone="warning" title="Partially published">
|
||||||
<ul className="relay-result-list">
|
The note reached {report.succeeded.length} of{' '}
|
||||||
{item.relays.map((url) => (
|
{report.succeeded.length + report.failed.length} enabled relays. Event ID:{' '}
|
||||||
<li key={url} className="ok">
|
<code className="mono" title={report.event_id}>
|
||||||
<span className="mono">{url}</span> — accepted
|
{shortenNpub(report.event_id, true)}
|
||||||
</li>
|
</code>
|
||||||
))}
|
</Alert>
|
||||||
{totalRelays === 0 && <li className="bad">No relays returned this event.</li>}
|
<CopyButton text={report.event_id} label="event ID" />
|
||||||
</ul>
|
<details className="alert-details">
|
||||||
</details>
|
<summary>Relay results</summary>
|
||||||
|
<ul className="relay-result-list">
|
||||||
|
{report.succeeded.map((url) => (
|
||||||
|
<li key={url} className="ok">
|
||||||
|
<span className="mono">{url}</span> — accepted
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{report.failed.map((failure) => (
|
||||||
|
<li key={failure.url} className="bad">
|
||||||
|
<span className="mono">{failure.url}</span> — {failure.error}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FirstRunGuide() {
|
function FirstRunGuide() {
|
||||||
const steps: { icon: string; title: string; body: string }[] = [
|
const steps: { title: string; body: string }[] = [
|
||||||
{
|
{
|
||||||
icon: 'users',
|
title: '1 · Create a profile',
|
||||||
title: 'Create a profile',
|
|
||||||
body: 'The app generates a public npub address and a private key for you. They are stored only on this computer.',
|
body: 'The app generates a public npub address and a private key for you. They are stored only on this computer.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'copy',
|
title: '2 · Share your npub',
|
||||||
title: 'Share your npub',
|
|
||||||
body: 'Your npub is public and safe to share. Never share your private key with anyone.',
|
body: 'Your npub is public and safe to share. Never share your private key with anyone.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'edit',
|
title: '3 · Publish a note',
|
||||||
title: 'Publish a note',
|
|
||||||
body: 'Write a note in Compose and publish it. Your note is signed locally and sent to the enabled relays.',
|
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>
|
<h2>How it works</h2>
|
||||||
<ol>
|
<ol>
|
||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
<li key={step.title} className="first-run-step">
|
<li key={step.title}>
|
||||||
<span className="first-run-step-indicator" aria-hidden="true">
|
<strong>{step.title}</strong>
|
||||||
<Icon name={step.icon as any} size={16} />
|
<p>{step.body}</p>
|
||||||
</span>
|
|
||||||
<div className="first-run-step-content">
|
|
||||||
<strong>{step.title}</strong>
|
|
||||||
<p>{step.body}</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -13,55 +13,31 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSecret('');
|
setSecret(''); setError(null); setSaving(false);
|
||||||
setError(null);
|
requestAnimationFrame(() => labelRef.current?.focus());
|
||||||
setSaving(false);
|
|
||||||
requestAnimationFrame(() => labelRef.current?.focus());
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!secret.trim() || saving) return;
|
if (!secret.trim() || saving) return;
|
||||||
setSaving(true);
|
setSaving(true); setError(null);
|
||||||
setError(null);
|
try { await importProfile('', secret.trim()); onClose(); }
|
||||||
try {
|
catch (err) { setError(err instanceof Error ? err.message : String(err)); setSaving(false); }
|
||||||
await importProfile('', secret.trim());
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <Modal open={open} title="Add an existing account" onClose={onClose}>
|
||||||
<Modal open={open} title="Add an existing account" onClose={onClose}>
|
<form onSubmit={submit} noValidate>
|
||||||
<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">
|
<div className="field">
|
||||||
Import an account using its private key. The key stays in your local vault and is never
|
<label htmlFor="import-profile-secret">Private key</label>
|
||||||
displayed.
|
<input id="import-profile-secret" type="password" value={secret} onChange={(e) => setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" />
|
||||||
</p>
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
<div className="field">
|
</div>
|
||||||
<label htmlFor="import-profile-secret">Private key</label>
|
<div className="modal-actions">
|
||||||
<input
|
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
id="import-profile-secret"
|
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>{saving ? 'Adding…' : 'Add account'}</Button>
|
||||||
type="password"
|
</div>
|
||||||
value={secret}
|
</form>
|
||||||
onChange={(e) => setSecret(e.target.value)}
|
</Modal>;
|
||||||
placeholder="nsec1... or 64-character hex"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{error && <ErrorText>{error}</ErrorText>}
|
|
||||||
</div>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>
|
|
||||||
{saving ? 'Adding…' : 'Add account'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ import { ErrorText } from '../components/ErrorText';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { ProfileEditModal } from '../components/ProfileEditModal';
|
import { ProfileEditModal } from '../components/ProfileEditModal';
|
||||||
import { ImportProfileModal } from './ImportProfileModal';
|
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||||
import { ExportSecretKeyModal } from '../components/ExportSecretKeyModal';
|
|
||||||
import { formatDate, shortenNpub } from '../lib/format';
|
import { formatDate, shortenNpub } from '../lib/format';
|
||||||
import type { MetadataPublishReport } from '../lib/types';
|
import type { MetadataPublishReport } from '../lib/types';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
@ -42,7 +41,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
nip05?: string | null;
|
nip05?: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
|
|
||||||
const profiles = state?.profiles ?? [];
|
const profiles = state?.profiles ?? [];
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
const shorten = state?.settings.shorten_npub ?? true;
|
||||||
|
|
@ -122,15 +120,10 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
title="No profiles yet"
|
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."
|
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={
|
action={
|
||||||
<div className="modal-actions">
|
<Button variant="primary" onClick={onCreateProfile}>
|
||||||
<Button variant="primary" onClick={onCreateProfile}>
|
<Icon name="plus" size={18} />
|
||||||
<Icon name="plus" size={18} />
|
Create Profile
|
||||||
Create Profile
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
|
||||||
Add existing account
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -165,9 +158,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
<Icon name="plus" size={18} />
|
<Icon name="plus" size={18} />
|
||||||
Create Profile
|
Create Profile
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
|
||||||
Add existing account
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||||
|
|
@ -347,7 +337,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ExportSecretKeyModal
|
<ShowSecretKeyModal
|
||||||
open={revealTarget !== null}
|
open={revealTarget !== null}
|
||||||
profile={revealTarget}
|
profile={revealTarget}
|
||||||
onClose={() => setRevealTarget(null)}
|
onClose={() => setRevealTarget(null)}
|
||||||
|
|
@ -404,7 +394,6 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
onError={setError}
|
onError={setError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
|
|
||||||
</div>
|
</div>
|
||||||
</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',
|
phase: 'stopped',
|
||||||
peer: null,
|
peer: null,
|
||||||
relays: [],
|
relays: [],
|
||||||
connectedRelays: [],
|
|
||||||
error: null,
|
error: null,
|
||||||
pending: [],
|
pending: [],
|
||||||
};
|
};
|
||||||
|
|
@ -145,25 +144,11 @@ export function SignerScreen() {
|
||||||
<dt>Relays</dt>
|
<dt>Relays</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{status.relays.length > 0 ? (
|
{status.relays.length > 0 ? (
|
||||||
<>
|
status.relays.map((relay) => (
|
||||||
{status.relays.map((relay) => {
|
<span key={relay} className="mono signer-relay">
|
||||||
const connected = status.connectedRelays.includes(relay);
|
{relay}
|
||||||
return (
|
</span>
|
||||||
<span
|
))
|
||||||
key={relay}
|
|
||||||
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
|
|
||||||
title={connected ? 'Connected' : 'No connection yet'}
|
|
||||||
>
|
|
||||||
{relay}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{status.phase === 'connecting' && status.connectedRelays.length === 0 && (
|
|
||||||
<span className="muted signer-relay-hint">
|
|
||||||
Waiting for a relay to answer…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="muted">None</span>
|
<span className="muted">None</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,15 @@ import {
|
||||||
import { api, BackendError } from '../lib/api';
|
import { api, BackendError } from '../lib/api';
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
EmbeddedSignerStatus,
|
|
||||||
FeedItem,
|
FeedItem,
|
||||||
LinkPreview,
|
LinkPreview,
|
||||||
MetadataPublishReport,
|
MetadataPublishReport,
|
||||||
Nip46SignerStatus,
|
|
||||||
PickedImage,
|
PickedImage,
|
||||||
ProfileSummary,
|
ProfileSummary,
|
||||||
PublishReport,
|
PublishReport,
|
||||||
RelayTestResult,
|
RelayTestResult,
|
||||||
RevealedKey,
|
RevealedKey,
|
||||||
Settings,
|
Settings,
|
||||||
SignerMode,
|
|
||||||
SignerStatus,
|
SignerStatus,
|
||||||
Theme,
|
Theme,
|
||||||
UpdateApplyReport,
|
UpdateApplyReport,
|
||||||
|
|
@ -43,7 +40,6 @@ interface AppContextValue {
|
||||||
lastPublish: LastPublish | null;
|
lastPublish: LastPublish | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
createProfile: (label: string) => Promise<ProfileSummary>;
|
createProfile: (label: string) => Promise<ProfileSummary>;
|
||||||
importProfile: (label: string, secret: string) => Promise<ProfileSummary>;
|
|
||||||
selectProfile: (npub: string) => Promise<void>;
|
selectProfile: (npub: string) => Promise<void>;
|
||||||
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
||||||
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
||||||
|
|
@ -67,22 +63,10 @@ interface AppContextValue {
|
||||||
unlockVault: (password: string) => Promise<AppState>;
|
unlockVault: (password: string) => Promise<AppState>;
|
||||||
lockVault: () => Promise<AppState>;
|
lockVault: () => Promise<AppState>;
|
||||||
removeVaultPassword: (password: string) => Promise<AppState>;
|
removeVaultPassword: (password: string) => Promise<AppState>;
|
||||||
exportSecretKey: (npub: string, password: string, reason: string) => Promise<RevealedKey>;
|
revealSecretKey: (npub: string) => Promise<RevealedKey>;
|
||||||
pickImages: () => Promise<PickedImage[]>;
|
pickImages: () => Promise<PickedImage[]>;
|
||||||
uploadImage: (token: string) => Promise<UploadedImage>;
|
uploadImage: (token: string) => Promise<UploadedImage>;
|
||||||
linkPreview: (url: string) => Promise<LinkPreview | null>;
|
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>;
|
signerConnect: (uri: string) => Promise<SignerStatus>;
|
||||||
signerDisconnect: () => Promise<SignerStatus>;
|
signerDisconnect: () => Promise<SignerStatus>;
|
||||||
signerStatus: () => Promise<SignerStatus>;
|
signerStatus: () => Promise<SignerStatus>;
|
||||||
|
|
@ -113,14 +97,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
const initial = await api.init();
|
const initial = await api.init();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setState(initial);
|
setState(initial);
|
||||||
if (initial.last_publish) {
|
|
||||||
setLastPublish({
|
|
||||||
report: initial.last_publish,
|
|
||||||
error: null,
|
|
||||||
details: null,
|
|
||||||
at: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -146,15 +122,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
[state?.settings],
|
[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 selectProfile = useCallback(async (npub: string) => {
|
||||||
const fresh = await api.selectProfile(npub);
|
const fresh = await api.selectProfile(npub);
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
|
@ -243,32 +210,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
return next;
|
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(
|
const setVaultPassword = useCallback(
|
||||||
(currentPassword: string | null, newPassword: string) =>
|
(currentPassword: string | null, newPassword: string) =>
|
||||||
applyState(api.setVaultPassword(currentPassword, newPassword)),
|
applyState(api.setVaultPassword(currentPassword, newPassword)),
|
||||||
|
|
@ -283,11 +224,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
(password: string) => applyState(api.removeVaultPassword(password)),
|
(password: string) => applyState(api.removeVaultPassword(password)),
|
||||||
[applyState],
|
[applyState],
|
||||||
);
|
);
|
||||||
const exportSecretKey = useCallback(
|
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
|
||||||
(npub: string, password: string, reason: string) =>
|
|
||||||
api.exportSecretKey(npub, password, reason),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const pickImages = useCallback(() => api.pickImages(), []);
|
const pickImages = useCallback(() => api.pickImages(), []);
|
||||||
const uploadImage = useCallback((token: string) => api.uploadImage(token), []);
|
const uploadImage = useCallback((token: string) => api.uploadImage(token), []);
|
||||||
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
|
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
|
||||||
|
|
@ -324,7 +261,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
lastPublish,
|
lastPublish,
|
||||||
refresh,
|
refresh,
|
||||||
createProfile,
|
createProfile,
|
||||||
importProfile,
|
|
||||||
selectProfile,
|
selectProfile,
|
||||||
publishNote,
|
publishNote,
|
||||||
recordPublishFailure,
|
recordPublishFailure,
|
||||||
|
|
@ -342,18 +278,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
unlockVault,
|
unlockVault,
|
||||||
lockVault,
|
lockVault,
|
||||||
removeVaultPassword,
|
removeVaultPassword,
|
||||||
exportSecretKey,
|
revealSecretKey,
|
||||||
pickImages,
|
pickImages,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
linkPreview,
|
linkPreview,
|
||||||
signerModeGet,
|
|
||||||
signerModeSet,
|
|
||||||
embeddedSignerStatus,
|
|
||||||
embeddedSignerApprove,
|
|
||||||
nip46Connect,
|
|
||||||
nip46Disconnect,
|
|
||||||
nip46Status,
|
|
||||||
nip46Approve,
|
|
||||||
signerConnect,
|
signerConnect,
|
||||||
signerDisconnect,
|
signerDisconnect,
|
||||||
signerStatus,
|
signerStatus,
|
||||||
|
|
@ -374,7 +302,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
lastPublish,
|
lastPublish,
|
||||||
refresh,
|
refresh,
|
||||||
createProfile,
|
createProfile,
|
||||||
importProfile,
|
|
||||||
selectProfile,
|
selectProfile,
|
||||||
publishProfileMetadata,
|
publishProfileMetadata,
|
||||||
setProfilePicture,
|
setProfilePicture,
|
||||||
|
|
@ -399,18 +326,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
unlockVault,
|
unlockVault,
|
||||||
lockVault,
|
lockVault,
|
||||||
removeVaultPassword,
|
removeVaultPassword,
|
||||||
exportSecretKey,
|
revealSecretKey,
|
||||||
pickImages,
|
pickImages,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
linkPreview,
|
linkPreview,
|
||||||
signerModeGet,
|
|
||||||
signerModeSet,
|
|
||||||
embeddedSignerStatus,
|
|
||||||
embeddedSignerApprove,
|
|
||||||
nip46Connect,
|
|
||||||
nip46Disconnect,
|
|
||||||
nip46Status,
|
|
||||||
nip46Approve,
|
|
||||||
signerConnect,
|
signerConnect,
|
||||||
signerDisconnect,
|
signerDisconnect,
|
||||||
signerStatus,
|
signerStatus,
|
||||||
|
|
|
||||||
|
|
@ -709,7 +709,7 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
margin: 6px 0 0;
|
margin: 4px 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -808,22 +808,22 @@ a {
|
||||||
.sidebar-logo {
|
.sidebar-logo {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
|
border-radius: 11px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
/* The logo PNG now carries a real alpha channel: no tile background,
|
background: #fff;
|
||||||
border, or mask — the artwork composites directly on the sidebar. */
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-logo img {
|
.sidebar-logo img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The artwork is black ink; flip it in dark themes so it stays visible on
|
/* The artwork is black-on-white; flip it in dark themes so it stays black
|
||||||
dark sidebars. */
|
bird on dark tile instead of a glaring white square. */
|
||||||
html[data-theme='dark'] .sidebar-logo img,
|
html[data-theme='dark'] .sidebar-logo img,
|
||||||
html[data-theme='neon'] .sidebar-logo img,
|
html[data-theme='neon'] .sidebar-logo img,
|
||||||
html[data-theme='glass'] .sidebar-logo img {
|
html[data-theme='glass'] .sidebar-logo img {
|
||||||
|
|
@ -1408,9 +1408,16 @@ select {
|
||||||
|
|
||||||
.home-grid {
|
.home-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-profile-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.active-profile-meta {
|
.active-profile-meta {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -1465,7 +1472,7 @@ select {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile-row {
|
.home-profile-row {
|
||||||
|
|
@ -1473,28 +1480,19 @@ select {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 8px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile-row.is-active {
|
.home-profile-row.is-active {
|
||||||
background: var(--surface-2);
|
background: var(--token-item-bg, rgba(0, 0, 0, 0.04));
|
||||||
border-color: var(--border);
|
border-color: var(--token-border, rgba(0, 0, 0, 0.12));
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-profile-row:not(.is-active) {
|
.home-profile-row:not(.is-active) {
|
||||||
cursor: pointer;
|
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 {
|
.home-profile-row .active-profile-meta {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -1513,17 +1511,7 @@ select {
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-add-profile {
|
.home-add-profile {
|
||||||
margin-top: 16px;
|
margin-top: 14px;
|
||||||
}
|
|
||||||
|
|
||||||
.home-publish-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay-status-list {
|
.relay-status-list {
|
||||||
|
|
@ -1587,18 +1575,6 @@ select {
|
||||||
flex-basis: 100%;
|
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 {
|
.relay-result-list {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
|
|
@ -1620,45 +1596,16 @@ select {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.first-run-guide h2 {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.first-run-guide ol {
|
.first-run-guide ol {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
padding: 0;
|
padding-left: 20px;
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.first-run-step {
|
.first-run-guide p {
|
||||||
display: flex;
|
margin: 2px 0 0;
|
||||||
align-items: flex-start;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.first-run-step-indicator {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--primary-soft);
|
|
||||||
color: var(--primary);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.first-run-step-content strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.first-run-step-content p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -2213,16 +2160,6 @@ select {
|
||||||
font-size: 12px;
|
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 {
|
.signer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -2323,320 +2260,3 @@ select {
|
||||||
transition: none;
|
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);
|
const { onNavigate } = renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
|
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
// The active profile appears in the profile list with its shortened npub.
|
expect(await screen.findByText('Alice')).toBeInTheDocument();
|
||||||
const profileList = await screen.findByRole('listbox');
|
expect(screen.getByText(/npub1alice\.\.\./)).toBeInTheDocument();
|
||||||
expect(within(profileList).getByText('Alice')).toBeInTheDocument();
|
|
||||||
expect(within(profileList).getByText(/npub1alice\.\.\./)).toBeInTheDocument();
|
|
||||||
|
|
||||||
const compose = screen.getByRole('button', { name: 'Compose' });
|
const compose = screen.getByRole('button', { name: /Compose note/i });
|
||||||
await userEvent.setup().click(compose);
|
await userEvent.setup().click(compose);
|
||||||
expect(onNavigate).toHaveBeenCalledWith('compose');
|
expect(onNavigate).toHaveBeenCalledWith('compose');
|
||||||
});
|
});
|
||||||
|
|
@ -38,11 +36,7 @@ describe('HomeScreen', () => {
|
||||||
renderHome(backend);
|
renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
// The active profile row carries the "Selected" badge; find Alice via the profile list.
|
const aliceRow = (await screen.findByText('Alice')).closest('.home-profile-row') as HTMLElement;
|
||||||
const profileList = await screen.findByRole('listbox');
|
|
||||||
const aliceRow = within(profileList)
|
|
||||||
.getByText('Alice')
|
|
||||||
.closest('.home-profile-row') as HTMLElement;
|
|
||||||
await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' }));
|
await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(backend.copied).toContain(ALICE);
|
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);
|
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 () => {
|
it('disconnects an active connection', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
|
|
@ -121,7 +80,6 @@ describe('SignerScreen', () => {
|
||||||
phase: 'connected',
|
phase: 'connected',
|
||||||
peer: 'ab12',
|
peer: 'ab12',
|
||||||
relays: ['wss://relay.damus.io'],
|
relays: ['wss://relay.damus.io'],
|
||||||
connectedRelays: ['wss://relay.damus.io'],
|
|
||||||
error: null,
|
error: null,
|
||||||
pending: [
|
pending: [
|
||||||
{ id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' },
|
{ id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' },
|
||||||
|
|
@ -157,7 +115,6 @@ describe('SignerScreen', () => {
|
||||||
phase: 'connected',
|
phase: 'connected',
|
||||||
peer: '79ab',
|
peer: '79ab',
|
||||||
relays: ['wss://relay.damus.io'],
|
relays: ['wss://relay.damus.io'],
|
||||||
connectedRelays: ['wss://relay.damus.io'],
|
|
||||||
error: null,
|
error: null,
|
||||||
pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
|
pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type {
|
||||||
ProfileSummary,
|
ProfileSummary,
|
||||||
RelayTestResult,
|
RelayTestResult,
|
||||||
Settings,
|
Settings,
|
||||||
SignerMode,
|
|
||||||
SignerStatus,
|
SignerStatus,
|
||||||
} from '../lib/types';
|
} from '../lib/types';
|
||||||
|
|
||||||
|
|
@ -44,8 +43,6 @@ export function makeState(overrides?: Partial<AppState>): AppState {
|
||||||
active_profile: alice,
|
active_profile: alice,
|
||||||
profiles: [alice, bob],
|
profiles: [alice, bob],
|
||||||
settings,
|
settings,
|
||||||
last_publish: null,
|
|
||||||
signer_mode: 'embedded' as SignerMode,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -74,15 +71,7 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
|
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
|
||||||
return {
|
return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides };
|
||||||
phase: 'stopped',
|
|
||||||
peer: null,
|
|
||||||
relays: [],
|
|
||||||
connectedRelays: [],
|
|
||||||
error: null,
|
|
||||||
pending: [],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -106,7 +95,7 @@ export interface ApiMock {
|
||||||
unlockVault: ReturnType<typeof vi.fn>;
|
unlockVault: ReturnType<typeof vi.fn>;
|
||||||
lockVault: ReturnType<typeof vi.fn>;
|
lockVault: ReturnType<typeof vi.fn>;
|
||||||
removeVaultPassword: ReturnType<typeof vi.fn>;
|
removeVaultPassword: ReturnType<typeof vi.fn>;
|
||||||
exportSecretKey: ReturnType<typeof vi.fn>;
|
revealSecretKey: ReturnType<typeof vi.fn>;
|
||||||
pickImages: ReturnType<typeof vi.fn>;
|
pickImages: ReturnType<typeof vi.fn>;
|
||||||
uploadImage: ReturnType<typeof vi.fn>;
|
uploadImage: ReturnType<typeof vi.fn>;
|
||||||
linkPreview: ReturnType<typeof vi.fn>;
|
linkPreview: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -213,7 +202,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
||||||
encrypted_storage: false,
|
encrypted_storage: false,
|
||||||
vault_locked: false,
|
vault_locked: false,
|
||||||
})),
|
})),
|
||||||
exportSecretKey: vi.fn(async (npub: string) => ({
|
revealSecretKey: vi.fn(async (npub: string) => ({
|
||||||
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
|
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
|
||||||
nsec: `nsec1${npub.slice(5)}`,
|
nsec: `nsec1${npub.slice(5)}`,
|
||||||
})),
|
})),
|
||||||
|
|
@ -236,7 +225,6 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
||||||
phase: 'connected',
|
phase: 'connected',
|
||||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||||
relays: ['wss://relay.damus.io'],
|
relays: ['wss://relay.damus.io'],
|
||||||
connectedRelays: ['wss://relay.damus.io'],
|
|
||||||
error: null,
|
error: null,
|
||||||
pending: [],
|
pending: [],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
phase: 'connected',
|
phase: 'connected',
|
||||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||||
relays: ['wss://relay.damus.io'],
|
relays: ['wss://relay.damus.io'],
|
||||||
connectedRelays: ['wss://relay.damus.io'],
|
|
||||||
error: null,
|
error: null,
|
||||||
pending: [],
|
pending: [],
|
||||||
};
|
};
|
||||||
|
|
@ -437,48 +436,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
return next;
|
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 npub = String(params.npub);
|
||||||
const password = String(params.password ?? '');
|
if (!state.profiles.some((p) => p.npub === npub)) {
|
||||||
const reason = String(params.reason ?? '');
|
throw new Error('That profile is not stored on this computer.');
|
||||||
|
|
||||||
// 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' },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||||
return { hex, nsec: `nsec1${npub.slice(5)}` };
|
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 userEvent from '@testing-library/user-event';
|
||||||
import App from '../App';
|
import App from '../App';
|
||||||
import { ALICE } from './apiMock';
|
|
||||||
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||||
|
|
||||||
describe('publication flow across screens', () => {
|
describe('publication flow across screens', () => {
|
||||||
it('publishes from Compose and shows the result on Home', async () => {
|
it('publishes from Compose and shows the result on Home', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
backend.state.settings.confirm_before_publish = false;
|
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);
|
installFakeBackend(backend);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await screen.findByRole('heading', { name: 'Home' });
|
await screen.findByRole('heading', { name: 'Home' });
|
||||||
|
|
||||||
const main = screen.getByRole('main');
|
await user.click(screen.getByRole('button', { name: /Compose note/i }));
|
||||||
await user.click(within(main).getByRole('button', { name: 'Compose' }));
|
|
||||||
await screen.findByRole('heading', { name: 'Compose' });
|
await screen.findByRole('heading', { name: 'Compose' });
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('Note content'), 'Hello from the flow test');
|
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 user.click(screen.getByRole('button', { name: 'Home' }));
|
||||||
await screen.findByRole('heading', { name: 'Home' });
|
await screen.findByRole('heading', { name: 'Home' });
|
||||||
expect(await screen.findByText(/Published/)).toBeInTheDocument();
|
expect(await screen.findByText('Published')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Hello from the flow test/)).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::general_purpose::STANDARD as B64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
use crate::audit::AuditLog;
|
|
||||||
use crate::crypto::{self, VaultKey};
|
use crate::crypto::{self, VaultKey};
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::profiles::{self, ProfileSummary};
|
use crate::profiles::{self, ProfileSummary};
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::vault::{
|
use crate::vault::{self, KdfParams, StoredProfile, Vault, VaultCrypto};
|
||||||
self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Minimum password length accepted when encrypting the vault.
|
/// Minimum password length accepted when encrypting the vault.
|
||||||
pub const MIN_PASSWORD_LEN: usize = 8;
|
pub const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
|
@ -24,29 +20,8 @@ pub struct App {
|
||||||
unlock_key: Option<VaultKey>,
|
unlock_key: Option<VaultKey>,
|
||||||
/// Stack of deleted profiles for undo functionality.
|
/// Stack of deleted profiles for undo functionality.
|
||||||
pub undo_history: Vec<ProfileSummary>,
|
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.
|
/// Snapshot of everything the UI needs, containing no secret keys.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct AppStateView {
|
pub struct AppStateView {
|
||||||
|
|
@ -63,35 +38,16 @@ pub struct AppStateView {
|
||||||
/// Recently deleted profiles, newest last, for undo.
|
/// Recently deleted profiles, newest last, for undo.
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub undo_history: Vec<ProfileSummary>,
|
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 {
|
impl App {
|
||||||
/// Load the vault (migrating a legacy vault if needed) and settings.
|
/// Load the vault (migrating a legacy vault if needed) and settings.
|
||||||
pub fn load() -> Result<Self, AppError> {
|
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 {
|
Ok(Self {
|
||||||
vault,
|
vault: vault::load_vault()?,
|
||||||
settings: vault::load_settings()?,
|
settings: vault::load_settings()?,
|
||||||
unlock_key: None,
|
unlock_key: None,
|
||||||
undo_history: Vec::new(),
|
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.
|
/// 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.
|
/// Returns the restored profile summary, or an error if there is no undo history.
|
||||||
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
|
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
|
||||||
|
|
@ -236,7 +115,6 @@ impl App {
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: restored.picture.clone(),
|
picture: restored.picture.clone(),
|
||||||
nip05: restored.nip05.clone(),
|
nip05: restored.nip05.clone(),
|
||||||
signer_mode: SignerMode::Embedded,
|
|
||||||
};
|
};
|
||||||
self.vault.profiles.push(stored);
|
self.vault.profiles.push(stored);
|
||||||
// If no active profile, this restored one becomes active
|
// If no active profile, this restored one becomes active
|
||||||
|
|
@ -362,8 +240,6 @@ impl App {
|
||||||
profiles: profiles::summaries(&self.vault),
|
profiles: profiles::summaries(&self.vault),
|
||||||
settings: self.settings.clone(),
|
settings: self.settings.clone(),
|
||||||
undo_history: self.undo_history.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(),
|
settings: offline_settings(),
|
||||||
unlock_key: None,
|
unlock_key: None,
|
||||||
undo_history: Vec::new(),
|
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,
|
Config,
|
||||||
/// Unexpected internal failure.
|
/// Unexpected internal failure.
|
||||||
Internal,
|
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.
|
/// Structured application error.
|
||||||
|
|
@ -205,65 +191,6 @@ impl AppError {
|
||||||
details,
|
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 {
|
impl fmt::Display for AppError {
|
||||||
|
|
|
||||||
346
src/ipc.rs
|
|
@ -12,11 +12,8 @@ use crate::profiles;
|
||||||
use crate::publish;
|
use crate::publish;
|
||||||
use crate::relays;
|
use crate::relays;
|
||||||
use crate::settings::Theme;
|
use crate::settings::Theme;
|
||||||
use crate::signer::embedded::EmbeddedSigner;
|
use crate::signer::Signer;
|
||||||
use crate::signer::nip46_client::Nip46ClientSigner;
|
|
||||||
use crate::signer::Signer as SignerTrait;
|
|
||||||
use crate::updates;
|
use crate::updates;
|
||||||
use crate::vault::SignerMode;
|
|
||||||
|
|
||||||
/// How long to wait for a relay connection test.
|
/// How long to wait for a relay connection test.
|
||||||
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||||
|
|
@ -40,10 +37,6 @@ pub enum Request {
|
||||||
CreateProfile {
|
CreateProfile {
|
||||||
label: String,
|
label: String,
|
||||||
},
|
},
|
||||||
ImportProfile {
|
|
||||||
label: String,
|
|
||||||
secret: String,
|
|
||||||
},
|
|
||||||
SelectProfile {
|
SelectProfile {
|
||||||
npub: String,
|
npub: String,
|
||||||
},
|
},
|
||||||
|
|
@ -132,58 +125,20 @@ pub enum Request {
|
||||||
RevealSecretKey {
|
RevealSecretKey {
|
||||||
npub: String,
|
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.
|
/// Sign a NIP-98 auth event for the active profile, for uploading media.
|
||||||
UploadAuth {
|
UploadAuth {
|
||||||
url: String,
|
url: String,
|
||||||
http_method: String,
|
http_method: String,
|
||||||
},
|
},
|
||||||
/// ===== SIGNER MODE MANAGEMENT =====
|
/// Start the NIP-46 remote signer for a `nostrconnect://` link.
|
||||||
/// Get the current signer mode.
|
|
||||||
SignerModeGet,
|
|
||||||
/// Set the signer mode (embedded or nip46).
|
|
||||||
SignerModeSet {
|
|
||||||
mode: SignerMode,
|
|
||||||
},
|
|
||||||
/// ===== EMBEDDED SIGNER =====
|
|
||||||
/// Get embedded signer status.
|
|
||||||
EmbeddedSignerStatus,
|
|
||||||
/// Approve/reject a pending embedded signer request.
|
|
||||||
EmbeddedSignerApprove {
|
|
||||||
index: usize,
|
|
||||||
approved: bool,
|
|
||||||
},
|
|
||||||
/// ===== NIP-46 CLIENT SIGNER =====
|
|
||||||
/// Connect to a NIP-46 signer using a nostrconnect:// URI.
|
|
||||||
Nip46Connect {
|
|
||||||
uri: String,
|
|
||||||
label: String,
|
|
||||||
},
|
|
||||||
/// Disconnect from the NIP-46 signer.
|
|
||||||
Nip46Disconnect,
|
|
||||||
/// Get NIP-46 connection status.
|
|
||||||
Nip46Status,
|
|
||||||
/// Approve/reject a pending NIP-46 request.
|
|
||||||
Nip46Approve {
|
|
||||||
id: String,
|
|
||||||
approved: bool,
|
|
||||||
},
|
|
||||||
/// ===== LEGACY NIP-46 BUNKER (server mode) =====
|
|
||||||
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
|
|
||||||
SignerConnect {
|
SignerConnect {
|
||||||
uri: String,
|
uri: String,
|
||||||
},
|
},
|
||||||
/// Stop the NIP-46 remote signer (bunker mode).
|
/// Stop the NIP-46 remote signer.
|
||||||
SignerDisconnect,
|
SignerDisconnect,
|
||||||
/// Report the remote signer's current status (bunker mode).
|
/// Report the remote signer's current status.
|
||||||
SignerStatus,
|
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 {
|
SignerApprove {
|
||||||
/// The internal id of the pending request, as reported by
|
/// The internal id of the pending request, as reported by
|
||||||
/// `SignerStatus.pending`.
|
/// `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
|
// 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.
|
// both see the same vault (including its unlock key) without racing writes.
|
||||||
let app = Arc::new(Mutex::new(App::load()?));
|
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 stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
|
||||||
|
|
||||||
let stdin = tokio::io::stdin();
|
let stdin = tokio::io::stdin();
|
||||||
|
|
@ -266,9 +222,10 @@ pub async fn serve() -> Result<(), AppError> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let task_app = app.clone();
|
let task_app = app.clone();
|
||||||
|
let task_signer = signer.clone();
|
||||||
let task_stdout = stdout.clone();
|
let task_stdout = stdout.clone();
|
||||||
tasks.spawn(async move {
|
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(
|
let _ = write_line(
|
||||||
&task_stdout,
|
&task_stdout,
|
||||||
ReplyEnvelope {
|
ReplyEnvelope {
|
||||||
|
|
@ -307,8 +264,12 @@ async fn write_line(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(app: Arc<Mutex<App>>, request: Request) -> Reply<serde_json::Value> {
|
async fn handle(
|
||||||
let result = run(&app, request).await;
|
app: Arc<Mutex<App>>,
|
||||||
|
signer: Arc<Signer>,
|
||||||
|
request: Request,
|
||||||
|
) -> Reply<serde_json::Value> {
|
||||||
|
let result = run(&app, &signer, request).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(value) => Reply::Ok { data: value },
|
Ok(value) => Reply::Ok { data: value },
|
||||||
Err(err) => Reply::Error {
|
Err(err) => Reply::Error {
|
||||||
|
|
@ -331,167 +292,43 @@ fn error_code(err: &AppError) -> String {
|
||||||
.unwrap_or_else(|_| "error".to_string())
|
.unwrap_or_else(|_| "error".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Main request dispatcher.
|
/// Signer control commands never touch the vault directly, so they take the
|
||||||
async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
|
/// 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 {
|
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 } => {
|
Request::SignerConnect { uri } => {
|
||||||
let guard = app.lock().await;
|
signer.connect(app.clone(), &uri)?;
|
||||||
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
Ok(json!(signer.status()))
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
|
||||||
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
|
|
||||||
return Ok(json!(status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(AppError::config(
|
|
||||||
"Legacy bunker mode not supported. Use NIP-46 client mode.",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
Request::SignerDisconnect => {
|
Request::SignerDisconnect => {
|
||||||
let guard = app.lock().await;
|
signer.disconnect();
|
||||||
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
Ok(json!(signer.status()))
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
|
||||||
signer.disconnect().await?;
|
|
||||||
let status = signer.status().await;
|
|
||||||
return Ok(json!(status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(AppError::config("Not in NIP-46 client mode"))
|
|
||||||
}
|
|
||||||
Request::SignerStatus => {
|
|
||||||
let guard = app.lock().await;
|
|
||||||
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
|
||||||
let status = signer.status().await;
|
|
||||||
return Ok(json!(status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(AppError::config("Not in NIP-46 client mode"))
|
|
||||||
}
|
}
|
||||||
|
Request::SignerStatus => Ok(json!(signer.status())),
|
||||||
Request::SignerApprove { id, approved } => {
|
Request::SignerApprove { id, approved } => {
|
||||||
let guard = app.lock().await;
|
signer.approve(&id, approved)?;
|
||||||
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
Ok(json!(signer.status()))
|
||||||
if let Some(signer) = &guard.nip46_signer {
|
|
||||||
signer.respond_to_approval(&id, approved).await?;
|
|
||||||
let status = signer.status().await;
|
|
||||||
return Ok(json!(status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(AppError::config("Not in NIP-46 client mode"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network-only requests (no shared state lock)
|
|
||||||
Request::RelayTest { url } => {
|
Request::RelayTest { url } => {
|
||||||
|
// Pure network probe against the given URL; no shared state.
|
||||||
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
|
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
|
||||||
Ok(json!(result))
|
Ok(json!(result))
|
||||||
}
|
}
|
||||||
Request::UpdateCheck => {
|
Request::UpdateCheck => {
|
||||||
|
// Long-running package-manager scan; never touches shared state.
|
||||||
let report = updates::check().await?;
|
let report = updates::check().await?;
|
||||||
Ok(json!(report))
|
Ok(json!(report))
|
||||||
}
|
}
|
||||||
Request::UpdateApply => {
|
Request::UpdateApply => {
|
||||||
|
// Installs updates on disk; a rebuild + restart picks them up.
|
||||||
let report = updates::apply().await?;
|
let report = updates::apply().await?;
|
||||||
Ok(json!(report))
|
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 limit = limit.unwrap_or(feed::DEFAULT_LIMIT);
|
||||||
let contacts_only = contacts_only.unwrap_or(false);
|
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()) {
|
let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()),
|
Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()),
|
||||||
None => None,
|
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 (settings, owner_hex) = {
|
||||||
let guard = app.lock().await;
|
let guard = app.lock().await;
|
||||||
let owner_hex = if author_hex.is_some() {
|
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))
|
Ok(json!(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vault state requests (require lock)
|
|
||||||
other => {
|
other => {
|
||||||
let mut guard = app.lock().await;
|
let mut guard = app.lock().await;
|
||||||
run_with_app(&mut guard, other).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 key = app.vault_key().copied();
|
||||||
let summary =
|
let summary =
|
||||||
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
|
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()?;
|
app.save_vault()?;
|
||||||
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::SelectProfile { npub } => {
|
Request::SelectProfile { npub } => {
|
||||||
profiles::set_active(&mut app.vault, &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()?;
|
app.save_vault()?;
|
||||||
Ok(json!(app.state_view()))
|
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 =
|
let report =
|
||||||
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
||||||
.await?;
|
.await?;
|
||||||
let stored = crate::vault::StoredPublishReport {
|
Ok(json!(report))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::RelayAdd { url } => {
|
Request::RelayAdd { url } => {
|
||||||
|
|
@ -698,32 +485,11 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
|
|
||||||
Request::UnlockVault { password } => {
|
Request::UnlockVault { password } => {
|
||||||
app.unlock(&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()))
|
Ok(json!(app.state_view()))
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::LockVault => {
|
Request::LockVault => {
|
||||||
app.lock();
|
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()))
|
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 } => {
|
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())?;
|
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))
|
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()))
|
Ok(json!(app.state_view()))
|
||||||
}
|
}
|
||||||
Request::UndoDelete => {
|
Request::UndoDelete => {
|
||||||
app.undo_delete()?;
|
let restored = app.undo_delete()?;
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
Ok(json!(app.state_view()))
|
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 app;
|
||||||
pub mod audit;
|
|
||||||
pub mod bunker;
|
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use keynectr::app::App;
|
use keynectr::app::App;
|
||||||
use keynectr::bunker::Signer;
|
|
||||||
use keynectr::errors::{AppError, ErrorKind};
|
use keynectr::errors::{AppError, ErrorKind};
|
||||||
use keynectr::ipc;
|
use keynectr::ipc;
|
||||||
use keynectr::profiles::{self, ProfileSummary};
|
use keynectr::profiles::{self, ProfileSummary};
|
||||||
use keynectr::publish;
|
use keynectr::publish;
|
||||||
use keynectr::relays;
|
use keynectr::relays;
|
||||||
use keynectr::settings::Theme;
|
use keynectr::settings::Theme;
|
||||||
|
use keynectr::signer::Signer;
|
||||||
use keynectr::vault::{self, StoredProfile, Vault};
|
use keynectr::vault::{self, StoredProfile, Vault};
|
||||||
|
|
||||||
const USAGE: &str = "\
|
const USAGE: &str = "\
|
||||||
|
|
@ -682,7 +682,6 @@ fn cli_undo_delete() -> Result<String, AppError> {
|
||||||
created_at: restored.created_at,
|
created_at: restored.created_at,
|
||||||
picture: restored.picture,
|
picture: restored.picture,
|
||||||
nip05: restored.nip05,
|
nip05: restored.nip05,
|
||||||
signer_mode: keynectr::vault::SignerMode::Embedded,
|
|
||||||
};
|
};
|
||||||
app.vault.profiles.push(stored);
|
app.vault.profiles.push(stored);
|
||||||
if app.vault.active_profile.is_none() {
|
if app.vault.active_profile.is_none() {
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ pub fn create_profile(
|
||||||
created_at,
|
created_at,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
signer_mode: crate::vault::SignerMode::Embedded,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_active = vault.active_profile.is_none();
|
let is_active = vault.active_profile.is_none();
|
||||||
|
|
@ -158,7 +157,6 @@ pub fn import_profile(
|
||||||
created_at,
|
created_at,
|
||||||
picture: metadata.as_ref().and_then(|m| m.picture.clone()),
|
picture: metadata.as_ref().and_then(|m| m.picture.clone()),
|
||||||
nip05: metadata.as_ref().and_then(|m| m.nip05.clone()),
|
nip05: metadata.as_ref().and_then(|m| m.nip05.clone()),
|
||||||
signer_mode: crate::vault::SignerMode::Embedded,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let relay_urls = relays::enabled_urls(settings);
|
let relay_urls = relays::enabled_urls(settings);
|
||||||
|
|
@ -249,6 +247,7 @@ pub fn set_profile_picture(
|
||||||
stored.picture.clone(),
|
stored.picture.clone(),
|
||||||
stored.nip05.clone(),
|
stored.nip05.clone(),
|
||||||
);
|
);
|
||||||
|
drop(stored);
|
||||||
let summary = ProfileSummary {
|
let summary = ProfileSummary {
|
||||||
label,
|
label,
|
||||||
npub,
|
npub,
|
||||||
|
|
@ -451,18 +450,6 @@ fn validate_picture_url(url: &str) -> Result<(), AppError> {
|
||||||
Ok(())
|
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> {
|
fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
|
||||||
vault
|
vault
|
||||||
.profiles
|
.profiles
|
||||||
|
|
@ -781,7 +768,6 @@ mod tests {
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
signer_mode: crate::vault::SignerMode::Embedded,
|
|
||||||
});
|
});
|
||||||
vault.profiles.push(StoredProfile {
|
vault.profiles.push(StoredProfile {
|
||||||
label: "Bob".to_string(),
|
label: "Bob".to_string(),
|
||||||
|
|
@ -790,7 +776,6 @@ mod tests {
|
||||||
created_at: 2,
|
created_at: 2,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
signer_mode: crate::vault::SignerMode::Embedded,
|
|
||||||
});
|
});
|
||||||
vault
|
vault
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::crypto::VaultKey;
|
use crate::crypto::VaultKey;
|
||||||
use crate::errors::{AppError, ErrorKind};
|
use crate::errors::{AppError, ErrorKind};
|
||||||
use crate::profiles;
|
use crate::profiles;
|
||||||
use crate::relays;
|
use crate::relays;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::signer::Signing;
|
|
||||||
use crate::vault::Vault;
|
use crate::vault::Vault;
|
||||||
|
|
||||||
/// How long to wait for a single relay to accept an event. Relays are sent
|
/// 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);
|
const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
|
|
||||||
/// A relay that rejected a published note.
|
/// A relay that rejected a published note.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct RelayFailure {
|
pub struct RelayFailure {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// Concise, user-facing reason.
|
/// Concise, user-facing reason.
|
||||||
|
|
@ -57,8 +56,8 @@ pub async fn publish_active(
|
||||||
validate_content(content)?;
|
validate_content(content)?;
|
||||||
let secret_hex = profiles::resolve_active_secret_key(vault, key)?;
|
let secret_hex = profiles::resolve_active_secret_key(vault, key)?;
|
||||||
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
||||||
let signing = Signing::Local(Keys::new(secret_key));
|
let keys = Keys::new(secret_key);
|
||||||
publish_with_keys(settings, content, &signing).await
|
publish_with_keys(settings, content, &keys).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a text note as a specific profile (used by the CLI).
|
/// Publish a text note as a specific profile (used by the CLI).
|
||||||
|
|
@ -74,8 +73,8 @@ pub async fn publish_as(
|
||||||
validate_content(content)?;
|
validate_content(content)?;
|
||||||
let secret_hex = profiles::resolve_secret_key(vault, npub, key)?;
|
let secret_hex = profiles::resolve_secret_key(vault, npub, key)?;
|
||||||
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
||||||
let signing = Signing::Local(Keys::new(secret_key));
|
let keys = Keys::new(secret_key);
|
||||||
publish_with_keys(settings, content, &signing).await
|
publish_with_keys(settings, content, &keys).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reject empty notes before any key or network work happens.
|
/// 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(
|
async fn publish_with_keys(
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
content: &str,
|
content: &str,
|
||||||
signing: &Signing,
|
keys: &Keys,
|
||||||
) -> Result<PublishReport, AppError> {
|
) -> Result<PublishReport, AppError> {
|
||||||
let content = content.trim();
|
let content = content.trim();
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
|
|
@ -164,43 +163,21 @@ async fn publish_with_keys(
|
||||||
return Err(AppError::no_enabled_relays());
|
return Err(AppError::no_enabled_relays());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract &Keys from Signing::Local for EventBuilder operations.
|
// Sign locally before touching the network so a signing failure is
|
||||||
// Currently Signing::Local is used from publish_active/publish_as,
|
// reported as such rather than as a network error.
|
||||||
// but the pattern supports External signers in the future.
|
|
||||||
let keys = match signing {
|
|
||||||
Signing::Local(k) => k,
|
|
||||||
Signing::External {
|
|
||||||
signer: _,
|
|
||||||
profile_pubkey: _,
|
|
||||||
} => {
|
|
||||||
return Err(AppError::sign_failed(
|
|
||||||
"External signer not yet supported in publish_with_keys",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the unsigned event.
|
|
||||||
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
|
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
|
||||||
let unsigned = builder
|
let event = builder
|
||||||
.finalize_async(keys)
|
.finalize_async(keys)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
|
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
|
||||||
|
|
||||||
// Sign the event through the Signing trait (routes to Keys::sign_event or
|
let event_id = event
|
||||||
// Signer::sign_event depending on the variant). This is the core refactor:
|
|
||||||
// the IPC layer no longer calls Keys::sign_event directly.
|
|
||||||
let signed = signing
|
|
||||||
.sign(unsigned.into())
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
|
|
||||||
|
|
||||||
let event_id = signed
|
|
||||||
.id
|
.id
|
||||||
.to_bech32()
|
.to_bech32()
|
||||||
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
|
.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 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() {
|
if succeeded.is_empty() {
|
||||||
return Err(AppError::publish_failed(failed));
|
return Err(AppError::publish_failed(failed));
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,6 @@ pub struct SignerStatus {
|
||||||
pub peer: Option<String>,
|
pub peer: Option<String>,
|
||||||
/// Relays used for the connection.
|
/// Relays used for the connection.
|
||||||
pub relays: Vec<String>,
|
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.
|
/// A user-facing error if the signer stopped because of one.
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
/// Requests currently waiting for the user to approve or reject them.
|
/// Requests currently waiting for the user to approve or reject them.
|
||||||
|
|
@ -93,7 +89,6 @@ struct SignerInner {
|
||||||
phase: SignerPhase,
|
phase: SignerPhase,
|
||||||
peer: Option<PublicKey>,
|
peer: Option<PublicKey>,
|
||||||
relays: Vec<String>,
|
relays: Vec<String>,
|
||||||
connected_relays: Vec<String>,
|
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
task: Option<tokio::task::JoinHandle<()>>,
|
task: Option<tokio::task::JoinHandle<()>>,
|
||||||
/// Requests waiting for the user to approve or reject, keyed by an
|
/// Requests waiting for the user to approve or reject, keyed by an
|
||||||
|
|
@ -123,7 +118,6 @@ impl Signer {
|
||||||
phase: SignerPhase::Stopped,
|
phase: SignerPhase::Stopped,
|
||||||
peer: None,
|
peer: None,
|
||||||
relays: Vec::new(),
|
relays: Vec::new(),
|
||||||
connected_relays: Vec::new(),
|
|
||||||
error: None,
|
error: None,
|
||||||
task: None,
|
task: None,
|
||||||
pending: HashMap::new(),
|
pending: HashMap::new(),
|
||||||
|
|
@ -148,7 +142,6 @@ impl Signer {
|
||||||
phase: inner.phase,
|
phase: inner.phase,
|
||||||
peer: inner.peer.map(|pk| pk.to_hex()),
|
peer: inner.peer.map(|pk| pk.to_hex()),
|
||||||
relays: inner.relays.clone(),
|
relays: inner.relays.clone(),
|
||||||
connected_relays: inner.connected_relays.clone(),
|
|
||||||
error: inner.error.clone(),
|
error: inner.error.clone(),
|
||||||
pending,
|
pending,
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +157,6 @@ impl Signer {
|
||||||
inner.phase = SignerPhase::Stopped;
|
inner.phase = SignerPhase::Stopped;
|
||||||
inner.peer = None;
|
inner.peer = None;
|
||||||
inner.relays.clear();
|
inner.relays.clear();
|
||||||
inner.connected_relays.clear();
|
|
||||||
inner.error = None;
|
inner.error = None;
|
||||||
inner.pending.clear();
|
inner.pending.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +259,6 @@ impl Signer {
|
||||||
inner.phase = SignerPhase::Connecting;
|
inner.phase = SignerPhase::Connecting;
|
||||||
inner.peer = Some(parsed.peer);
|
inner.peer = Some(parsed.peer);
|
||||||
inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect();
|
inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect();
|
||||||
inner.connected_relays.clear();
|
|
||||||
inner.error = None;
|
inner.error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,7 +272,6 @@ impl Signer {
|
||||||
inner.phase = SignerPhase::Stopped;
|
inner.phase = SignerPhase::Stopped;
|
||||||
inner.error = Some(message.into());
|
inner.error = Some(message.into());
|
||||||
inner.task = None;
|
inner.task = None;
|
||||||
inner.connected_relays.clear();
|
|
||||||
inner.pending.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,
|
/// The background loop: connect to the client's relays, announce ourselves,
|
||||||
/// subscribe to kind 24133 events, and answer requests until stopped.
|
/// 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) {
|
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;
|
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.
|
// 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
|
// 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
|
// 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());
|
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]
|
#[tokio::test]
|
||||||
async fn pending_approvals_are_capped() {
|
async fn pending_approvals_are_capped() {
|
||||||
let signer = Signer::new();
|
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::general_purpose::STANDARD as B64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
use crate::errors::AppError;
|
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.
|
/// Current vault schema version.
|
||||||
pub const VAULT_VERSION: u32 = 3;
|
pub const VAULT_VERSION: u32 = 2;
|
||||||
/// Filename of the profiles vault.
|
/// Filename of the profiles vault.
|
||||||
pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
|
pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
|
||||||
/// Filename of the settings file.
|
/// Filename of the settings file.
|
||||||
pub const SETTINGS_FILE_NAME: &str = "settings.json";
|
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.
|
/// A profile stored on disk.
|
||||||
///
|
///
|
||||||
|
|
@ -62,10 +44,6 @@ pub struct StoredProfile {
|
||||||
/// part of kind 0 metadata so clients show a human handle.
|
/// part of kind 0 metadata so clients show a human handle.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub nip05: Option<String>,
|
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
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub crypto: Option<VaultCrypto>,
|
pub crypto: Option<VaultCrypto>,
|
||||||
pub profiles: Vec<StoredProfile>,
|
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 {
|
impl Vault {
|
||||||
|
|
@ -150,8 +95,6 @@ impl Vault {
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
crypto: None,
|
crypto: None,
|
||||||
profiles: Vec::new(),
|
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)
|
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.
|
/// Candidate locations for a legacy vault created by the old CLI version.
|
||||||
///
|
///
|
||||||
/// The old application wrote `profiles_vault.json` in its working directory.
|
/// 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,
|
active_profile: None,
|
||||||
crypto: None,
|
crypto: None,
|
||||||
profiles,
|
profiles,
|
||||||
nip46_connections: Vec::new(),
|
|
||||||
connection_secrets: Vec::new(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}")))
|
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
|
/// Persist the vault to the stable application-data location with
|
||||||
/// restrictive permissions.
|
/// restrictive permissions.
|
||||||
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
|
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
|
||||||
|
|
@ -667,7 +463,6 @@ mod tests {
|
||||||
created_at: 1_700_000_000,
|
created_at: 1_700_000_000,
|
||||||
picture: None,
|
picture: None,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
signer_mode: SignerMode::Embedded,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -822,189 +617,4 @@ mod tests {
|
||||||
fs::write(&path, encrypted).unwrap();
|
fs::write(&path, encrypted).unwrap();
|
||||||
assert!(is_populated_vault_file(&path));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||