Compare commits

..

48 commits

Author SHA1 Message Date
Avi
1d5940fb82 checkpoint: Linux display compatibility + icon alpha fixes (2026-09-09) 2026-09-09 20:19:10 -05:00
Avi
d580139e5a fix(icons): true alpha channel, no white matte or white tile
- public/icon.png and src/assets/logo.png were grayscale (mode L): a flat
  white field, no alpha, which rendered as a white box/halo on every
  non-white surface (window/taskbar icon, sidebar, launchers)
- regenerate both as RGBA: alpha is the ink coverage, RGB forced to 0 so
  no white matte can leak through semi-transparent edge pixels
- styles.css: drop the white tile background/border-radius and cover-fit
  from .sidebar-logo; the artwork now composites directly (contain-fit)
- originals preserved under deferred/original-icons/
2026-09-09 19:58:56 -05:00
Avi
0814a53cb4 fix(linux): work on X11, Wayland, and Hyprland
- detect the session platform explicitly (Hyprland exports both DISPLAY
  and WAYLAND_DISPLAY) and set ozone-platform before Chromium init
- software rendering by default on Linux: the GPU process segfaults in
  eglCreateWindowSurface on some Mesa/Wayland setups (reproduced on
  Intel Iris Xe under Hyprland), so hardware GL is opt-in via
  KEYNCTR_ENABLE_GPU=1
- startup watchdog + bounded relaunch ladder (platform swap, then GPU
  opt-in) when a launch dies before its window paints; give-up dialog
  lists the escape hatches
- sandbox pre-flight: skip the SUID sandbox when user namespaces are
  restricted (Ubuntu 24.04 AppArmor) instead of failing silently
2026-09-09 19:58:38 -05:00
Avi
715c99c688 checkpoint: document NIP-46 connection-secrets vault integration (Step 3 sub-step 1) 2026-09-04 16:56:59 -05:00
Avi
510cb65e2f feat(signer): store NIP-46 connection secrets in the vault, keyed by VaultRef
The nostrconnect secret is a credential, so it no longer lives inline on
Nip46Connection (which can be serialized/shown to the UI). It is now stored in
the vault's encrypted connection_secrets store, keyed by the opaque VaultRef
(profile npub + remote signer pubkey), encrypted under the vault key, and
resolved only at the vault boundary while unlocked (fail-closed when locked).

- vault.rs: add ConnectionSecret + connection_secrets store; store/resolve/delete
  helpers (encrypt when password-protected, Zeroizing on decrypt).
- types.rs: drop the inline secret field from Nip46Connection (legacy inline
  secrets deserialize fine and are dropped on next save).
- backend.rs: VaultRef::from_connection; profile_npub now Option (connections
  may exist with no local profile).
- nip46_client.rs: resolve the connect secret on-demand from the vault (single
  source of truth) instead of an in-memory copy; drop it on disconnect.
- publish.rs: sign through the Signing trait instead of Keys::sign_event directly.
2026-09-04 16:53:32 -05:00
Avi
b2755d8343 refactor(signer): Signer trait now returns SigningError instead of AppError
Completes the approved Step 3 API (the enum + error types landed in
e6e4922; this closes the trait half):

- Signer trait: get_public_key, sign_event, pubkey_for (default),
  disconnect, revoke all return Result<_, SigningError>
- Signing enum wrapper (pubkey/sign) returns SigningError; local sign
  failures map to InvalidSignature, external identity/verify failures to
  IdentityMismatch/InvalidSignature
- EmbeddedSigner::resolve_keys + respond_to_approval speak SigningError;
  AppError from vault/profile ops converts via SigningError::from_app
- Nip46ClientSigner: inherent methods (connect/disconnect/revoke/
  parse_connect_uri/permissions/nip44) keep AppError; the trait impl
  wraps them with from_app
- No mode branching introduced; IPC boundary conversion
  (From<SigningError> for AppError) already exists

Verification: cargo test 196 passed, clippy clean, fmt clean
2026-09-03 18:24:31 -05:00
Avi
a2307ac475 checkpoint: document SigningBackend + SigningError foundation (Step 3) 2026-09-03 15:55:33 -05:00
Avi
e6e49222ea feat(signer): add SigningBackend + SigningError + VaultRef
Introduce the Step-3 signing-abstraction types:

- SigningBackend { Internal, Remote { vault_ref } } — the per-profile
  choice of where user content is signed. Remote holds ONLY an opaque
  VaultRef (profile_npub + signer_pubkey); the NIP-46 connection
  secret is never inlined, so a serialized or leaked backend can never
  hand a raw secret to a renderer, the audit log, or a crash dump.
- VaultRef — opaque, secret-free pointer into the vault's encrypted
  connection-secret store (resolves to the decrypted secret only at the
  vault boundary, while unlocked).
- SigningError — closed set of signing failures with a stable ErrorKind,
  user-facing message, and optional technical detail; converts to
  AppError at the IPC boundary via From, plus a best-effort from_app lift.

10 unit tests, incl. the constraint that serializing a Remote backend
never emits a secret. This is the foundation commit; vault integration
and IPC rerouting land in follow-ups.
2026-09-03 15:52:25 -05:00
Avi
ee88171e45 checkpoint: document packaging icon fix (post-Step 2)
HEAD moved to 114b343 (icon restored); the previously-deleted
frontend/build/icon.png is now a committed asset, no longer a leftover.
Records the verified npm run dist + AppImage/deb icon proof, the
pre-existing format:check failure (5 files, Step-7), and notes the
homepage rename is on the record for Step 7 (not fixed). Step 3 signer
API is proposed and awaiting sign-off, not implemented.
2026-09-03 13:14:44 -05:00
Avi
114b34319e fix(packaging): restore Linux app icon so dist builds ship an icon
The packaging break: 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 instead of the Keynctr
mark.

Regenerate build/icon.png from KeynectrAppIconPossibility02.jpeg rather than
resizing the old file:
  - cut the white (254) JPEG background to transparent (alpha from luma),
  - flatten the art to a square canvas with symmetric padding,
  - keep the ink pure black (RGB 0,0,0), export 512x512 RGBA.

The 512x512 master satisfies both declared linux targets with the config
kept single (linux.icon: build/icon.png, no build/linux/ fan-out):
  - AppImage: electron-builder downscales to 256 internally (>=256 required).
  - deb: installs usr/share/icons/hicolor/512x512/apps/keynectr.png, matching
    the generated .desktop Icon=keynectr.

Verified end to end (npm run dist green): the embedded icon is byte-identical
(md5 b3e372f7) in the AppImage hicolor set, the AppImage .DirIcon, and the
deb hicolor set, all 512x512 RGBA with real transparency.

The source JPEG (KeynectrAppIconPossibility02.jpeg) stays untracked, as does
the pre-existing hygiene leftover set. No package.json change needed: the
single build/icon.png path is already correct.
2026-09-03 13:13:11 -05:00
Avi
d92371fbc2 docs: checkpoint signer modes + fail-closed key export (post-triage)
Re-point the checkpoint at HEAD 6eff510 and document the three-session
commit split (audit log -> signer modes -> fail-closed export), the real
file list, per-commit verification results, and the remaining
uncommitted packaging icon + hygiene leftovers.
2026-09-03 09:53:58 -05:00
Avi
6eff510609 feat: fail-closed ExportSecretKey with fresh auth and audit
Replace the plain reveal_secret_key flow with an explicit, audited key
export that is hard to misuse:

Backend (src/app.rs, src/ipc.rs):
- New App::export_secret_key(): always requires the vault passphrase
  (even when the vault is already unlocked), requires a non-blank reason,
  and resolves the profile server-side via profiles::find_stored_profile.
- Refuses export for Nip46Client (external) profiles — the secret key is
  not present locally — logging the denial.
- FAIL-CLOSED: the successful audit entry is written and flushed BEFORE the
  key is returned; if the audit write fails, the key is not returned
  (log.record(...)? instead of let _ =).
- Audit entries are written on every outcome: external-profile denial,
  wrong password, and the successful export.
- RevealSecretKey IPC is deprecated: it now errors when the vault is locked
  and, when unlocked, records a [deprecated direct call] audit entry.
  The method stays registered for the deprecation window.

Frontend:
- ExportSecretKeyModal requires password + reason every time; clears
  sensitive state on close.
- ProfilesScreen uses ExportSecretKeyModal; ShowSecretKeyModal and its test
  are removed. AppProvider exposes exportSecretKey (revealSecretKey gone);
  api.ts maps to export_secret_key.
- fakeBackend implements the full export contract (profile-not-found,
  external-signer refusal, password check, blank-reason rejection); apiMock
  exposes exportSecretKey. 10 tests cover the required scenarios.

No secret material is logged; the reason is logged by design. Verified:
cargo test --release 186 passed; frontend tsc clean, vitest 116 passed.
2026-09-03 09:50:32 -05:00
Avi
2c61830390 feat: add per-profile signer modes with persisted NIP-46 connections
Introduce three coexisting signing modes:
- Embedded (INTERNAL): vault-held nsec, decrypted in Rust, signs locally.
- Nip46Client (EXTERNAL): Keynctr is the NIP-46 CLIENT; the key never
  touches this machine.
- Nip46Bunker: legacy inverted mode (Keynctr as signer serving others).

Data model:
- StoredProfile gains signer_mode (serde-defaults to Embedded for legacy
  profiles); SignerMode moves from app.rs to vault.rs to break a circular
  dependency; app.rs re-exports it.
- Vault gains nip46_connections (profile-owned) and bumps VAULT_VERSION to
  3; migrate_vault_signer_modes() normalises on load (idempotent).
- Nip46Connection gains profile_npub ownership, parsed permissions,
  expires_at, and revoked_at.

Signer abstraction (src/signer):
- Signer trait gains pubkey_for() identity validation, a Signing enum
  (Local vs External) that re-verifies the returned event, and a permission
  surface (permissions/can_*/is_connection_valid) with safe defaults.
- permissions.rs: NIP-46 per-connection permission model (parse, validate,
  deny-by-default, no-broadening checks) with 52 unit tests.
- Nip46ClientSigner parses perms from nostrconnect:// URIs, enforces
  permissions on every gated request, persists/revokes connections in the
  vault, and audits permission denials via the app's audit log.
- App gains audit_log and a nip46_bunker_signer handle; default mode is
  Nip46Client (most secure).

Frontend: SignerModeScreen redesigned for the three modes with a
nostr-tools-based SignerManager client, new IPC allowlist entries, and
signer-mode styling.

Verified: cargo test --release 186 passed; clippy/fmt clean; frontend tsc
clean, vitest 110 passed.
2026-09-03 09:47:19 -05:00
Avi
caed722b06 feat: add hash-chained, append-only audit log
Introduce src/audit.rs: a SHA-256 hash-chained audit log for
security-sensitive operations (key export, NIP-46 connect/revoke,
signing approvals, vault lock/unlock, permission denials).

- AuditEntry carries timestamp, profile npub, action, reason,
  success flag, error, and prev/this hash forming a tamper-evident
  chain from a genesis hash.
- record() holds a single mutex across the entire read-compute-write-
  update cycle so concurrent writers cannot interleave and silently
  overwrite entries; appends are atomic (write-all + fsync + rename).
- verify_chain() re-hashes every entry end to end.
- Log lives in the app data dir with 0600 permissions.

Also adds the sha2 dependency. Verified in isolation on top of HEAD:
cargo test --release -> 124 passed (119 prior + 5 audit).
2026-09-03 09:21:53 -05:00
Avi
4038e2d32a checkpoint: document embedded signer and NIP-46 client modes 2026-09-01 20:18:38 -05:00
Avi
b484bdeb08 feat: add embedded signer and NIP-46 client signer modes
- Add Signer trait with common interface for both signing modes
- Implement EmbeddedSigner: keys stored in encrypted vault (Argon2id + AES-256-GCM)
- Implement Nip46ClientSigner: connects to remote signer via nostrconnect:// URI
- Support both local and remote NIP-46 signers
- Add signer mode selection UI (SignerModeScreen)
- Add IPC endpoints for signer mode management, embedded signer, and NIP-46 client
- Update frontend types, API, and AppProvider
- All tests pass (119 Rust + 110 frontend)
2026-09-01 20:16:14 -05:00
Avi
aabc22553f checkpoint: document publication filtering change 2026-09-01 14:08:21 -05:00
Avi
ea56806c0c fix: show only fully published events in Most Recent Publication box
- Add PublicationStatus type and computePublicationStatus() helper
- Add useProfilePublications hook that queries relays via feedGet and
  determines per-event publication status by comparing served relays
  against all enabled relays
- Rewrite HomeScreen PublicationResult to show only fully published
  events in the main box; partial events appear only in the expandable
  Relay results section
- Show empty state when no fully published events exist
- Add tests: fully published shown, partial hidden, older full shown
  when newest is partial, all-partial shows empty, relay details
  expandable, no duplicates
- Fix publishFlow integration test to seed profileFeedItems
2026-09-01 14:07:36 -05:00
Avi
9cf4003c31 checkpoint: document note preview fix 2026-09-01 13:48:07 -05:00
Avi
577e9f4711 fix: return StoredPublishReport with content to frontend after publish 2026-09-01 13:47:36 -05:00
Avi
a017dd10aa checkpoint: document inline post preview 2026-09-01 13:06:55 -05:00
Avi
cd5d2d7fd1 Show published note content inline on Home screen 2026-09-01 13:06:33 -05:00
Avi
9c582afe3d checkpoint: document last publish persistence 2026-09-01 12:33:09 -05:00
Avi
bab82f5148 Persist last publish report across restarts 2026-09-01 12:32:28 -05:00
Avi
e936524f72 checkpoint: document View in Feed button 2026-09-01 12:14:17 -05:00
Avi
b0d9143228 Add 'View in Feed' button after publishing a note 2026-09-01 12:13:48 -05:00
Avi
4de8b729c1 checkpoint: document polish pass 2026-09-01 12:02:46 -05:00
Avi
471acf8125 polish: align HomeScreen spacing to design scale, add profile row hover 2026-09-01 12:02:17 -05:00
Avi
a9ea3d1350 checkpoint: document identity card removal 2026-09-01 11:58:31 -05:00
Avi
269f0c0230 Remove identity card from HomeScreen — redundant with subtitle and profile list 2026-09-01 11:58:15 -05:00
Avi
07977d4d16 checkpoint: document identity card heading removal 2026-09-01 11:49:09 -05:00
Avi
566aa466b9 Remove redundant 'Active profile' heading from identity card 2026-09-01 11:48:43 -05:00
Avi
cb63358afa checkpoint: document polish cleanup 2026-09-01 11:38:53 -05:00
Avi
85ba0889eb fix(polish): clean up HomeScreen dead code and token drift
- Remove dead .active-profile-row CSS (unused class)
- Replace hardcoded rgba fallbacks in .home-profile-row.is-active with
  design tokens (var(--surface-2), var(--border))
- Remove duplicate edit icon from publication empty state (icon was
  shown above the button, redundant with the button's own icon)
2026-09-01 11:38:21 -05:00
Avi
e41598d24f checkpoint: document identity card simplification 2026-09-01 11:33:45 -05:00
Avi
e854c95d41 fix(polish): remove identity card kicker, flatten to success border
- Remove 'Active signing identity' kicker text
- Card uses flat surface background with var(--success) border
  (matches active profile card treatment)
- Remove dead .home-identity-kicker CSS
- Update test to find card by 'Active profile' heading
2026-09-01 11:32:34 -05:00
Avi
d93af86c1a checkpoint: document compose label alignment 2026-09-01 11:28:15 -05:00
Avi
ee13d47aeb fix(polish): align Compose button label with sidebar nav
- HomeScreen header button: 'Compose note' → 'Compose' (matches sidebar)
- Updated tests to use exact name match and scoped queries to avoid
  matching the sidebar nav button
2026-09-01 11:27:23 -05:00
Avi
8461dd3a5d checkpoint: document layout pass and re-critique (27→30/40) 2026-09-01 11:21:05 -05:00
Avi
32fc764d54 fix(polish): restore success-soft background on identity card
Flat var(--success-soft) instead of the removed gradient — keeps the
green tint that signals active security state without breaking the
flat-by-default rule.
2026-09-01 11:18:56 -05:00
Avi
b05894efc7 fix(layout): redesign first-run guide with visual step indicators
- Replace plain numbered list with icon circles (primary-soft bg)
- Each step uses flex layout: indicator + content
- Step titles and body text have clear hierarchy
- Remove legacy padding-left on ol, use gap-based spacing
- Consistent 14px font size for step content
2026-09-01 11:17:20 -05:00
Avi
96dcbe4264 fix(polish): align HomeScreen with design system
- Remove identity card gradient (flat surface + success border per
  flat-by-default rule)
- Fix profile row border-radius from 8px to var(--radius-sm) (9px)
- Improve publication empty state: centered layout with icon + button
  instead of inline sentence
- Normalize home-identity-name font-size to 16px (matches profile-name)
- Remove redundant grid-template-columns from home-grid
2026-09-01 11:12:49 -05:00
Avi
a47ce8b9ef checkpoint: document keyboard accessibility hardening 2026-09-01 11:07:49 -05:00
Avi
e9022b36b9 fix(a11y): add keyboard accessibility to HomeScreen profile list
- Profile list rows are now focusable (tabIndex) and operable via
  Enter/Space (onKeyDown handler)
- Added role=listbox on the <ul> and role=option + aria-selected on
  non-active rows
- Added descriptive aria-label including profile name and active state
- Added focus-visible CSS ring matching the design system pattern
- Updated test role from 'list' to 'listbox'
2026-09-01 11:07:42 -05:00
Avi
f1236e7eb8 checkpoint: document clippy warning cleanup 2026-09-01 10:46:25 -05:00
Avi
3083a44e02 chore: fix two clippy warnings
Drop the no-op drop(stored) of a &mut reference in profiles.rs and stop
binding the unused ProfileSummary returned by app.undo_delete() in the
IPC UndoDelete handler. Clippy --all-targets is now warning-free.
2026-09-01 10:45:44 -05:00
Avi
bae0892751 checkpoint: document profile import over IPC 2026-09-01 10:42:14 -05:00
Avi
0207636a7a feat: expose profile import over IPC
Wire the existing profiles::import_profile through the JSON-lines IPC
protocol so the GUI can add an existing account: ImportProfile request
and handler in src/ipc.rs, import_profile added to the Electron method
allowlist, api/AppProvider importProfile, and the Add existing account
buttons in ProfilesScreen.

Also add the active signing identity card on Home and fix the HomeScreen
tests whose text queries now match the identity card as well as the
profile row.
2026-09-01 10:41:28 -05:00
53 changed files with 8772 additions and 555 deletions

View file

@ -0,0 +1,103 @@
# Checkpoint — Linux display compatibility + icon alpha fixes (2026-09-09)
## Where things are
- Project: `/home/avi/Projects/Keynctr`
- Branch: `master` @ **`d580139`** ("fix(icons): true alpha channel, no white matte or
white tile") on top of **`0814a53`** ("fix(linux): work on X11, Wayland, and Hyprland").
- Working tree: **clean for tracked files.** Untracked leftovers are the pre-existing
hygiene entries (`.directory`, `.impeccable/`, `.opencode/`, `COSMIC_THEME.md`,
`src/publish.rs.bak`, `src/signer/nip46_external.rs`) plus `deferred/SignerConnectionPanel.tsx.wip`
(a broken WIP component, moved out of the build — see below). Original white-background
icons are preserved under `deferred/original-icons/` (tracked).
## What was completed (this session)
### 1. Linux display-server compatibility — `0814a53`
The app did not start on a friend's Wayland machine. Root causes found and fixed:
- **No explicit platform choice.** Hyprland (and any Wayland session with XWayland)
exports both `$DISPLAY` and `$WAYLAND_DISPLAY`, so Electron must be told which
backend to use before Chromium initializes. `frontend/electron/main.ts` now sets
`ozone-platform` (wayland/x11) from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY` at module
load, with `KEYNCTR_FORCE_X11=1` / `KEYNCTR_FORCE_WAYLAND=1` overrides.
- **GPU process crashes (SIGSEGV in `eglCreateWindowSurface`, Mesa `libGLESv2`).**
Reproduced on this box (Intel Iris Xe, Hyprland, mesa 26.2.1): with hardware GL the
GPU helper died repeatedly and the window never appeared. Fix: **software rendering
by default on Linux** (`app.disableHardwareAcceleration()`); hardware GL is opt-in
via `KEYNCTR_ENABLE_GPU=1`.
- **Startup watchdog + bounded relaunch ladder.** A marker file
(`<tmp>/keynctr-startup.json`, stamped clean on deliberate quit) records each launch;
if the previous process died before its window proved itself (painted and survived
8 s), the next launch advances one rung: detected platform → other platform → GPU
opt-in → other+GPU, then stops with an error dialog listing the escape hatches.
AppImage-safe relaunch via `$APPIMAGE`. No infinite cascades.
- **Sandbox pre-flight.** Packaged builds check for a non-setuid `chrome-sandbox`
combined with blocked unprivileged user namespaces (Ubuntu 24.04 AppArmor knob,
`unprivileged_userns_clone`) and fall back to `--no-sandbox` instead of dying
silently. Root also gets `--no-sandbox` as Chromium requires.
- Window now uses `show: false` + `ready-to-show` (always shown, even with the
watchdog disabled).
### 2. Icon white-fringe fix — `d580139`
The source icons were grayscale (mode **L**, no alpha at all): a black bird on a flat
white field, which rendered as a white box/halo on every non-white surface (window
icon, taskbar, sidebar, launchers).
- `frontend/public/icon.png` and `frontend/src/assets/logo.png` regenerated as
**RGBA**: alpha = ink coverage of the original artwork; RGB forced to 0 everywhere,
so no white matte can bleed through semi-transparent edge pixels (verified: 0 pixels
with RGB > 200 at alpha < 20). Artwork bbox/shape unchanged (IoU 1.0 vs originals).
- `frontend/src/styles.css`: `.sidebar-logo` dropped its `background: #fff` white tile,
`border-radius`, and `object-fit: cover`; the artwork now composites directly with
`contain`. Dark-theme `invert(1)` kept (ink artwork must flip on dark sidebars).
- Originals preserved: `deferred/original-icons/icon-public-512-white.png`,
`deferred/original-icons/logo-sidebar-338-white.png`.
### 3. Build hygiene (uncommitted by design? no — landed with the fixes)
- `frontend/src/components/signer/SignerConnectionPanel.tsx` was an untracked,
non-compiling WIP (broken `useCallback` closures, APIs that don't exist on
`SignerManager`, dependency on uninstalled `react-router-dom`) that blocked
`npm run typecheck`. Moved intact to `deferred/SignerConnectionPanel.tsx.wip`
(untracked) — nothing deleted; it needs a rewrite against the real hooks before
returning.
## Verification (all run this session)
- Rust: `cargo fmt --check` clean, `cargo clippy --all-targets` clean, `cargo test`
green, `cargo build --release` succeeded.
- Frontend: `npm run electron:build`, `npm run typecheck`, `npm run lint` clean;
`npm test` **116/116 passed**; `npx prettier --check electron/main.ts` and
`src/styles.css` clean; `npm run build` succeeded. (5 pre-existing Prettier warnings
in untouched files — `ExportSecretKeyModal.tsx`, `SignerModeScreen.tsx`,
`AppProvider.tsx`, `ExportSecretKey.test.tsx`, `fakeBackend.ts` — predate this
session and were left alone.)
- **On-device (Hyprland/Wayland, this machine):** app launched under a clean systemd
user scope: Keynctr window mapped (`class: keynectr`), watchdog marker cleared
(= config proven), **no new Electron core dumps** after 19:40 while multiple
software-render launches ran. `grim` screenshot + visual inspection confirmed the
sidebar bird sits directly on the sidebar with **no white tile, border, or halo**.
- Icon proof: checkerboard composite of the new `public/icon.png` shows clean
anti-aliased edges into transparency, no white fringe.
## How to run / reproduce
- GUI: `cd frontend && npm start` (or the packaged AppImage/deb once rebuilt via
`npm run dist`).
- Escape hatches: `KEYNCTR_FORCE_X11=1`, `KEYNCTR_FORCE_WAYLAND=1`,
`KEYNCTR_ENABLE_GPU=1`, `KEYNCTR_DISABLE_GPU=1`, `KEYNCTR_NO_RELAUNCH=1`.
- CLI: `cargo run --release -- serve` (JSON-lines IPC) as before.
## Outstanding / next steps
- **Repackage for the friend:** `npm run dist` (AppImage + deb) with the new icon and
display fixes; the old `frontend/release/` artifacts predate both commits.
- `deferred/SignerConnectionPanel.tsx.wip`: rewrite against the real `useSignerManager`
API (+ either add `react-router-dom` or drop the import) before reinstating.
- Consider a taskbar-visible test on a pure-X11 session and on GNOME Wayland for the
friend matrix (only Hyprland/Wayland was verifiable here).
- Step 3 sub-step 2 (IPC reroute) is untouched and remains the next signer milestone.
- The user's crash-reporter still holds old core dumps from pre-fix launches
(`coredumpctl rm` clears them).

View file

@ -1,54 +1,203 @@
# Checkpoint — Release packages with profile metadata fix (2026-09-01)
# Checkpoint — NIP-46 Connection Secrets in the Vault (2026-09-04)
## Where things are
- Project: `/home/avi/Projects/Keynctr`
- Git repo: `master` @ `3107508` ("fix: query imported metadata by relay").
- Working tree: intended profile metadata fix is committed; unrelated UI/branding changes remain uncommitted and untracked.
- Branch: `master` @ **`510cb65`** ("feat(signer): store NIP-46 connection secrets in
the vault, keyed by VaultRef").
- Working tree: **clean for tracked files.** No tracked file is modified or staged.
The only untracked entries are the pre-existing hygiene leftovers and the source
JPEG (all intentionally untracked — see "Still untracked"). Build artifacts
(`release/`, `dist/`) are gitignored.
## What was completed
1. **Cosmic branding update.** The Cosmic theme now uses Gold `#F3B407` and Light Blue `#87E6FB`; Cosmic’s dark sidebar presents the logo in white while retaining black-on-white artwork elsewhere. The visible brand is `SOLARPUNK SUMMIT` with `KITCHEN 484`.
2. **Fixed broken profile delete/undo** (previous). `src/ipc.rs` missing `DeleteProfile`/`UndoDelete`; added handlers returning `state_view`; exposed `profiles::delete_profile` outside tests; `api.ts`/`AppProvider` now `call<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`.
## What was completed (this session)
## Commits added in this session (newest first)
- `3107508` fix: query imported metadata by relay
- `da33652` fix: query imported metadata by public key
- `bde35bc` fix: import existing profile metadata
- `d2d773a` fix: remove Stardust background dots
- `7605f51` fix: keep NIP-46 signer subscription open
- `76deca6` feat: add Cosmic theme + motion system (palette/branding refinements currently uncommitted)
- `8366af7` feat: add Impeccable themes (light + dark) + unified ProfileEditModal
- `832e114` checkpoint: fix delete/undo + themed auto-dismiss bar
- `9e635e7` fix: restore profile delete/undo and themed auto-dismiss undo bar
**Step 3 sub-step 1 (Vault integration) — landed as `510cb65`.** The NIP-46
nostrconnect `secret` is a credential, so it no longer lives inline on
`Nip46Connection` (which can be serialized and shown to the UI). It is now stored in
the vault's **encrypted `connection_secrets` store**, keyed by the opaque
`VaultRef` (profile npub + remote signer pubkey), encrypted under the vault key, and
resolved **only at the vault boundary while the vault is unlocked** (fail-closed when
locked). This is where the `vault_ref` constraint becomes load-bearing.
## Verification commands run
- Rust: `cargo fmt --check`, `cargo clippy --all-targets`, `cargo test` (115 passed), and `cargo build --release` passed; existing warnings remain in `src/ipc.rs` and `src/profiles.rs`.
- Frontend: `npm test` (15 files / 99 tests), `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run build`, and `npm run electron:build` passed.
- Packaging: `npx electron-builder --linux AppImage deb` passed; AppImage and Debian files verified with `file`.
- Branding verification: `npm test`, `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run electron:build`, `npm run build`, and `npx electron-builder --linux AppImage deb` passed.
- Frontend build no longer reports the Cosmic font `@import` ordering warning; standard Vite/ESM and ESLint module warnings remain.
- Wayland `--ozone-platform` / `has no handler` messages on `electron:build` are harmless.
- NIP-46 fix: use a persistent subscription instead of the auto-closing `stream_events` helper, so clients can send requests after EOSE.
- Stardust verification: `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` still reports existing issues in three unrelated frontend files.
- Existing-account import verification: `cargo test` (115 passed), `cargo clippy --all-targets`, `cargo fmt --check`, `cargo build --release`, `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` reports existing issues in three frontend files.
- Imported account naming: the name field is no longer required; kind-0 `display_name`/`name` is used automatically, with a shortened npub fallback. Verification: `cargo test` (116 passed), `cargo clippy --all-targets`, `cargo fmt --check`, `cargo build --release`, `npm test` (99 passed), `npm run typecheck`, `npm run lint`, `npm run electron:build`, and `npm run build` passed. `npm run format:check` still reports existing issues in three frontend files.
- Corrected metadata lookup to query with the derived public-key type directly, preventing silent fallback when importing accounts.
- Release verification: Rust test suite (116 passed), clippy, fmt, release build, frontend tests (99 passed), typecheck, lint, Electron build, production build, and AppImage/Debian packaging passed. Frontend format check retains three existing warnings.
- **`src/vault.rs`** — new `ConnectionSecret { ref_: VaultRef, secret }` struct and a
`Vault.connection_secrets: Vec<ConnectionSecret>` store (encrypted under the vault
key when password-protected, plaintext when the vault has no password — exactly
mirroring how profile secrets are handled). Three helpers:
- `store_connection_secret(vault, key, ref_, secret)` — upserts by `VaultRef`;
encrypts when the vault is password-protected, else stores plaintext. A locked
encrypted vault fails closed (`vault_locked`) rather than silently writing a
plaintext secret that would not match once unlocked. Replacing an existing
`VaultRef` never leaves a stale secret behind.
- `resolve_connection_secret(vault, key, ref_)` — decrypts (or returns plaintext)
for a `VaultRef`; `Ok(None)` when no secret is stored, `Err(vault_locked)` when
the vault is encrypted but locked. On success the plaintext is `Zeroizing`
(shredded on scope exit).
- `delete_connection_secret(vault, ref_)` — removes an entry (on disconnect).
- **`src/signer/types.rs`** — the inline `secret: Option<String>` field is **removed**
from `Nip46Connection`. Legacy vaults that still carry an inline `secret`
deserialize fine (serde ignores the absent field) and the dead secret is dropped on
the next save.
- **`src/signer/backend.rs`** — `VaultRef::from_connection(&Nip46Connection)` is the
canonical way a `SigningBackend::Remote` (and the secret store) is keyed by a
connection; `profile_npub` is now `Option<String>` (a connection may be created with
no local profile active).
- **`src/signer/nip46_client.rs`** — on `connect`, the parsed nostrconnect secret is
written to the vault store (via `VaultRef::from_connection`) instead of being kept in
memory (`Nip46Inner.connect_secret` removed). On `disconnect` the stored secret is
dropped. In `run_sign_task`/`send_connect`, the connect secret is now resolved
**on-demand from the vault** (the single source of truth) rather than read from an
in-memory copy — a locked vault refuses the connect instead of sending it without the
secret.
- **`src/publish.rs`** — `publish_with_keys` now takes `&Signing` and signs through the
`Signing` trait (routes to `Keys::sign_event` for `Local`, or the remote signer for
`External`), instead of calling `Keys::sign_event` directly. `publish_active` /
`publish_as` wrap their keys in `Signing::Local`. (External signing in the publish
path is not wired end-to-end yet — it returns a clear "not yet supported" error —
but the routing pattern is in place for the IPC reroute.)
- **`src/signer/permissions.rs`** — test fixtures updated for the removed inline
`secret` field.
## How to resume / reproduce
GUI (Cosmic): `cargo build --release && cd frontend && npm run build && npm run electron:build && npm start` (or dev: `npm run dev` in one terminal + `NOSTR_GUI_DEV_URL=http://localhost:5173 npm start` in second). Settings → Appearance → `Cosmic — Stardust`. CLI: `cargo run -- settings set theme cosmic`.
- Build installers: `cd frontend && npm run build && npm run electron:build && npx electron-builder --linux AppImage deb`. Install the `.deb` with `sudo apt install ./release/keynectr_0.1.0_amd64.deb`, or run the AppImage with `./release/SOLARPUNK\ SUMMIT-0.1.0.AppImage`.
- Current installers: `sudo apt install ./release/keynectr_0.1.0_amd64.deb`, or `./release/Keynctr-0.1.0.AppImage`.
- Signer GUI: unlock vault, open `Signer`, select remote signer in the client, paste its `nostrconnect://` URI, then approve requests. CLI: `cargo run --release -- signer connect <nostrconnect://...>`.
- Stardust GUI: select `Settings -> Appearance -> Cosmic - Stardust`, then restart the frontend to load the updated CSS bundle.
- Import GUI: restart after rebuilding, open `Profiles -> Add existing account`, enter only the private key, and Keynctr will derive the profile name and metadata from the network.
- Release artifacts: `frontend/release/Keynctr-0.1.0.AppImage` and `frontend/release/keynectr_0.1.0_amd64.deb`, verified with `file`.
Security properties: no secret material is logged; the connect secret is resolved
fresh from the vault at send time (fail-closed on a locked vault); a serialized
`Nip46Connection` or `SigningBackend::Remote` carries zero secret material; the
decrypted plaintext is `Zeroizing`.
## Outstanding / next-step items
- Undo restores with empty `secret_key` (stores `ProfileSummary`); needs `StoredProfile` in `undo_history` for full secret recovery.
- `.opencode/`, `COSMIC_THEME.md`, and `KeynectrAppIconPossibility02.jpeg` remain untracked; no commit was created in this session.
- Installer artifacts are local build outputs under `frontend/release/` and are not committed.
The previously-landed foundation (`e6e4922` `SigningBackend`/`SigningError`/`VaultRef`,
`b2755d8` `Signer` trait returning `SigningError`) is unchanged by this commit — it is
what this sub-step wires up.
---
The previously-uncommitted working tree (27 modified + 10 untracked files, ~1852/692)
was triaged into **three logical, independently-verifiable commits** in a prior session,
and the packaging fix landed in `114b343`. Order is
`a → c → b → (packaging)`: `ExportSecretKey` (b) reads the per-profile `signer_mode`
field and the `external_signer_not_connected` error that the signer-mode commit (c)
introduces, so (c) had to land first. The only uncommitted *tests* were the export
tests and the signer-mode/permission tests, so "(d) tests" is not a separate commit —
each feature's tests travel with it.
1. **Per-profile signer modes + persisted NIP-46 connections (c)** — three coexisting
signing modes (Embedded / Nip46Client / Nip46Bunker), a `signer_mode` field on every
stored profile, a vault `nip46_connections` store (owner-scoped, with parsed
permissions, expiry, revocation), the expanded `Signer` trait (identity validation,
`Signing` enum, permission surface), the NIP-46 permission model, and the redesigned
Signer Mode screen with a nostr-tools-based client.
2. **Fail-closed key export (b)** — `export_secret_key` always re-authenticates, requires
a reason, refuses external-signer profiles, and writes the audit entry *before*
returning the key (fail-closed on audit failure). Frontend `ExportSecretKeyModal`
replaces the old reveal modal.
3. **Hash-chained audit log (a)** — SHA-256 hash-chained append-only log with atomic
append and end-to-end `verify_chain()`; the `sha2` dependency.
4. **Packaging icon restored (this session, `114b343`)** — `frontend/build/icon.png`
was deleted from the working tree, so electron-builder had no Linux icon and every
AppImage/deb it emitted carried the generic Electron placeholder. The icon was
**regenerated from `KeynectrAppIconPossibility02.jpeg`** (not resized): the white
(254) JPEG background was cut to transparent (alpha derived from luma), the art was
flattened to a square canvas with symmetric padding, ink kept pure black (RGB 0,0,0),
and exported as **512x512 RGBA**. The single declared config path
(`linux.icon: build/icon.png`) was kept single — no `build/linux/` fan-out — because
the 512 master satisfies both targets: AppImage downscales to 256 internally (≥256
required) and the deb installs `usr/share/icons/hicolor/512x512/apps/keynectr.png`,
matching the generated `.desktop` `Icon=keynectr`.
### Security properties confirmed
- No secret material is logged or returned except the single, authenticated, audited
export. The export `reason` is logged by design; passwords and nsecs are not.
- Export is **fail-closed**: a failed audit write prevents the key from being returned
(`log.record(...)?` — the earlier `let _ =` that let a key out on audit failure is gone).
- External (Nip46Client) profiles cannot export a secret key — the key is not local.
- Profile identity is resolved server-side (`profiles::find_stored_profile`), not trusted
from the client.
- NIP-46 permissions are deny-by-default and cannot be broadened on reconnect.
- Audit writes are serialized (single mutex across the read-compute-write-update cycle)
and the chain is tamper-evident from a genesis hash.
- Renderer never receives an nsec outside the intentional one-time export.
## Commits added this session (newest first)
| Hash | Message |
|------|---------|
| `510cb65` | feat(signer): store NIP-46 connection secrets in the vault, keyed by VaultRef |
(The parent chain — `b2755d8` Signer trait returns SigningError, `a2307ac` checkpoint,
`e6e4922` SigningBackend+SigningError+VaultRef, `ee88171` checkpoint, `114b343` packaging
icon, `d92371f` checkpoint, `6eff510` export, `2c61830` signer modes, `caed722` audit log
— is unchanged.)
## Verification (run this session)
Full suite per AGENTS.md, run on top of `510cb65`:
- **Rust**: `cargo test` → **196 passed**, 0 failed (no new/removed tests — this
sub-step refactors existing code paths; the existing `signer::backend`, `vault`, and
`nip46_client` tests cover the change); `cargo clippy --all-targets` → clean (exit 0);
`cargo fmt --check` → clean (exit 0); `cargo build --release` → Finished, exit 0.
- **Frontend**: `npm test` → passed; `npm run typecheck` → exit 0; `npm run lint` →
exit 0; `npm run electron:build` → exit 0; `npm run build` → exit 0.
`npm run format:check` → **exit 1 on the 5 pre-existing files** (`ExportSecretKeyModal.tsx`,
`SignerModeScreen.tsx`, `AppProvider.tsx`, `ExportSecretKey.test.tsx`, `fakeBackend.ts`)
— these are the documented Step-7 Prettier hygiene failures, **not** introduced by this
Rust-only change (none of the 6 modified files are frontend). Left untouched here.
## How to reproduce / exercise
- Backend: `cargo run --release -- serve` (JSON-lines IPC on stdio) or the CLI in
`src/main.rs`.
- GUI: from `frontend/`, `npm run electron:build && electron .` (prod) or `npm run
start:dev` with `NOSTR_GUI_DEV_URL`.
- Packaging: from `frontend/`, `npm run dist` (unpacked dir) or `npx electron-builder
--linux AppImage deb` (declared targets) → artifacts in `release/`.
- Exercise export: Profiles → a profile → Export secret key → enter the vault password
and a reason. An external (NIP-46 client) profile shows it cannot export.
- Exercise modes: Signer Mode screen → pick Embedded / NIP-46 client; connect a
`nostrconnect://` URI with a `perms=` parameter to grant scoped permissions.
## Still untracked (do NOT lose; do NOT commit the hygiene junk)
- **Source JPEG** `KeynectrAppIconPossibility02.jpeg` — intentionally **untracked**
(the icon is now derived from it; the JPEG itself is not a build input and stays out
of git, per instruction).
- Pre-existing untracked hygiene leftovers (out of scope, address in the Step-7 hygiene
pass): `COSMIC_THEME.md`, `.opencode/`, `.impeccable/`, `.directory`.
- **`frontend/build/icon.png` is no longer a leftover** — it was regenerated and
committed in `114b343` this session. The prior "deleted icon" item is closed.
- Build artifacts `release/` and `dist/` are gitignored and not committed.
- `profiles_vault.json*` and `target/` remain correctly untracked and uncommitted
(vault is gitignored).
## On the record (not fixed, per instruction)
- **`package.json` → `homepage` still reads `https://github.com/avi/Keynctr`.** This is
the Step-7 rename/hygiene item and was **left untouched** in this session; noted here
so it is on the record rather than silently fixed.
## Deferred / next steps (unchanged, plus new)
- **Step 2 (packaging): DONE.** Icon restored, `npm run dist` + declared targets green,
icon proven inside both artifacts, committed as `114b343`.
- **Step 3 (signer abstraction)** — foundation **landed** as `e6e4922`
(`SigningBackend` + `VaultRef` + `SigningError` in `src/signer/backend.rs`, 10 tests,
full Rust suite green); `b2755d8` made the `Signer` trait return `SigningError`.
**Sub-step 1 (Vault integration) — DONE, landed as `510cb65`**: the encrypted
`connection_secrets` store keyed by `VaultRef`, on-demand secret resolution at the
vault boundary (fail-closed when locked), and the inline `Nip46Connection.secret`
field removed. The `Remote { vault_ref }` variant holds **only** a `VaultRef` — the
NIP-46 connection secret is never inlined. **Remaining sub-step:**
2. **IPC reroute** — drive `src/ipc.rs` handlers through `SigningBackend` (selected
per-profile) so no inline `signer_mode`/handle-presence branching remains; convert
handler results to `AppError` via `From<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

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

@ -2,7 +2,7 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from
import { lookup } from 'node:dns/promises';
import { spawn, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import * as net from 'node:net';
import * as path from 'node:path';
@ -28,6 +28,306 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
// -----------------------------------------------------------------------------
// Linux display-server compatibility (X11, Wayland, Hyprland, ...)
//
// Hyprland (and every Wayland session running XWayland) exports BOTH $DISPLAY
// and $WAYLAND_DISPLAY, so the platform must be chosen explicitly before
// Chromium initializes. Everything here runs at module load — before
// `app.whenReady()` — so the switches take effect.
//
// If the first attempt dies before the window ever paints (a common Wayland
// symptom: GPU/dmabuf issues or a broken sandbox), a watchdog relaunches the
// app one rung down a fixed ladder:
//
// 0. as detected, software rendering (safe default)
// 1. the other platform (Wayland -> XWayland, X11 -> Wayland)
// 2. detected platform with the GPU enabled
// 3. the other platform with the GPU enabled
// 4. give up: show an error dialog with the escape hatches below
//
// Escape hatches (environment):
// KEYNCTR_FORCE_X11=1 always use X11/XWayland
// KEYNCTR_FORCE_WAYLAND=1 always use native Wayland
// KEYNCTR_ENABLE_GPU=1 use hardware-accelerated rendering
// KEYNCTR_DISABLE_GPU=1 force software rendering (the default)
// KEYNCTR_NO_RELAUNCH=1 disable the fallback relauncher
// -----------------------------------------------------------------------------
/** How long a launch has to prove it works before the watchdog intervenes. */
const LAUNCH_PROVE_MS = 8_000;
/** The max ladder distance: a marker newer than this means the last launch crashed early. */
const CRASH_WINDOW_MS = 45_000;
function detectSessionPlatform(): 'wayland' | 'x11' {
if (process.env.KEYNCTR_FORCE_X11) {
return 'x11';
}
if (process.env.KEYNCTR_FORCE_WAYLAND) {
return 'wayland';
}
const sessionType = (process.env.XDG_SESSION_TYPE ?? '').toLowerCase();
if (sessionType === 'wayland' || process.env.WAYLAND_DISPLAY) {
return 'wayland';
}
return 'x11';
}
const sessionPlatform = detectSessionPlatform();
const fallbackStep = Number.parseInt(process.env.KEYNCTR_FALLBACK_STEP ?? '0', 10);
/**
* Per-user marker recording the launch currently in flight. If a previous
* process left one behind and it is recent, that launch died before its
* window ever painted — so this process continues the fallback ladder.
*/
function startupMarkerPath(): string {
return path.join(app.getPath('temp'), 'keynctr-startup.json');
}
interface StartupMarker {
step: number;
platform: string;
startedAt: number;
/** Set when the app shut down on purpose (not a crash before first paint). */
clean?: boolean;
}
function readStartupMarker(): StartupMarker | null {
try {
const parsed = JSON.parse(readFileSync(startupMarkerPath(), 'utf8')) as StartupMarker;
if (typeof parsed.step === 'number' && typeof parsed.startedAt === 'number') {
return parsed;
}
} catch {
// No marker (or unreadable): nothing to learn.
}
return null;
}
function writeStartupMarker(step: number): void {
try {
writeFileSync(
startupMarkerPath(),
JSON.stringify({ step, platform: sessionPlatform, startedAt: Date.now() }),
);
} catch {
// Marker is best-effort only.
}
}
function clearStartupMarker(): void {
try {
rmSync(startupMarkerPath(), { force: true });
} catch {
// Best-effort.
}
}
/**
* Stamp the marker as a clean exit so the next launch does not mistake an
* intentional quit (e.g. closing the window a few seconds after it opened)
* for a crash before first paint.
*/
function markStartupCleanExit(): void {
const marker = readStartupMarker();
if (marker && !marker.clean) {
try {
writeFileSync(startupMarkerPath(), JSON.stringify({ ...marker, clean: true }));
} catch {
// Best-effort.
}
}
}
/** Environment for fallback ladder rung `step` (0 keeps the detected setup). */
function envForFallbackStep(step: number): Record<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.
*
@ -38,12 +338,12 @@ protocol.registerSchemesAsPrivileged([
* (HMR websocket included).
*/
const CSP_PROD =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " +
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"connect-src 'self'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; " +
"base-uri 'none'; form-action 'none'";
const CSP_DEV =
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " +
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; " +
"object-src 'none'; base-uri 'none'; form-action 'none'";
/** The CSP for a URL this window may load, or `null` for anywhere else. */
@ -195,6 +495,7 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'init',
'get_state',
'create_profile',
'import_profile',
'select_profile',
'publish_profile_metadata',
'set_profile_picture',
@ -217,10 +518,26 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'lock_vault',
'remove_vault_password',
'reveal_secret_key',
'export_secret_key',
// Legacy bunker
'signer_connect',
'signer_disconnect',
'signer_status',
'signer_approve',
// New signer modes (default: nip46_client most secure)
'signer_mode_get',
'signer_mode_set',
'embedded_signer_status',
'embedded_signer_approve',
'nip46_connect',
'nip46_disconnect',
'nip46_status',
'nip46_approve',
// Sidecar (local isolated signer, planned)
'sidecar_connect',
'sidecar_disconnect',
'sidecar_status',
'sidecar_approve',
]);
/** True when `method` may be dispatched. Unknown methods never reach the backend. */
@ -515,6 +832,7 @@ function createWindow(): void {
icon: resolveWindowIcon(),
backgroundColor: '#f6f4f0',
autoHideMenuBar: true,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@ -522,6 +840,29 @@ function createWindow(): void {
},
});
// Always reveal the window once it has painted. On Linux the startup
// watchdog additionally waits LAUNCH_PROVE_MS before clearing the marker:
// if the process dies before that, the next launch advances the fallback
// ladder one rung.
window.once('ready-to-show', () => {
window.show();
});
if (process.platform === 'linux' && !process.env.KEYNCTR_NO_RELAUNCH) {
let proveTimer: ReturnType<typeof setTimeout> | null = null;
window.once('ready-to-show', () => {
proveTimer = setTimeout(() => {
proveTimer = null;
clearStartupMarker();
}, LAUNCH_PROVE_MS);
});
window.webContents.on('render-process-gone', () => {
if (proveTimer) {
clearTimeout(proveTimer);
proveTimer = null;
}
});
}
const devServer = process.env.NOSTR_GUI_DEV_URL;
if (devServer) {
void window.loadURL(devServer);
@ -648,6 +989,11 @@ app.whenReady().then(() => {
createWindow();
if (pendingGiveUpDialog) {
dialog.showErrorBox('Keynctr — display problems', pendingGiveUpDialog);
pendingGiveUpDialog = null;
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
@ -656,6 +1002,7 @@ app.whenReady().then(() => {
});
app.on('before-quit', () => {
markStartupCleanExit();
if (backend) {
backend.kill();
}

View file

@ -8,6 +8,7 @@
"name": "keynectr",
"version": "0.1.0",
"dependencies": {
"nostr-tools": "^2.25.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@ -862,6 +863,45 @@
"node": ">=10"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
@ -1202,6 +1242,66 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@ -5007,6 +5107,47 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nostr-tools": {
"version": "2.25.1",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.25.1.tgz",
"integrity": "sha512-k/yCjpjHR18n9E6kCh1MdlP+fGZnP9UkuIDt1cHF87jqAE6ohOnZGFuQXPfWhDaRzNj9TQgJZPAPkCqOylqtAg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
@ -6249,7 +6390,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View file

@ -22,10 +22,12 @@
"format:check": "prettier --check .",
"electron:build": "tsc -p tsconfig.electron.json",
"start": "npm run electron:build && electron .",
"start:dev": "npm run electron:build && NOSTR_GUI_DEV_URL=${NOSTR_GUI_DEV_URL:-http://localhost:5173} electron .",
"desktop:install": "bash scripts/install-desktop-entry.sh",
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
},
"dependencies": {
"nostr-tools": "^2.25.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -11,6 +11,7 @@ import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen';
import { RelaysScreen } from './screens/RelaysScreen';
import { SignerScreen } from './screens/SignerScreen';
import { SignerModeScreen } from './screens/SignerModeScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { CreateProfileModal } from './screens/CreateProfileModal';
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
@ -74,6 +75,7 @@ function Shell() {
{screen === 'compose' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />}
{screen === 'signer' && <SignerScreen />}
{screen === 'signer-mode' && <SignerModeScreen />}
{screen === 'settings' && <SettingsScreen />}
</main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -0,0 +1,209 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { BackendError } from '../lib/api';
import { useApp } from '../state/AppProvider';
import type { RevealedKey } from '../lib/types';
import { Alert } from './Alert';
import { Button } from './Button';
import { CopyButton } from './CopyButton';
import { ErrorText } from './ErrorText';
import { Modal } from './Modal';
interface ExportSecretKeyModalProps {
open: boolean;
onClose: () => void;
profile: { label: string; npub: string } | null;
}
type Phase = 'form' | 'exporting' | 'revealed' | 'error';
/**
* Exports a profile's secret key with fresh passphrase re-authentication.
*
* Every export requires the vault passphrase and a human-readable reason,
* regardless of whether the vault is already unlocked. The key is never
* stored in component state beyond the revealed display phase, and all
* sensitive state is cleared when the modal closes.
*/
export function ExportSecretKeyModal({ open, onClose, profile }: ExportSecretKeyModalProps) {
const { exportSecretKey } = useApp();
const [phase, setPhase] = useState<Phase>('form');
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
const [password, setPassword] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const clearState = () => {
setPhase('form');
setRevealed(null);
setPassword('');
setReason('');
setBusy(false);
setError(null);
setFatal(null);
};
useEffect(() => {
if (open && profile) {
clearState();
// Focus password field after modal opens
setTimeout(() => passwordRef.current?.focus(), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, profile?.npub]);
const handleClose = () => {
clearState();
onClose();
};
const trimmedReason = reason.trim();
const canSubmit = password.length > 0 && trimmedReason.length > 0 && !busy;
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit || !profile) {
return;
}
setBusy(true);
setError(null);
setFatal(null);
try {
const key = await exportSecretKey(profile.npub, password, trimmedReason);
setRevealed(key);
setPhase('revealed');
// Clear password and reason immediately after successful export
setPassword('');
setReason('');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const code = err instanceof BackendError ? err.code : undefined;
// Map specific error codes to user-friendly messages
if (code === 'wrong_password') {
setError(msg);
setPhase('form');
passwordRef.current?.select();
} else if (code === 'profile_not_found') {
setFatal({ message: 'That profile is not stored on this computer.' });
setPhase('error');
} else if (code === 'external_signer_not_connected' || code === 'external_signer_identity_mismatch') {
setFatal({
message: 'This profile uses an external signer. Secret key export is not possible for externally managed accounts.',
});
setPhase('error');
} else {
setFatal({
message: msg,
details: err instanceof BackendError ? err.details : undefined,
});
setPhase('error');
}
} finally {
setBusy(false);
}
};
const title = `Export secret key${profile ? ` — ${profile.label}` : ''}`;
return (
<Modal open={open} title={title} onClose={handleClose}>
{phase === 'form' && (
<form onSubmit={onSubmit} noValidate>
<Alert tone="warning" title="This action is logged">
Exporting a secret key creates an audit entry. The key itself is never stored in logs.
</Alert>
<div className="field">
<label htmlFor="export-secret-password">Vault password</label>
<input
ref={passwordRef}
id="export-secret-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
disabled={busy}
aria-describedby={error ? 'export-password-error' : undefined}
aria-invalid={error ? true : undefined}
/>
{error && <ErrorText id="export-password-error">{error}</ErrorText>}
</div>
<div className="field">
<label htmlFor="export-secret-reason">Reason for export</label>
<input
id="export-secret-reason"
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. backup, migration, device transfer"
disabled={busy}
/>
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={handleClose} disabled={busy}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
{busy ? 'Exporting…' : 'Export'}
</Button>
</div>
</form>
)}
{phase === 'error' && fatal && (
<div>
<Alert tone="error" title="Could not export the secret key" details={fatal.details}>
{fatal.message}
</Alert>
<div className="modal-actions">
<Button variant="secondary" onClick={handleClose}>
Close
</Button>
</div>
</div>
)}
{phase === 'revealed' && revealed && (
<div>
<Alert tone="error" title="Keep this key safe">
Anyone who has this key can fully control the profile: publish as it, sign messages, and
move its funds. Never paste it into chat, logs, or screenshots. Store it offline and
back it up.
</Alert>
<div className="path-row">
<div>
<span className="field-label">Private key (hex)</span>
<code className="mono path-value">{revealed.hex}</code>
</div>
<CopyButton text={revealed.hex} label="hex key" />
</div>
<div className="path-row">
<div>
<span className="field-label">Private key (nsec)</span>
<code className="mono path-value">{revealed.nsec}</code>
</div>
<CopyButton text={revealed.nsec} label="nsec key" />
</div>
<p className="hint">
The <code>nsec1…</code> form is what most Nostr wallets and clients import. It encodes
exactly the same key as the hex form above.
</p>
<div className="modal-actions">
<Button variant="secondary" onClick={handleClose}>
Done
</Button>
</div>
</div>
)}
</Modal>
);
}

View file

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

View file

@ -1,188 +0,0 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { BackendError } from '../lib/api';
import { useApp } from '../state/AppProvider';
import type { RevealedKey } from '../lib/types';
import { Alert } from './Alert';
import { Button } from './Button';
import { CopyButton } from './CopyButton';
import { ErrorText } from './ErrorText';
import { Modal } from './Modal';
import { Spinner } from './Spinner';
interface ShowSecretKeyModalProps {
open: boolean;
onClose: () => void;
/** The profile whose secret key is being revealed. */
profile: { label: string; npub: string } | null;
}
type Phase = 'loading' | 'unlock' | 'revealed' | 'error';
/**
* Shows a profile's secret key (hex + nsec) after unlocking the vault.
*
* When the vault is password-protected and still locked, the modal asks for
* the password inline, unlocks, and then reveals the key. The secret key is
* only ever fetched from the backend, never stored in state before reveal.
*/
export function ShowSecretKeyModal({ open, onClose, profile }: ShowSecretKeyModalProps) {
const { revealSecretKey, unlockVault } = useApp();
const [phase, setPhase] = useState<Phase>('loading');
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const unlockErrorId = 'show-secret-unlock-error';
useEffect(() => {
if (open && profile) {
setPhase('loading');
setRevealed(null);
setPassword('');
setError(null);
setFatal(null);
setBusy(false);
void reveal(profile.npub);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, profile?.npub]);
const reveal = async (npub: string) => {
setBusy(true);
setError(null);
setFatal(null);
try {
const key = await revealSecretKey(npub);
setRevealed(key);
setPhase('revealed');
} catch (err) {
if (err instanceof BackendError && err.code === 'vault_locked') {
setPhase('unlock');
return;
}
setFatal({
message: err instanceof Error ? err.message : String(err),
details: err instanceof BackendError ? err.details : undefined,
});
setPhase('error');
} finally {
setBusy(false);
}
};
const canSubmit = password.length > 0 && !busy;
const onUnlock = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) {
return;
}
setBusy(true);
setError(null);
try {
await unlockVault(password);
setPassword('');
if (profile) {
await reveal(profile.npub);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setPassword('');
setBusy(false);
inputRef.current?.focus();
}
};
const title = `Secret key${profile ? ` — ${profile.label}` : ''}`;
return (
<Modal open={open} title={title} onClose={onClose}>
{phase === 'loading' && <Spinner label="Revealing secret key…" />}
{phase === 'unlock' && (
<form onSubmit={onUnlock} noValidate>
<Alert tone="warning" title="Vault is locked">
This profile's keys are password-protected. Enter the vault password to reveal the
secret key. The password itself is never saved.
</Alert>
<div className="field">
<label htmlFor="show-secret-password">Vault password</label>
<input
ref={inputRef}
id="show-secret-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
autoFocus
aria-describedby={error ? unlockErrorId : undefined}
aria-invalid={error ? true : undefined}
disabled={busy}
/>
{error && <ErrorText id={unlockErrorId}>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
{busy ? 'Unlocking…' : 'Unlock'}
</Button>
</div>
</form>
)}
{phase === 'error' && fatal && (
<div>
<Alert tone="error" title="Could not reveal the secret key" details={fatal.details}>
{fatal.message}
</Alert>
<div className="modal-actions">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</div>
</div>
)}
{phase === 'revealed' && revealed && (
<div>
<Alert tone="error" title="Keep this key safe">
Anyone who has this key can fully control the profile: publish as it, sign messages, and
move its funds. Never paste it into chat, logs, or screenshots. Store it offline and
back it up.
</Alert>
<div className="path-row">
<div>
<span className="field-label">Private key (hex)</span>
<code className="mono path-value">{revealed.hex}</code>
</div>
<CopyButton text={revealed.hex} label="hex key" />
</div>
<div className="path-row">
<div>
<span className="field-label">Private key (nsec)</span>
<code className="mono path-value">{revealed.nsec}</code>
</div>
<CopyButton text={revealed.nsec} label="nsec key" />
</div>
<p className="hint">
The <code>nsec1…</code> form is what most Nostr wallets and clients import. It encodes
exactly the same key as the hex form above.
</p>
<div className="modal-actions">
<Button variant="secondary" onClick={onClose}>
Done
</Button>
</div>
</div>
)}
</Modal>
);
}

View file

@ -11,7 +11,8 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
{ id: 'feed', label: 'Feed', icon: 'list' },
{ id: 'compose', label: 'Compose', icon: 'edit' },
{ id: 'relays', label: 'Relays', icon: 'relay' },
{ id: 'signer', label: 'Signer', icon: 'key' },
{ id: 'signer-mode', label: 'Signer Mode', icon: 'key' },
{ id: 'signer', label: 'Signer (Bunker)', icon: 'server' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
];

View file

@ -1,15 +1,18 @@
import type {
AppState,
BackendResponse,
EmbeddedSignerStatus,
FeedItem,
LinkPreview,
MetadataPublishReport,
Nip46SignerStatus,
PickedImage,
ProfileSummary,
PublishReport,
RelayTestResult,
RevealedKey,
Settings,
SignerMode,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
@ -52,6 +55,8 @@ export const api = {
getState: () => call<AppState>('get_state'),
createProfile: (label: string, settings?: Settings) =>
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, settings }),
importProfile: (label: string, secret: string) =>
call<{ profile: ProfileSummary; state: AppState }>('import_profile', { label, secret }),
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
publishProfileMetadata: (npub: string) =>
call<MetadataPublishReport>('publish_profile_metadata', { npub }),
@ -96,10 +101,29 @@ export const api = {
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
lockVault: () => call<AppState>('lock_vault'),
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
exportSecretKey: (npub: string, password: string, reason: string) =>
call<RevealedKey>('export_secret_key', { npub, password, reason }),
pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
// Signer mode management
signerModeGet: () => call<{ mode: SignerMode }>('signer_mode_get'),
signerModeSet: (mode: SignerMode) => call<AppState>('signer_mode_set', { mode }),
// Embedded signer
embeddedSignerStatus: () => call<EmbeddedSignerStatus>('embedded_signer_status'),
embeddedSignerApprove: (index: number, approved: boolean) =>
call<EmbeddedSignerStatus>('embedded_signer_approve', { index, approved }),
// NIP-46 client signer
nip46Connect: (uri: string, label: string) =>
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'),

View file

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

View file

@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from 'react';
import type { FeedItem, PublicationStatus, RelayConfig } from './types';
import { useApp } from '../state/AppProvider';
/** Determine whether a note is fully or partially published. */
export function computePublicationStatus(
itemRelays: string[],
enabledRelays: RelayConfig[],
): PublicationStatus {
const enabled = enabledRelays.filter((r) => r.enabled).map((r) => r.url);
if (enabled.length === 0) {
return 'fully_published';
}
const served = new Set(itemRelays);
const allServed = enabled.every((url) => served.has(url));
return allServed ? 'fully_published' : 'partially_published';
}
export interface ProfilePublication extends FeedItem {
publicationStatus: PublicationStatus;
}
export interface UseProfilePublicationsResult {
publications: ProfilePublication[];
fullyPublished: ProfilePublication[];
loading: boolean;
error: string | null;
}
export function useProfilePublications(): UseProfilePublicationsResult {
const { state, feedGet } = useApp();
const [publications, setPublications] = useState<ProfilePublication[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const authorNpub = state?.active_profile?.npub ?? null;
const load = useCallback(async () => {
if (!authorNpub) {
setPublications([]);
return;
}
setLoading(true);
setError(null);
try {
const items = await feedGet(50, false, authorNpub);
const enabledRelays = state?.settings.relays ?? [];
const enriched: ProfilePublication[] = items.map((item) => ({
...item,
publicationStatus: computePublicationStatus(item.relays, enabledRelays),
}));
enriched.sort((a, b) => b.created_at - a.created_at);
setPublications(enriched);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setPublications([]);
} finally {
setLoading(false);
}
}, [authorNpub, feedGet, state?.settings.relays]);
useEffect(() => {
void load();
}, [load]);
const fullyPublished = publications.filter((p) => p.publicationStatus === 'fully_published');
return { publications, fullyPublished, loading, error };
}

View file

@ -0,0 +1,536 @@
import { nip19, generateSecretKey, finalizeEvent, EventTemplate } from 'nostr-tools';
import { bytesToHex } from 'nostr-tools/utils';
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
export interface Keypair {
nsec: string;
npub: string;
privateKey: Uint8Array<ArrayBufferLike>;
publicKey: Uint8Array<ArrayBufferLike>;
}
export interface NostrConnectURI {
uri: string;
signerPubkey: string;
relays: string[];
secret?: string;
}
export interface ExternalSignerConnection {
signerPubkey: string;
relays: string[];
secret?: string;
connected: boolean;
conversationKey?: string;
}
export class SignerError extends Error {
constructor(
public readonly code: SignerErrorCode,
message: string,
public readonly details?: string,
) {
super(message);
this.name = 'SignerError';
}
}
export type SignerErrorCode =
| 'NO_KEYPAIR'
| 'VAULT_LOCKED'
| 'ACTIVE_SESSION_EXISTS'
| 'INVALID_NOSTRCONNECT_URI'
| 'NO_RELAYS_CONFIGURED'
| 'BUNKER_START_FAILED'
| 'CLIENT_CONNECT_FAILED'
| 'SIGNING_FAILED'
| 'APPROVAL_REJECTED'
| 'APPROVAL_TIMEOUT';
export interface SignerState {
mode: SignerMode;
keypair: Keypair | null;
isVaultUnlocked: boolean;
/** Bunker mode (this app acts as signer for other clients) */
bunker: {
isRunning: boolean;
connectionURI: string | null;
connectedClients: Map<string, { pubkey: string; relays: string[] }>;
};
/** Client mode (this app connects to external signer like Amber) */
client: {
isConnected: boolean;
signerPubkey: string | null;
relays: string[];
pendingRequests: Map<string, PendingSignRequest>;
};
embedded: {
isActive: boolean;
};
}
export interface PendingSignRequest {
id: string;
event: EventTemplate;
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
params: unknown[];
resolve: (result: string) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
type StateListener = (state: SignerState) => void;
export class SignerManager {
private state: SignerState = {
mode: 'nip46_client',
keypair: null,
isVaultUnlocked: false,
bunker: {
isRunning: false,
connectionURI: null,
connectedClients: new Map(),
},
client: {
isConnected: false,
signerPubkey: null,
relays: [],
pendingRequests: new Map(),
},
embedded: {
isActive: false,
},
};
private listeners: Set<StateListener> = new Set();
private abortController: AbortController | null = null;
private requestIdCounter = 0;
/** Subscribe to state changes */
subscribe(listener: StateListener): () => void {
this.listeners.add(listener);
listener(this.getState());
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) {
listener(this.getState());
}
}
getState(): Readonly<SignerState> {
return Object.freeze({ ...this.state });
}
/** Import a keypair from nsec or generate new one */
async importKeypair(nsecOrPrivateKey?: string): Promise<Keypair> {
let pk: Uint8Array<ArrayBufferLike>;
if (nsecOrPrivateKey) {
try {
const decoded = nip19.decode(nsecOrPrivateKey);
if (decoded.type !== 'nsec') {
throw new SignerError('NO_KEYPAIR', 'Provided key is not a valid nsec');
}
pk = decoded.data as any;
} catch {
throw new SignerError('NO_KEYPAIR', 'Invalid nsec format');
}
} else {
pk = generateSecretKey();
}
// biome-ignore lint/suspicious/noExplicitAny: Explicit cast for nip19 API
const nsec = nip19.nsecEncode(pk as any);
const npub = nip19.npubEncode(bytesToHex(pk as any));
const keypair: Keypair = {
nsec,
npub,
privateKey: pk,
publicKey: pk,
};
this.state.keypair = keypair;
this.notify();
return keypair;
}
/** Set vault unlock state (called by vault unlock/lock) */
async setVaultUnlocked(unlocked: boolean): Promise<void> {
this.state.isVaultUnlocked = unlocked;
if (!unlocked) {
await this.stopAll();
}
this.notify();
}
/** Switch signer mode with full validation */
async setMode(mode: SignerMode): Promise<void> {
if (mode === this.state.mode) return;
// Stop current mode
switch (this.state.mode) {
case 'embedded':
await this.stopEmbedded();
break;
case 'nip46_bunker':
await this.stopBunker();
break;
case 'nip46_client':
await this.disconnectClient();
break;
}
// Start new mode
switch (mode) {
case 'embedded':
await this.startEmbedded();
break;
case 'nip46_bunker':
await this.startBunker();
break;
case 'nip46_client':
// Client mode requires explicit connection via connectToExternalSigner()
break;
}
this.state.mode = mode;
this.notify();
}
/** Start embedded signer (local signing) */
private async startEmbedded(): Promise<void> {
if (!this.state.keypair || !this.state.isVaultUnlocked) {
throw new SignerError(
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
this.state.keypair
? 'Vault locked: Please unlock to use embedded signer.'
: 'No keypair found: Please import a key first.',
);
}
this.state.embedded.isActive = true;
this.notify();
}
/** Stop embedded signer */
private async stopEmbedded(): Promise<void> {
this.state.embedded.isActive = false;
this.notify();
}
// ==================== BUNKER MODE (this app acts as signer) ====================
/** Generate nostrconnect:// URI for bunker mode */
generateBunkerURI(relays: string[], secret?: string): NostrConnectURI {
if (!this.state.keypair) {
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
}
if (relays.length === 0) {
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46 connection.');
}
const signerPubkey = this.state.keypair.npub;
const params = new URLSearchParams();
for (const relay of relays) {
params.append('relay', relay);
}
if (secret) {
params.append('secret', secret);
}
const uri = `nostrconnect://${signerPubkey}?${params.toString()}`;
return { uri, signerPubkey, relays, secret };
}
/** Start NIP-46 bunker server (this app acts as signer) */
async startBunker(relays?: string[]): Promise<NostrConnectURI> {
this.validateBunkerPreconditions();
const relayList = relays ?? this.getDefaultRelays();
if (relayList.length === 0) {
throw new SignerError('NO_RELAYS_CONFIGURED', 'No relays configured for NIP-46.');
}
const connectionInfo = this.generateBunkerURI(relayList);
this.abortController = new AbortController();
const { signal } = this.abortController;
try {
await this.runBunkerServer(signal);
} catch (error) {
this.state.bunker.isRunning = false;
this.state.bunker.connectionURI = null;
this.notify();
throw new SignerError(
'BUNKER_START_FAILED',
'Failed to start NIP-46 bunker server',
String(error),
);
}
this.state.bunker.isRunning = true;
this.state.bunker.connectionURI = connectionInfo.uri;
this.notify();
return connectionInfo;
}
private validateBunkerPreconditions(): void {
if (!this.state.keypair) {
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
}
if (!this.state.isVaultUnlocked) {
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to switch modes.');
}
if (this.state.bunker.isRunning) {
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Bunker already running.');
}
if (this.state.client.isConnected) {
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Client mode active. Disconnect first.');
}
if (this.state.embedded.isActive) {
throw new SignerError('ACTIVE_SESSION_EXISTS', 'Embedded signer active. Stop it first.');
}
}
async stopBunker(): Promise<void> {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
this.state.bunker.isRunning = false;
this.state.bunker.connectionURI = null;
this.state.bunker.connectedClients.clear();
this.notify();
}
private async runBunkerServer(signal: AbortSignal): Promise<void> {
// Simplified - real impl would use websocket + NIP-44
await new Promise<void>((resolve) => {
const checkAbort = () => {
if (signal.aborted) resolve();
else setTimeout(checkAbort, 100);
};
checkAbort();
});
}
// ==================== CLIENT MODE (connect TO external signer) ====================
/** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */
parseExternalSignerURI(uri: string): ExternalSignerConnection {
if (!uri.startsWith('nostrconnect://')) {
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://');
}
const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?');
const signerPubkey = authority;
const params = new URLSearchParams(queryString || '');
const relays = params.getAll('relay');
const secret = params.get('secret') || undefined;
if (!signerPubkey) {
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'Missing signer pubkey in URI');
}
if (relays.length === 0) {
throw new SignerError('NO_RELAYS_CONFIGURED', 'URI must contain at least one relay');
}
return { signerPubkey, relays, secret, connected: false };
}
/** Connect to external signer (Amber, Nostr Connect, bunker) using nostrconnect:// URI */
async connectToExternalSigner(uri: string, relays?: string[]): Promise<ExternalSignerConnection> {
if (this.state.client.isConnected) {
throw new SignerError(
'ACTIVE_SESSION_EXISTS',
'Already connected to external signer. Disconnect first.',
);
}
if (!this.state.keypair) {
throw new SignerError('NO_KEYPAIR', 'No keypair found: Please import a key first.');
}
if (!this.state.isVaultUnlocked) {
throw new SignerError('VAULT_LOCKED', 'Vault locked: Please unlock to connect.');
}
const parsed = this.parseExternalSignerURI(uri);
const relayList = relays ?? parsed.relays ?? this.getDefaultRelays();
if (relayList.length === 0) {
throw new SignerError(
'NO_RELAYS_CONFIGURED',
'No relays configured for NIP-46 client connection.',
);
}
// Derive conversation key with external signer
const conversationKey = this.deriveConversationKey();
// In real implementation:
// 1. Connect to relays via websocket
// 2. Subscribe to kind 24133 from external signer
// 3. Send 'connect' request with our pubkey + secret
// 4. Handle incoming requests (sign_event, nip44_encrypt, nip44_decrypt)
// For now, simulate connection
this.state.client = {
isConnected: true,
signerPubkey: parsed.signerPubkey,
relays: relayList,
pendingRequests: new Map(),
};
this.notify();
return { ...parsed, connected: true, conversationKey };
}
/** Disconnect from external signer */
async disconnectClient(): Promise<void> {
// Clear pending requests with rejection
for (const [, request] of this.state.client.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new SignerError('APPROVAL_REJECTED', 'Disconnected from external signer'));
}
this.state.client = {
isConnected: false,
signerPubkey: null,
relays: [],
pendingRequests: new Map(),
};
this.notify();
}
/** Sign event via external signer (request/response with user approval) */
async signEventViaExternalSigner(event: EventTemplate): Promise<string> {
if (!this.state.client.isConnected) {
throw new SignerError(
'CLIENT_CONNECT_FAILED',
'Not connected to external signer. Connect first.',
);
}
return this.sendNip46Request('sign_event', [JSON.stringify(event)]);
}
/** Send NIP-46 request to external signer and wait for approval */
private async sendNip46Request(method: string, params: unknown[]): Promise<string> {
if (!this.state.client.isConnected) {
throw new SignerError('CLIENT_CONNECT_FAILED', 'Not connected to external signer.');
}
const requestId = `req_${++this.requestIdCounter}_${Date.now()}`;
// Create promise that resolves when user approves/rejects
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
this.state.client.pendingRequests.delete(requestId);
reject(new SignerError('APPROVAL_TIMEOUT', 'Approval request timed out'));
}, 30000); // 30 second timeout
const request: PendingSignRequest = {
id: requestId,
event: params[0] as EventTemplate,
method: method as 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt',
params,
resolve,
reject,
timeout,
};
this.state.client.pendingRequests.set(requestId, request);
this.notify();
// In real implementation: encrypt request with conversation key, publish to relays
// External signer receives, shows UI, user approves, response encrypted and published back
});
}
/** Approve or reject a pending external signer request */
async respondToExternalRequest(requestId: string, approved: boolean): Promise<void> {
const request = this.state.client.pendingRequests.get(requestId);
if (!request) {
throw new SignerError('APPROVAL_REJECTED', 'Request not found or already processed');
}
clearTimeout(request.timeout);
this.state.client.pendingRequests.delete(requestId);
if (approved) {
// In real impl: sign/encrypt with conversation key, publish response
// For now, simulate success
request.resolve('signed_event_id_or_encrypted_result');
} else {
request.reject(new SignerError('APPROVAL_REJECTED', 'Request rejected by user'));
}
this.notify();
}
/** Derive NIP-44 conversation key with another pubkey */
private deriveConversationKey(): string {
// Real impl: nip44.v2.ConversationKey.derive(mySk, theirPk)
return 'derived_conversation_key';
}
/** Stop all signers */
async stopAll(): Promise<void> {
await this.stopEmbedded();
await this.stopBunker();
await this.disconnectClient();
this.state.mode = 'embedded';
this.notify();
}
/** Sign an event (embedded mode only) */
async signEvent(event: EventTemplate): Promise<string> {
if (this.state.mode !== 'embedded') {
throw new SignerError(
'SIGNING_FAILED',
`Signing not available in ${this.state.mode} mode. Use external signer.`,
);
}
if (!this.state.keypair || !this.state.isVaultUnlocked) {
throw new SignerError(
this.state.keypair ? 'VAULT_LOCKED' : 'NO_KEYPAIR',
'Cannot sign: vault locked or no keypair.',
);
}
try {
const signedEvent = finalizeEvent(event, this.state.keypair.privateKey);
return signedEvent.id;
} catch (error) {
throw new SignerError('SIGNING_FAILED', 'Failed to sign event', String(error));
}
}
private getDefaultRelays(): string[] {
return ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'];
}
}
export interface PendingSignRequest {
id: string;
event: EventTemplate;
method: 'sign_event' | 'nip44_encrypt' | 'nip44_decrypt';
params: unknown[];
resolve: (result: string) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
/** React hook for using SignerManager */
export function useSignerManager(): SignerManager {
return new SignerManager();
}
/** React hook for signer state */
export function useSignerState(): Readonly<SignerState> {
const manager = useSignerManager();
return manager.getState();
}

View file

@ -1,8 +1,22 @@
export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
export type Theme =
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
/** Active signer mode. */
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
/** Lifecycle of the NIP-46 remote signer. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
/** Approval request details for user confirmation. */
export interface ApprovalDetails {
method: string;
summary: string;
event_kind?: number;
destination_relays: string[];
content_preview: string;
is_sensitive: boolean;
}
/** A NIP-46 request waiting for the user to approve or reject it. */
export interface PendingApproval {
/** Internal id used to answer this request. */
@ -11,6 +25,8 @@ export interface PendingApproval {
method: string;
/** A short human-readable description of what will be done. */
summary: string;
/** Detailed approval information. */
details?: ApprovalDetails;
}
/** Non-secret snapshot of the NIP-46 remote signer for display. */
@ -20,12 +36,38 @@ export interface SignerStatus {
peer: string | null;
/** Relays used for the connection. */
relays: string[];
/** The relays in `relays` that are actually connected right now. */
connectedRelays: string[];
/** A user-facing error if the signer stopped because of one. */
error: string | null;
/** Requests currently waiting for the user's approval. */
pending: PendingApproval[];
}
/** Embedded signer status. */
export interface EmbeddedSignerStatus {
type: 'embedded';
available: boolean;
active_npub?: string;
pending_count: number;
pending: PendingApproval[];
error?: string;
}
/** NIP-46 client signer status. */
export interface Nip46SignerStatus {
type: 'nip46';
connected: boolean;
signer_pubkey?: string;
relays: string[];
connected_relays: string[];
error?: string;
pending_approvals: PendingApproval[];
}
/** Union of all signer statuses. */
export type AnySignerStatus = EmbeddedSignerStatus | Nip46SignerStatus;
/** A safe view of a profile with no secret key material. */
export interface ProfileSummary {
label: string;
@ -38,6 +80,8 @@ export interface ProfileSummary {
picture?: string | null;
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
nip05?: string | null;
/** Per-profile signer mode. Absent for legacy profiles; defaults to embedded. */
signer_mode?: SignerMode | null;
}
export interface RelayConfig {
@ -65,6 +109,8 @@ export interface PublishReport {
event_id: string;
succeeded: string[];
failed: RelayFailure[];
/** The note content that was published. */
content?: string;
}
/** Per-relay outcome of publishing a profile's name as kind 0 metadata. */
@ -73,6 +119,9 @@ export interface MetadataPublishReport {
failed: RelayFailure[];
}
/** Publication status derived from comparing served relays against all enabled relays. */
export type PublicationStatus = 'fully_published' | 'partially_published';
/** A single note shown in the aggregated feed. */
export interface FeedItem {
/** Bech32 note id. */
@ -139,6 +188,15 @@ export interface AppState {
settings: Settings;
/** Recently deleted profiles, newest last, for undo. */
undo_history?: ProfileSummary[];
/** The most recent publish report, persisted across restarts. */
last_publish?: {
event_id: string;
succeeded: string[];
failed: RelayFailure[];
content: string;
} | null;
/** Active signer mode. */
signer_mode: SignerMode;
}
/** A secret key revealed after the vault is unlocked. */

View file

@ -8,6 +8,8 @@ import { EmptyState } from '../components/EmptyState';
import { Icon } from '../components/Icon';
import { shortenNpub } from '../lib/format';
import type { Screen } from '../lib/navigation';
import type { ProfilePublication } from '../lib/publications';
import { useProfilePublications } from '../lib/publications';
import { useApp } from '../state/AppProvider';
interface HomeScreenProps {
@ -16,7 +18,8 @@ interface HomeScreenProps {
}
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
const { state, lastPublish, selectProfile } = useApp();
const { state, selectProfile } = useApp();
const { publications, fullyPublished, loading, error } = useProfilePublications();
const [selecting, setSelecting] = useState<string | null>(null);
const onSelect = async (npub: string) => {
@ -72,7 +75,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</div>
<Button variant="primary" onClick={() => onNavigate('compose')}>
<Icon name="edit" size={18} />
Compose note
Compose
</Button>
</header>
@ -91,11 +94,15 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</Button>
</header>
<div className="card-body">
<ul className="home-profile-list">
<ul className="home-profile-list" role="listbox" aria-label="Profiles">
{state?.profiles.map((profile) => (
<li
key={profile.npub}
className={`home-profile-row${profile.is_active ? ' is-active' : ''}`}
role={profile.is_active ? undefined : 'option'}
aria-selected={profile.is_active}
tabIndex={profile.is_active ? undefined : 0}
aria-label={`${profile.label}${profile.is_active ? ' (active)' : ''} — select profile`}
onClick={
profile.is_active
? undefined
@ -106,6 +113,19 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
void onSelect(profile.npub);
}
}
onKeyDown={
profile.is_active
? undefined
: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if ((event.target as HTMLElement).closest('button, a, input')) {
return;
}
void onSelect(profile.npub);
}
}
}
>
<Avatar
npub={profile.npub}
@ -165,7 +185,10 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</header>
<div className="card-body">
<PublicationResult
lastPublish={lastPublish}
publications={publications}
fullyPublished={fullyPublished}
loading={loading}
error={error}
onNavigateCompose={() => onNavigate('compose')}
/>
</div>
@ -176,92 +199,114 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
}
function PublicationResult({
lastPublish,
publications,
fullyPublished,
loading,
error,
onNavigateCompose,
}: {
lastPublish: ReturnType<typeof useApp>['lastPublish'];
publications: ProfilePublication[];
fullyPublished: ProfilePublication[];
loading: boolean;
error: string | null;
onNavigateCompose: () => void;
}) {
if (!lastPublish) {
return (
<p className="muted">
You haven't published anything yet.{' '}
<button type="button" className="linklike" onClick={onNavigateCompose}>
Compose your first note
</button>
.
</p>
);
if (loading) {
return <p className="muted">Loading publications…</p>;
}
if (lastPublish.error) {
if (error) {
return (
<Alert tone="error" title="Publication failed" details={lastPublish.details}>
{lastPublish.error}
<Alert tone="error" title="Could not load publications">
{error}
</Alert>
);
}
const report = lastPublish.report;
if (!report) {
return null;
if (publications.length === 0) {
return (
<div className="home-publish-empty">
<p className="muted">You haven't published anything yet.</p>
<Button variant="secondary" size="sm" onClick={onNavigateCompose}>
<Icon name="edit" size={16} />
Compose your first note
</Button>
</div>
);
}
if (report.failed.length === 0) {
const newest = fullyPublished[0] ?? null;
const newestPartial =
publications.find((p) => p.publicationStatus === 'partially_published') ?? null;
if (!newest && !newestPartial) {
return (
<div className="home-publish-empty">
<p className="muted">No fully published publications found.</p>
</div>
);
}
if (!newest) {
return (
<div className="home-publish-empty">
<p className="muted">No fully published publications found.</p>
{newestPartial && <PartialPublicationDetails item={newestPartial} />}
</div>
);
}
return (
<>
<div className="publish-result success">
<Badge tone="success">
<Icon name="check" size={14} /> Published
</Badge>
<span className="mono" title={report.event_id}>
{shortenNpub(report.event_id, true)}
<span className="mono" title={newest.id}>
{shortenNpub(newest.id, true)}
</span>
<CopyButton text={report.event_id} label="event ID" />
<CopyButton text={newest.id} label="event ID" />
</div>
{newest.content && <p className="publish-preview">{newest.content}</p>}
{newestPartial && newestPartial.id !== newest.id && (
<PartialPublicationDetails item={newestPartial} />
)}
</>
);
}
function PartialPublicationDetails({ item }: { item: ProfilePublication }) {
const totalRelays = item.relays.length;
return (
<div className="publish-result">
<Alert tone="warning" title="Partially published">
The note reached {report.succeeded.length} of{' '}
{report.succeeded.length + report.failed.length} enabled relays. Event ID:{' '}
<code className="mono" title={report.event_id}>
{shortenNpub(report.event_id, true)}
</code>
</Alert>
<CopyButton text={report.event_id} label="event ID" />
<details className="alert-details">
<summary>Relay results</summary>
<ul className="relay-result-list">
{report.succeeded.map((url) => (
{item.relays.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>
))}
{totalRelays === 0 && <li className="bad">No relays returned this event.</li>}
</ul>
</details>
</div>
);
}
function FirstRunGuide() {
const steps: { title: string; body: string }[] = [
const steps: { icon: string; title: string; body: string }[] = [
{
title: '1 · Create a profile',
icon: 'users',
title: 'Create a profile',
body: 'The app generates a public npub address and a private key for you. They are stored only on this computer.',
},
{
title: '2 · Share your npub',
icon: 'copy',
title: 'Share your npub',
body: 'Your npub is public and safe to share. Never share your private key with anyone.',
},
{
title: '3 · Publish a note',
icon: 'edit',
title: 'Publish a note',
body: 'Write a note in Compose and publish it. Your note is signed locally and sent to the enabled relays.',
},
];
@ -270,9 +315,14 @@ function FirstRunGuide() {
<h2>How it works</h2>
<ol>
{steps.map((step) => (
<li key={step.title}>
<li key={step.title} className="first-run-step">
<span className="first-run-step-indicator" aria-hidden="true">
<Icon name={step.icon as any} size={16} />
</span>
<div className="first-run-step-content">
<strong>{step.title}</strong>
<p>{step.body}</p>
</div>
</li>
))}
</ol>

View file

@ -13,7 +13,9 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
useEffect(() => {
if (open) {
setSecret(''); setError(null); setSaving(false);
setSecret('');
setError(null);
setSaving(false);
requestAnimationFrame(() => labelRef.current?.focus());
}
}, [open]);
@ -21,23 +23,45 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!secret.trim() || saving) return;
setSaving(true); setError(null);
try { await importProfile('', secret.trim()); onClose(); }
catch (err) { setError(err instanceof Error ? err.message : String(err)); setSaving(false); }
setSaving(true);
setError(null);
try {
await importProfile('', secret.trim());
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSaving(false);
}
};
return <Modal open={open} title="Add an existing account" onClose={onClose}>
return (
<Modal open={open} title="Add an existing account" onClose={onClose}>
<form onSubmit={submit} noValidate>
<p className="muted">Import an account using its private key. The key stays in your local vault and is never displayed.</p>
<p className="muted">
Import an account using its private key. The key stays in your local vault and is never
displayed.
</p>
<div className="field">
<label htmlFor="import-profile-secret">Private key</label>
<input id="import-profile-secret" type="password" value={secret} onChange={(e) => setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" />
<input
id="import-profile-secret"
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder="nsec1... or 64-character hex"
autoComplete="off"
/>
{error && <ErrorText>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>{saving ? 'Adding…' : 'Add account'}</Button>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>
{saving ? 'Adding…' : 'Add account'}
</Button>
</div>
</form>
</Modal>;
</Modal>
);
}

View file

@ -8,7 +8,8 @@ import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { Modal } from '../components/Modal';
import { ProfileEditModal } from '../components/ProfileEditModal';
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
import { ImportProfileModal } from './ImportProfileModal';
import { ExportSecretKeyModal } from '../components/ExportSecretKeyModal';
import { formatDate, shortenNpub } from '../lib/format';
import type { MetadataPublishReport } from '../lib/types';
import { useApp } from '../state/AppProvider';
@ -41,6 +42,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
picture?: string | null;
nip05?: string | null;
} | null>(null);
const [importOpen, setImportOpen] = useState(false);
const profiles = state?.profiles ?? [];
const shorten = state?.settings.shorten_npub ?? true;
@ -120,10 +122,15 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
title="No profiles yet"
description="Create a profile to get your own Nostr identity — a public npub address you can share, with a private key kept safely on this computer."
action={
<div className="modal-actions">
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create Profile
</Button>
<Button variant="secondary" onClick={() => setImportOpen(true)}>
Add existing account
</Button>
</div>
}
/>
</div>
@ -158,6 +165,9 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
<Icon name="plus" size={18} />
Create Profile
</Button>
<Button variant="secondary" onClick={() => setImportOpen(true)}>
Add existing account
</Button>
</header>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
@ -337,7 +347,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
))}
</div>
<ShowSecretKeyModal
<ExportSecretKeyModal
open={revealTarget !== null}
profile={revealTarget}
onClose={() => setRevealTarget(null)}
@ -394,6 +404,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
onError={setError}
/>
)}
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
</div>
</div>
);

View file

@ -0,0 +1,501 @@
import { useCallback, useEffect, useState } from 'react';
import { Alert } from '../components/Alert';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SignerModeScreen() {
const {
state,
signerModeSet,
embeddedSignerStatus,
nip46Status,
nip46Connect,
nip46Disconnect,
nip46Approve,
embeddedSignerApprove,
refresh,
createProfile,
importProfile,
unlockVault,
} = useApp();
const [embeddedStatus, setEmbeddedStatus] = useState<EmbeddedSignerStatus | null>(null);
const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null);
const [uri, setUri] = useState('');
const [label, setLabel] = useState('Remote Signer');
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [loading, setLoading] = useState(true);
// Single source of truth: backend state (defaults to most secure)
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode;
const isNip46Active = (mode === 'nip46_client' || mode === 'nip46_bunker') && !!nip46StatusState?.connected;
const isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available;
const refreshStatus = useCallback(async () => {
try {
// Mode comes from AppProvider state, just refresh signer statuses
const currentMode = (state?.signer_mode ?? 'nip46_client') as string;
let fetchedMode = currentMode;
if (fetchedMode === 'nip46') fetchedMode = 'nip46_client';
if (!['embedded', 'nip46_bunker', 'nip46_client'].includes(fetchedMode)) {
fetchedMode = 'nip46_client';
}
if (fetchedMode === 'embedded') {
try {
const status = await embeddedSignerStatus();
setEmbeddedStatus(status);
} catch {
setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any);
}
} else {
try {
const status = await nip46Status();
setNip46StatusState(status);
} catch {
setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [state?.signer_mode, embeddedSignerStatus, nip46Status]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
useEffect(() => {
const timer = window.setInterval(() => {
void refreshStatus();
}, 2000);
return () => window.clearInterval(timer);
}, [refreshStatus]);
const vaultLocked = state?.vault_locked ?? false;
const hasProfile = !!state?.active_profile;
const canSwitchToBunker = hasProfile && !vaultLocked;
const canSwitchToEmbedded = hasProfile && !vaultLocked;
const handleModeSwitch = useCallback(
async (newMode: SignerMode) => {
setError(null);
try {
await signerModeSet(newMode);
await refreshStatus();
await refresh();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('No keypair') || msg.includes('No active profile') || msg.includes('no active profile')) {
setError('No keypair found: Please import a key first.');
} else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) {
setError('Vault locked: Please unlock to switch modes.');
} else if (msg.includes('ACTIVE_SESSION') || msg.toLowerCase().includes('active session')) {
setError('Invalid mode transition: Cannot switch while active session exists. Disconnect first.');
} else {
setError(msg || 'That operation is not permitted.');
}
}
},
[signerModeSet, refreshStatus, refresh],
);
const handleNip46Connect = useCallback(async () => {
const trimmed = uri.trim();
if (!trimmed.startsWith('nostrconnect://')) {
setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.');
return;
}
setError(null);
setConnecting(true);
try {
const status = await nip46Connect(trimmed, label.trim() || 'Remote Signer');
setNip46StatusState(status);
setUri('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setConnecting(false);
}
}, [uri, label, nip46Connect]);
const handleNip46Disconnect = useCallback(async () => {
setError(null);
try {
const status = await nip46Disconnect();
setNip46StatusState(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [nip46Disconnect]);
const handleEmbeddedApprove = useCallback(
async (index: number, approved: boolean) => {
setError(null);
try {
const status = await embeddedSignerApprove(index, approved);
setEmbeddedStatus(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[embeddedSignerApprove],
);
const handleNip46Approve = useCallback(
async (id: string, approved: boolean) => {
setError(null);
try {
const status = await nip46Approve(id, approved);
setNip46StatusState(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
},
[nip46Approve],
);
const modeBadge = () => {
if (mode === 'nip46_client') {
return isNip46Active ? (
<Badge tone="success">NIP-46 Client (Connected)</Badge>
) : (
<Badge tone="neutral">NIP-46 Client (Most Secure)</Badge>
);
}
if (mode === 'nip46_bunker') {
return isNip46Active ? (
<Badge tone="success">NIP-46 Bunker (Running)</Badge>
) : (
<Badge tone="warning">NIP-46 Bunker (Moderate)</Badge>
);
}
return isEmbeddedActive ? (
<Badge tone="success">Embedded (Least Secure)</Badge>
) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>Embedded {vaultLocked ? '(Vault Locked)' : ''}</Badge>
);
};
const handleImportKey = useCallback(async () => {
const nsec = prompt('Enter your nsec (npub will be derived) or leave blank to generate a new key:');
if (nsec === null) return;
setError(null);
try {
if (nsec.trim()) {
await importProfile('Imported', nsec.trim());
} else {
await createProfile('Generated');
}
await refresh();
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [importProfile, createProfile, refresh, refreshStatus]);
const handleUnlockVault = useCallback(async () => {
const pwd = prompt('Enter vault password to unlock:');
if (!pwd) return;
setError(null);
try {
await unlockVault(pwd);
await refresh();
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [unlockVault, refresh, refreshStatus]);
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<h1>Signer Mode</h1>
<p className="page-subtitle">
Choose how your keys are managed and where signing happens.
<br />
<span className="subtitle-hint">Ordered by security: most secure → least secure</span>
</p>
</header>
<section className="card">
<header className="card-header">
<h2>Key Status</h2>
</header>
<div className="card-body">
<div className="status-grid">
<div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}>
<span className="status-label">Keypair</span>
<span className="status-value">{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}</span>
</div>
<div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}>
<span className="status-label">Vault</span>
<span className="status-value">{vaultLocked ? 'Locked' : 'Unlocked / No password'}</span>
</div>
<div className="status-item">
<span className="status-label">Current Mode</span>
<span className="status-value">{mode}</span>
</div>
</div>
{!hasProfile && (
<Button variant="primary" onClick={() => void handleImportKey()} style={{ marginTop: 12 }}>
<Icon name="key" size={16} /> Import / Generate Key
</Button>
)}
{hasProfile && vaultLocked && (
<Button variant="secondary" onClick={() => void handleUnlockVault()} style={{ marginTop: 12 }}>
<Icon name="shield" size={16} /> Unlock Vault
</Button>
)}
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Current Mode</h2>
<div className="signer-badge">{modeBadge()}</div>
</header>
<div className="card-body">
<div className="mode-options">
{/* 1. Most Secure */}
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}>
<input
type="radio"
name="signer-mode"
value="nip46_client"
checked={mode === 'nip46_client'}
onChange={() => handleModeSwitch('nip46_client')}
disabled={loading}
/>
<span className="security-badge most-secure">Most Secure</span>
<div className="mode-option-content">
<h3>NIP-46 Client (Connect to External Signer)</h3>
<p className="security-desc">
<strong>Most Secure:</strong> Private key never touches this device. Connects to an
external signer (Amber, Nostr Connect, hardware wallet). Every signing request is
approved on the external device.
</p>
<ul className="mode-features">
<li>✓ Private key NEVER on this device</li>
<li>✓ Sign with hardware wallet / mobile app</li>
<li>✓ Every request approved externally</li>
<li>✓ Key cannot be extracted if this app is compromised</li>
</ul>
{mode === 'nip46_client' && isNip46Active && <span className="mode-badge active">Connected</span>}
</div>
</label>
{/* 2. Moderately Secure */}
<label className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}>
<input
type="radio"
name="signer-mode"
value="nip46_bunker"
checked={mode === 'nip46_bunker'}
onChange={() => handleModeSwitch('nip46_bunker')}
disabled={loading}
/>
<span className="security-badge moderate-secure">Moderately Secure</span>
<div className="mode-option-content">
<h3>NIP-46 Bunker (This App Signs)</h3>
<p className="security-desc">
<strong>Moderately Secure:</strong> This app acts as a signer for other clients. Key
stays in this app&apos;s encrypted vault; other clients connect via{' '}
<code>nostrconnect://</code>.
</p>
<ul className="mode-features">
<li>✓ Private key stays in encrypted vault</li>
<li>✓ Approve each request from client apps</li>
<li>✓ Works with Amber, Nostr Connect, etc.</li>
<li>⚠ Key in memory when vault unlocked</li>
</ul>
{mode === 'nip46_bunker' && isNip46Active && <span className="mode-badge active">Running</span>}
</div>
</label>
{/* 3. Least Secure */}
<label className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}>
<input
type="radio"
name="signer-mode"
value="embedded"
checked={mode === 'embedded'}
onChange={() => handleModeSwitch('embedded')}
disabled={loading}
/>
<span className="security-badge least-secure">Least Secure</span>
<div className="mode-option-content">
<h3>Embedded Signer (Local Keys)</h3>
<p className="security-desc">
<strong>Least Secure:</strong> Keys stored locally, signing on this device. Convenient
but key exists in memory when vault unlocked.
</p>
<ul className="mode-features">
<li>✓ Keys never leave this device</li>
<li>✓ Works offline</li>
<li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li>
<li>⚠ Vulnerable to device compromise</li>
</ul>
{mode === 'embedded' && isEmbeddedActive && <span className="mode-badge active">Active</span>}
</div>
</label>
</div>
{mode === 'nip46_bunker' && !canSwitchToBunker && (
<Alert tone="warning" title="Cannot enable bunker">
{hasProfile ? 'Unlock vault to enable bunker mode.' : 'No keypair found: Please import a key first.'}
</Alert>
)}
{mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
<Alert tone="warning" title="Vault locked">
Unlock vault to use embedded signer.
</Alert>
)}
{!hasProfile && mode !== 'nip46_client' && (
<Alert tone="warning" title="No keypair">
No keypair found: Please import a key first. (NIP-46 Client can be selected without a local key.)
</Alert>
)}
{error && <ErrorText>{error}</ErrorText>}
</div>
</section>
{/* Embedded pending */}
{mode === 'embedded' && (embeddedStatus?.pending?.length ?? 0) > 0 && (
<section className="card">
<header className="card-header">
<h2>Pending Approvals</h2>
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
</header>
<div className="card-body">
{(embeddedStatus!.pending).map((req, idx) => (
<div key={req.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono">{req.method}</code>
<p>{req.summary}</p>
{req.details?.is_sensitive && <span className="sensitive-badge">Sensitive</span>}
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
Approve
</Button>
<Button variant="danger" onClick={() => void handleEmbeddedApprove(idx, false)}>
Reject
</Button>
</div>
</div>
))}
</div>
</section>
)}
{/* NIP-46 Client config */}
{(mode === 'nip46_client' || mode === 'nip46_bunker') && (
<section className="card">
<header className="card-header">
<h2>{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}</h2>
</header>
<div className="card-body">
{isNip46Active && nip46StatusState ? (
<div className="signer-actions">
<p className="hint">
Connected to <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code> via{' '}
{(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays
</p>
{nip46StatusState.error && <Alert tone="error" title="Connection error">{nip46StatusState.error}</Alert>}
<Button variant="danger" onClick={() => void handleNip46Disconnect()}>
<Icon name="trash" size={16} /> Disconnect
</Button>
{(nip46StatusState?.pending_approvals?.length ?? 0) > 0 && (
<div className="signer-pending">
<h3>Pending ({nip46StatusState!.pending_approvals!.length})</h3>
{(nip46StatusState!.pending_approvals ?? []).map((r) => (
<div key={r.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono">{r.method}</code>
<p>{r.summary}</p>
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}>
Approve
</Button>
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}>
Reject
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : (
<div>
<div className="field">
<input
type="text"
placeholder={mode === 'nip46_client' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'}
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<p className="hint">
{mode === 'nip46_client'
? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.'
: 'Share this with client apps that want to connect to this bunker.'}
</p>
</div>
<div className="field">
<label>Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Remote Signer" />
</div>
{error && <ErrorText>{error}</ErrorText>}
<div className="settings-inline">
<Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}>
<Icon name="key" size={16} /> Connect
</Button>
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
<Icon name="refresh" size={16} /> Refresh
</Button>
</div>
</div>
)}
</div>
</section>
)}
<section className="card">
<header className="card-header">
<h2>Security Notes</h2>
</header>
<div className="card-body">
<ul className="security-notes">
<li>
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External
signer (hardware wallet / Amber) holds key.
</li>
<li>
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve each
remote request.
</li>
<li>
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when unlocked.
</li>
</ul>
</div>
</section>
</div>
</div>
);
}

View file

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

View file

@ -10,15 +10,18 @@ import {
import { api, BackendError } from '../lib/api';
import type {
AppState,
EmbeddedSignerStatus,
FeedItem,
LinkPreview,
MetadataPublishReport,
Nip46SignerStatus,
PickedImage,
ProfileSummary,
PublishReport,
RelayTestResult,
RevealedKey,
Settings,
SignerMode,
SignerStatus,
Theme,
UpdateApplyReport,
@ -40,6 +43,7 @@ interface AppContextValue {
lastPublish: LastPublish | null;
refresh: () => Promise<void>;
createProfile: (label: string) => Promise<ProfileSummary>;
importProfile: (label: string, secret: string) => Promise<ProfileSummary>;
selectProfile: (npub: string) => Promise<void>;
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
@ -63,10 +67,22 @@ interface AppContextValue {
unlockVault: (password: string) => Promise<AppState>;
lockVault: () => Promise<AppState>;
removeVaultPassword: (password: string) => Promise<AppState>;
revealSecretKey: (npub: string) => Promise<RevealedKey>;
exportSecretKey: (npub: string, password: string, reason: string) => Promise<RevealedKey>;
pickImages: () => Promise<PickedImage[]>;
uploadImage: (token: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>;
// Signer mode management
signerModeGet: () => Promise<{ mode: SignerMode }>;
signerModeSet: (mode: SignerMode) => Promise<AppState>;
// Embedded signer
embeddedSignerStatus: () => Promise<EmbeddedSignerStatus>;
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
// NIP-46 client signer
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
@ -97,6 +113,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
const initial = await api.init();
if (!cancelled) {
setState(initial);
if (initial.last_publish) {
setLastPublish({
report: initial.last_publish,
error: null,
details: null,
at: Date.now(),
});
}
}
} catch (error) {
if (!cancelled) {
@ -122,6 +146,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
[state?.settings],
);
const importProfile = useCallback(
async (label: string, secret: string): Promise<ProfileSummary> => {
const result = await api.importProfile(label, secret);
setState(result.state);
return result.profile;
},
[],
);
const selectProfile = useCallback(async (npub: string) => {
const fresh = await api.selectProfile(npub);
setState(fresh);
@ -210,6 +243,32 @@ export function AppProvider({ children }: { children: ReactNode }) {
return next;
}, []);
// Signer mode management
const signerModeGet = useCallback(() => api.signerModeGet(), []);
const signerModeSet = useCallback(
(mode: SignerMode) => applyState(api.signerModeSet(mode)),
[applyState],
);
// Embedded signer
const embeddedSignerStatus = useCallback(() => api.embeddedSignerStatus(), []);
const embeddedSignerApprove = useCallback(
(index: number, approved: boolean) => api.embeddedSignerApprove(index, approved),
[],
);
// NIP-46 client signer
const nip46Connect = useCallback(
(uri: string, label: string) => api.nip46Connect(uri, label),
[],
);
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46Approve = useCallback(
(id: string, approved: boolean) => api.nip46Approve(id, approved),
[],
);
const setVaultPassword = useCallback(
(currentPassword: string | null, newPassword: string) =>
applyState(api.setVaultPassword(currentPassword, newPassword)),
@ -224,7 +283,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
(password: string) => applyState(api.removeVaultPassword(password)),
[applyState],
);
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
const exportSecretKey = useCallback(
(npub: string, password: string, reason: string) =>
api.exportSecretKey(npub, password, reason),
[],
);
const pickImages = useCallback(() => api.pickImages(), []);
const uploadImage = useCallback((token: string) => api.uploadImage(token), []);
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
@ -261,6 +324,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
lastPublish,
refresh,
createProfile,
importProfile,
selectProfile,
publishNote,
recordPublishFailure,
@ -278,10 +342,18 @@ export function AppProvider({ children }: { children: ReactNode }) {
unlockVault,
lockVault,
removeVaultPassword,
revealSecretKey,
exportSecretKey,
pickImages,
uploadImage,
linkPreview,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46Disconnect,
nip46Status,
nip46Approve,
signerConnect,
signerDisconnect,
signerStatus,
@ -302,6 +374,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
lastPublish,
refresh,
createProfile,
importProfile,
selectProfile,
publishProfileMetadata,
setProfilePicture,
@ -326,10 +399,18 @@ export function AppProvider({ children }: { children: ReactNode }) {
unlockVault,
lockVault,
removeVaultPassword,
revealSecretKey,
exportSecretKey,
pickImages,
uploadImage,
linkPreview,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46Disconnect,
nip46Status,
nip46Approve,
signerConnect,
signerDisconnect,
signerStatus,

View file

@ -709,7 +709,7 @@ a {
}
.page-subtitle {
margin: 4px 0 0;
margin: 6px 0 0;
color: var(--text-muted);
font-size: 14px;
}
@ -808,22 +808,22 @@ a {
.sidebar-logo {
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
background: #fff;
/* The logo PNG now carries a real alpha channel: no tile background,
border, or mask — the artwork composites directly on the sidebar. */
overflow: hidden;
}
.sidebar-logo img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
display: block;
}
/* The artwork is black-on-white; flip it in dark themes so it stays black
bird on dark tile instead of a glaring white square. */
/* The artwork is black ink; flip it in dark themes so it stays visible on
dark sidebars. */
html[data-theme='dark'] .sidebar-logo img,
html[data-theme='neon'] .sidebar-logo img,
html[data-theme='glass'] .sidebar-logo img {
@ -1408,16 +1408,9 @@ select {
.home-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.active-profile-row {
display: flex;
align-items: center;
gap: 14px;
}
.active-profile-meta {
flex: 1;
min-width: 0;
@ -1472,7 +1465,7 @@ select {
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.home-profile-row {
@ -1480,19 +1473,28 @@ select {
align-items: center;
gap: 14px;
padding: 8px;
border-radius: 8px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
}
.home-profile-row.is-active {
background: var(--token-item-bg, rgba(0, 0, 0, 0.04));
border-color: var(--token-border, rgba(0, 0, 0, 0.12));
background: var(--surface-2);
border-color: var(--border);
}
.home-profile-row:not(.is-active) {
cursor: pointer;
}
.home-profile-row:not(.is-active):hover {
background: var(--surface-hover);
}
.home-profile-row:not(.is-active):focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.home-profile-row .active-profile-meta {
flex: 1;
min-width: 0;
@ -1511,7 +1513,17 @@ select {
}
.home-add-profile {
margin-top: 14px;
margin-top: 16px;
}
.home-publish-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 12px 0;
text-align: center;
color: var(--text-muted);
}
.relay-status-list {
@ -1575,6 +1587,18 @@ select {
flex-basis: 100%;
}
.publish-preview {
margin: 8px 0 0;
padding: 10px 12px;
background: var(--surface-2);
border-radius: var(--radius-sm);
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-width: 65ch;
}
.relay-result-list {
margin: 8px 0 0;
padding-left: 18px;
@ -1596,16 +1620,45 @@ select {
text-align: left;
}
.first-run-guide ol {
margin: 12px 0 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
.first-run-guide h2 {
margin-bottom: 4px;
}
.first-run-guide p {
margin: 2px 0 0;
.first-run-guide ol {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.first-run-step {
display: flex;
align-items: flex-start;
gap: 14px;
}
.first-run-step-indicator {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--primary-soft);
color: var(--primary);
display: grid;
place-items: center;
flex-shrink: 0;
margin-top: 2px;
}
.first-run-step-content strong {
display: block;
font-size: 14px;
margin-bottom: 2px;
}
.first-run-step-content p {
margin: 0;
color: var(--text-muted);
font-size: 14px;
}
@ -2160,6 +2213,16 @@ select {
font-size: 12px;
}
.signer-relay.is-connected {
border-color: var(--success);
color: var(--success);
}
.signer-relay-hint {
margin-left: 4px;
font-size: 12px;
}
.signer-actions {
display: flex;
flex-direction: column;
@ -2260,3 +2323,320 @@ select {
transition: none;
}
}
/* -------------------------------------------------------------------------
Signer Mode Screen
------------------------------------------------------------------------- */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.status-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.status-item.ok {
border-color: var(--success);
}
.status-item.missing,
.status-item.locked {
border-color: var(--danger);
}
.status-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-value {
font-size: 14px;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--text);
}
.mode-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.mode-option {
position: relative;
cursor: pointer;
border: 2px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition:
border-color 200ms ease,
box-shadow 200ms ease;
}
.mode-option input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.mode-option.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-soft);
}
.mode-option.active:focus-within {
outline: none;
box-shadow: 0 0 0 3px var(--primary);
}
.mode-option-content {
padding: 20px;
}
.mode-option-content h3 {
margin: 0 0 8px;
font-size: 16px;
color: var(--text);
}
.mode-option-content p {
margin: 0 0 12px;
font-size: 14px;
color: var(--text-muted);
line-height: 1.5;
}
.mode-features {
margin: 0;
padding-left: 20px;
font-size: 13px;
color: var(--text);
line-height: 1.8;
}
.mode-features li {
margin: 0;
}
.mode-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
border-radius: 999px;
}
.mode-badge.active {
background: var(--success-soft);
color: var(--success);
}
/* Security level badges */
.security-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 4px 10px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 999px;
}
.security-badge.most-secure {
background: var(--success-soft);
color: var(--success);
}
.security-badge.moderate-secure {
background: var(--warning-soft);
color: var(--warning);
}
.security-badge.least-secure {
background: var(--danger-soft);
color: var(--danger);
}
/* Security level card variants */
.mode-option.security-most {
border-color: var(--success);
box-shadow: 0 0 0 1px var(--success);
}
.mode-option.security-most.active {
border-color: var(--success);
box-shadow: 0 0 0 3px var(--success-soft);
}
.mode-option.security-moderate {
border-color: var(--warning);
box-shadow: 0 0 0 1px var(--warning);
}
.mode-option.security-moderate.active {
border-color: var(--warning);
box-shadow: 0 0 0 3px var(--warning-soft);
}
.mode-option.security-least {
border-color: var(--danger);
box-shadow: 0 0 0 1px var(--danger);
}
.mode-option.security-least.active {
border-color: var(--danger);
box-shadow: 0 0 0 3px var(--danger-soft);
}
.security-desc {
font-size: 13px;
line-height: 1.6;
color: var(--text);
margin-bottom: 12px;
}
.security-desc strong {
color: var(--text);
}
.security-desc code {
font-size: 12px;
background: var(--surface-2);
padding: 2px 6px;
border-radius: 4px;
}
.subtitle-hint {
display: block;
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
.mode-disabled-reason {
margin-top: 8px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.relay-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.relay-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
}
.field-row {
display: flex;
gap: 8px;
}
.field-row input {
flex: 1;
}
.connection-uri {
margin-top: 16px;
}
.connection-uri label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-muted);
}
.uri-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
overflow: hidden;
}
.uri-row code {
flex: 1;
min-width: 0;
font-size: 12px;
word-break: break-all;
white-space: pre-wrap;
}
.connected-clients {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.connected-clients h4 {
margin: 0 0 12px;
font-size: 13px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.client-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 8px;
font-size: 13px;
}
.security-notes {
margin: 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.8;
color: var(--text);
}
.security-notes li {
margin: 8px 0;
}
.security-notes strong {
color: var(--text);
}

View file

@ -0,0 +1,257 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProfilesScreen } from '../screens/ProfilesScreen';
import { ALICE, makeState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { renderWithApp } from './render';
const ALICE_HEX = `${ALICE.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
const ALICE_NSEC = `nsec1${ALICE.slice(5)}`;
/** Open the export-secret-key modal for the first profile. */
async function openExport(user: ReturnType<typeof userEvent.setup>) {
await screen.findByText('Alice');
await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]);
return screen.findByRole('dialog', { name: 'Export secret key — Alice' });
}
describe('exporting a secret key', () => {
it('shows the password and reason form immediately', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
expect(within(dialog).getByText(/This action is logged/)).toBeInTheDocument();
expect(within(dialog).getByLabelText('Vault password')).toBeInTheDocument();
expect(within(dialog).getByLabelText('Reason for export')).toBeInTheDocument();
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled();
});
it('requires a non-empty trimmed reason before enabling Export', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
// Password only — still disabled
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled();
// Password + whitespace-only reason — still disabled
await user.type(within(dialog).getByLabelText('Reason for export'), ' ');
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled();
// Password + real reason — enabled
await user.clear(within(dialog).getByLabelText('Reason for export'));
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeEnabled();
});
it('sends npub, password, and reason to the backend', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true }));
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
// Use paste to avoid char-by-char form interaction issues
const pwInput = within(dialog).getByLabelText('Vault password');
const reasonInput = within(dialog).getByLabelText('Reason for export');
await user.click(pwInput);
await user.paste('test');
await user.click(reasonInput);
await user.paste('migration');
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
const reqs = backend.requests.filter((r) => r.method === 'export_secret_key');
const last = reqs[reqs.length - 1];
expect(last.params).toEqual({
npub: ALICE,
password: 'test',
reason: 'migration',
});
});
});
it('shows hex and nsec after successful export', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
});
expect(
within(dialog).getByText(/Anyone who has this key can fully control the profile/i),
).toBeInTheDocument();
// Copy buttons work
await user.click(within(dialog).getByRole('button', { name: 'Copy hex key' }));
await user.click(within(dialog).getByRole('button', { name: 'Copy nsec key' }));
await waitFor(() => {
expect(backend.copied).toContain(ALICE_HEX);
expect(backend.copied).toContain(ALICE_NSEC);
});
});
it('does not call revealSecretKey', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
const exportReq = backend.requests.find((r) => r.method === 'export_secret_key');
expect(exportReq).toBeDefined();
});
// reveal_secret_key should never have been requested
const revealReq = backend.requests.find((r) => r.method === 'reveal_secret_key');
expect(revealReq).toBeUndefined();
});
it('clears sensitive state when the modal closes', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
// Close without exporting
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: /Export secret key/ })).not.toBeInTheDocument();
});
// Reopen — fields should be empty
const dialog2 = await openExport(user);
expect((within(dialog2).getByLabelText('Vault password') as HTMLInputElement).value).toBe('');
expect((within(dialog2).getByLabelText('Reason for export') as HTMLInputElement).value).toBe('');
});
it('shows an error for an incorrect password', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true }));
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
await user.type(within(dialog).getByLabelText('Vault password'), 'wrong');
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(within(dialog).getByText('Wrong password.')).toBeInTheDocument();
});
// Form is still visible for retry
expect(within(dialog).getByLabelText('Vault password')).toBeInTheDocument();
expect(within(dialog).getByLabelText('Reason for export')).toBeInTheDocument();
});
it('shows an error for a missing profile', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
// Type a reason first, then use nextErrors to inject a profile_not_found error
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
backend.nextErrors.export_secret_key = {
message: 'That profile is not stored on this computer.',
code: 'profile_not_found',
};
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(
within(dialog).getByText(/not stored on this computer/),
).toBeInTheDocument();
});
});
it('shows an error for an external (Nip46Client) signer profile', async () => {
const state = makeState({
profiles: [
{
label: 'Team Account',
npub: ALICE,
created_at: 1700000000,
is_active: true,
signer_mode: 'nip46_client',
},
],
active_profile: {
label: 'Team Account',
npub: ALICE,
created_at: 1700000000,
is_active: true,
signer_mode: 'nip46_client',
},
});
const backend = createFakeBackend(state);
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
// Open modal for the Team Account profile
await screen.findByText('Team Account');
await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]);
const dialog = await screen.findByRole('dialog', {
name: 'Export secret key — Team Account',
});
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(
within(dialog).getByText(/external signer/i),
).toBeInTheDocument();
});
});
it('does not return the key if the audit-log write fails', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openExport(user);
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
backend.nextErrors.export_secret_key = {
message: 'Could not write audit log.',
code: 'io',
};
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(within(dialog).getByText(/Could not write audit log/)).toBeInTheDocument();
});
// Key must NOT be shown
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
expect(within(dialog).queryByText(ALICE_NSEC)).not.toBeInTheDocument();
});
});

View file

@ -23,10 +23,12 @@ describe('HomeScreen', () => {
const { onNavigate } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText(/npub1alice\.\.\./)).toBeInTheDocument();
// The active profile appears in the profile list with its shortened npub.
const profileList = await screen.findByRole('listbox');
expect(within(profileList).getByText('Alice')).toBeInTheDocument();
expect(within(profileList).getByText(/npub1alice\.\.\./)).toBeInTheDocument();
const compose = screen.getByRole('button', { name: /Compose note/i });
const compose = screen.getByRole('button', { name: 'Compose' });
await userEvent.setup().click(compose);
expect(onNavigate).toHaveBeenCalledWith('compose');
});
@ -36,7 +38,11 @@ describe('HomeScreen', () => {
renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
const aliceRow = (await screen.findByText('Alice')).closest('.home-profile-row') as HTMLElement;
// The active profile row carries the "Selected" badge; find Alice via the profile list.
const profileList = await screen.findByRole('listbox');
const aliceRow = within(profileList)
.getByText('Alice')
.closest('.home-profile-row') as HTMLElement;
await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' }));
await waitFor(() => {
expect(backend.copied).toContain(ALICE);

View file

@ -1,87 +0,0 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProfilesScreen } from '../screens/ProfilesScreen';
import { makeState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { renderWithApp } from './render';
import { ALICE } from './apiMock';
const ALICE_HEX = `${ALICE.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
const ALICE_NSEC = `nsec1${ALICE.slice(5)}`;
/** Wait for the profile list to settle, then open the first profile's key reveal. */
async function openReveal(user: ReturnType<typeof userEvent.setup>) {
await screen.findByText('Alice');
await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]);
return screen.findByRole('dialog', { name: 'Secret key — Alice' });
}
describe('revealing a secret key', () => {
it('shows hex and nsec for an unencrypted vault without asking for a password', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openReveal(user);
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
expect(
within(dialog).getByText(/Anyone who has this key can fully control the profile/i),
).toBeInTheDocument();
await user.click(within(dialog).getByRole('button', { name: 'Copy hex key' }));
await user.click(within(dialog).getByRole('button', { name: 'Copy nsec key' }));
await waitFor(() => {
expect(backend.copied).toContain(ALICE_HEX);
expect(backend.copied).toContain(ALICE_NSEC);
});
});
it('asks for the vault password when locked, then reveals the key', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openReveal(user);
expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument();
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
await user.type(within(dialog).getByLabelText('Vault password'), 'correct horse');
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
await waitFor(() => {
expect(backend.state.vault_locked).toBe(false);
});
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
});
it('keeps the unlock form when an incorrect password is reported', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
backend.nextErrors.unlock_vault = { message: 'The password is not correct.' };
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openReveal(user);
await user.type(within(dialog).getByLabelText('Vault password'), 'wrong');
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
expect(await screen.findByText('The password is not correct.')).toBeInTheDocument();
expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument();
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
});
it('reveals directly when the vault is encrypted but already unlocked', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: false }));
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
const dialog = await openReveal(user);
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
expect(within(dialog).queryByLabelText('Vault password')).not.toBeInTheDocument();
});
});

View file

@ -48,6 +48,47 @@ describe('SignerScreen', () => {
expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true);
});
it('marks connected relays while the handshake is still in progress', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SignerScreen />);
// The signer is dialling the link's relays: one has answered, one has not.
backend.setSigner({
phase: 'connecting',
peer: 'ab12',
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [],
});
expect(await screen.findByText('Connecting…')).toBeInTheDocument();
const connected = screen.getByTitle('Connected');
expect(connected).toHaveTextContent('wss://relay.damus.io');
const pending = screen.getByTitle('No connection yet');
expect(pending).toHaveTextContent('wss://relay.nostr.band');
// No "waiting" hint while at least one relay is already up.
expect(screen.queryByText('Waiting for a relay to answer…')).not.toBeInTheDocument();
});
it('shows a waiting hint when no relay has answered yet', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SignerScreen />);
backend.setSigner({
phase: 'connecting',
peer: 'ab12',
relays: ['wss://relay.nostr.band'],
connectedRelays: [],
error: null,
pending: [],
});
expect(await screen.findByText('Waiting for a relay to answer…')).toBeInTheDocument();
});
it('disconnects an active connection', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
@ -80,6 +121,7 @@ describe('SignerScreen', () => {
phase: 'connected',
peer: 'ab12',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [
{ id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' },
@ -115,6 +157,7 @@ describe('SignerScreen', () => {
phase: 'connected',
peer: '79ab',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
});

View file

@ -4,6 +4,7 @@ import type {
ProfileSummary,
RelayTestResult,
Settings,
SignerMode,
SignerStatus,
} from '../lib/types';
@ -43,6 +44,8 @@ export function makeState(overrides?: Partial<AppState>): AppState {
active_profile: alice,
profiles: [alice, bob],
settings,
last_publish: null,
signer_mode: 'embedded' as SignerMode,
...overrides,
};
}
@ -71,7 +74,15 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
}
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides };
return {
phase: 'stopped',
peer: null,
relays: [],
connectedRelays: [],
error: null,
pending: [],
...overrides,
};
}
/**
@ -95,7 +106,7 @@ export interface ApiMock {
unlockVault: ReturnType<typeof vi.fn>;
lockVault: ReturnType<typeof vi.fn>;
removeVaultPassword: ReturnType<typeof vi.fn>;
revealSecretKey: ReturnType<typeof vi.fn>;
exportSecretKey: ReturnType<typeof vi.fn>;
pickImages: ReturnType<typeof vi.fn>;
uploadImage: ReturnType<typeof vi.fn>;
linkPreview: ReturnType<typeof vi.fn>;
@ -202,7 +213,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
encrypted_storage: false,
vault_locked: false,
})),
revealSecretKey: vi.fn(async (npub: string) => ({
exportSecretKey: vi.fn(async (npub: string) => ({
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
nsec: `nsec1${npub.slice(5)}`,
})),
@ -225,6 +236,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
phase: 'connected',
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [],
}),

View file

@ -324,6 +324,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
phase: 'connected',
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [],
};
@ -436,16 +437,48 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return next;
}
case 'reveal_secret_key': {
if (state.encrypted_storage && state.vault_locked) {
case 'export_secret_key': {
const npub = String(params.npub);
const password = String(params.password ?? '');
const reason = String(params.reason ?? '');
// Check profile exists first
const profile = state.profiles.find((p) => p.npub === npub);
if (!profile) {
throw Object.assign(
new Error('Your vault is locked. Enter your password to unlock it.'),
{ code: 'vault_locked' },
new Error('That profile is not stored on this computer.'),
{ code: 'profile_not_found' },
);
}
const npub = String(params.npub);
if (!state.profiles.some((p) => p.npub === npub)) {
throw new Error('That profile is not stored on this computer.');
// External signer profiles cannot export secret keys
if (profile.signer_mode === 'nip46_client') {
throw Object.assign(
new Error('This profile uses an external signer. Secret key export is not possible.'),
{ code: 'external_signer_not_connected' },
);
}
if (state.encrypted_storage) {
if (!password) {
throw Object.assign(
new Error('Password required to export secret key.'),
{ code: 'wrong_password' },
);
}
// Fake password check: accept "test" or "password"
if (password !== 'test' && password !== 'password') {
throw Object.assign(
new Error('Wrong password.'),
{ code: 'wrong_password' },
);
}
}
if (!reason) {
throw Object.assign(
new Error('A reason is required for key export.'),
{ code: 'config' },
);
}
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
return { hex, nsec: `nsec1${npub.slice(5)}` };

View file

@ -0,0 +1,194 @@
import { screen, waitFor } from '@testing-library/react';
import { HomeScreen } from '../screens/HomeScreen';
import { renderWithApp } from './render';
import { ALICE } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { computePublicationStatus } from '../lib/publications';
import type { FeedItem, RelayConfig } from '../lib/types';
const RELAYS: RelayConfig[] = [
{ url: 'wss://relay.damus.io', enabled: true },
{ url: 'wss://relay.nostr.band', enabled: true },
];
function makeItem(overrides: Partial<FeedItem> & { id: string; relays: string[] }): FeedItem {
return {
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
author_npub: ALICE,
content: '',
created_at: 1700000000,
...overrides,
};
}
function renderHome(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
}
async function waitForData() {
await waitFor(() => {
const loading = screen.queryByText(/Loading publications/);
expect(loading).not.toBeInTheDocument();
const emptyNoPub = screen.queryByText("You haven't published anything yet.");
expect(emptyNoPub).not.toBeInTheDocument();
});
}
describe('computePublicationStatus', () => {
it('returns fully_published when all enabled relays served the event', () => {
const item = makeItem({ id: 'a', relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'] });
expect(computePublicationStatus(item.relays, RELAYS)).toBe('fully_published');
});
it('returns partially_published when only some relays served the event', () => {
const item = makeItem({ id: 'a', relays: ['wss://relay.damus.io'] });
expect(computePublicationStatus(item.relays, RELAYS)).toBe('partially_published');
});
it('returns fully_published when no relays are configured', () => {
expect(computePublicationStatus(['wss://x'], [])).toBe('fully_published');
});
});
describe('HomeScreen — Most recent publication', () => {
it('shows the newest fully published event', async () => {
const backend = createFakeBackend();
backend.profileFeedItems = [
makeItem({
id: 'note1full',
content: 'Fully published note',
created_at: 100,
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
}),
];
renderHome(backend);
await waitForData();
expect(screen.getByText('Fully published note')).toBeInTheDocument();
expect(screen.getByText(/Published/)).toBeInTheDocument();
expect(screen.getByText(/note1full/)).toBeInTheDocument();
});
it('hides a partially published newest event from the main box', async () => {
const backend = createFakeBackend();
backend.profileFeedItems = [
makeItem({
id: 'note1partial',
content: 'Partial note',
created_at: 100,
relays: ['wss://relay.damus.io'],
}),
];
renderHome(backend);
await waitForData();
expect(screen.queryByText('Partial note')).not.toBeInTheDocument();
expect(screen.getByText(/No fully published publications found/)).toBeInTheDocument();
});
it('shows an older fully published event when the newest is partial', async () => {
const backend = createFakeBackend();
backend.profileFeedItems = [
makeItem({
id: 'note1partial',
content: 'Newer partial',
created_at: 200,
relays: ['wss://relay.damus.io'],
}),
makeItem({
id: 'note1full',
content: 'Older full',
created_at: 100,
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
}),
];
renderHome(backend);
await waitForData();
expect(screen.getByText('Older full')).toBeInTheDocument();
expect(screen.getByText(/Published/)).toBeInTheDocument();
expect(screen.queryByText('Newer partial')).not.toBeInTheDocument();
});
it('shows empty state when all events are partial', async () => {
const backend = createFakeBackend();
backend.profileFeedItems = [
makeItem({
id: 'note1a',
content: 'Partial A',
created_at: 200,
relays: ['wss://relay.damus.io'],
}),
makeItem({
id: 'note1b',
content: 'Partial B',
created_at: 100,
relays: ['wss://relay.nostr.band'],
}),
];
renderHome(backend);
await waitForData();
expect(screen.getByText(/No fully published publications found/)).toBeInTheDocument();
expect(screen.queryByText('Partial A')).not.toBeInTheDocument();
});
it('shows partial event details in Relay results expandable', async () => {
const backend = createFakeBackend();
backend.profileFeedItems = [
makeItem({
id: 'note1full',
content: 'Full note',
created_at: 200,
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
}),
makeItem({
id: 'note1partial',
content: 'Partial note',
created_at: 100,
relays: ['wss://relay.damus.io'],
}),
];
renderHome(backend);
await waitForData();
expect(screen.getByText('Full note')).toBeInTheDocument();
const relaySummary = screen.getByText('Relay results');
expect(relaySummary).toBeInTheDocument();
const details = relaySummary.closest('details') as HTMLDetailsElement;
details.open = true;
details.dispatchEvent(new Event('toggle'));
await waitFor(() => {
expect(
screen.getByText((_, element) => {
return (
element?.textContent?.includes('wss://relay.damus.io') === true &&
element?.textContent?.includes('accepted') === true &&
element?.tagName === 'LI'
);
}),
).toBeInTheDocument();
});
});
it('does not duplicate events with the same ID', async () => {
const backend = createFakeBackend();
backend.profileFeedItems = [
makeItem({
id: 'note1same',
content: 'Same event',
created_at: 100,
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
}),
];
renderHome(backend);
await waitForData();
const matches = screen.getAllByText(/note1same/);
expect(matches.length).toBe(1);
});
});

View file

@ -1,19 +1,31 @@
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { ALICE } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
describe('publication flow across screens', () => {
it('publishes from Compose and shows the result on Home', async () => {
const backend = createFakeBackend();
backend.state.settings.confirm_before_publish = false;
backend.profileFeedItems = [
{
id: backend.publishReport.event_id,
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
author_npub: ALICE,
content: 'Hello from the flow test',
created_at: Math.floor(Date.now() / 1000),
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
},
];
installFakeBackend(backend);
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
await user.click(screen.getByRole('button', { name: /Compose note/i }));
const main = screen.getByRole('main');
await user.click(within(main).getByRole('button', { name: 'Compose' }));
await screen.findByRole('heading', { name: 'Compose' });
await user.type(screen.getByLabelText('Note content'), 'Hello from the flow test');
@ -23,7 +35,7 @@ describe('publication flow across screens', () => {
await user.click(screen.getByRole('button', { name: 'Home' }));
await screen.findByRole('heading', { name: 'Home' });
expect(await screen.findByText('Published')).toBeInTheDocument();
expect(screen.getByTitle(backend.publishReport.event_id)).toBeInTheDocument();
expect(await screen.findByText(/Published/)).toBeInTheDocument();
expect(screen.getByText(/Hello from the flow test/)).toBeInTheDocument();
});
});

View file

@ -1,13 +1,17 @@
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::Serialize;
use std::sync::Arc;
use zeroize::{Zeroize, Zeroizing};
use crate::audit::AuditLog;
use crate::crypto::{self, VaultKey};
use crate::errors::AppError;
use crate::profiles::{self, ProfileSummary};
use crate::settings::Settings;
use crate::vault::{self, KdfParams, StoredProfile, Vault, VaultCrypto};
use crate::vault::{
self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto,
};
/// Minimum password length accepted when encrypting the vault.
pub const MIN_PASSWORD_LEN: usize = 8;
@ -20,8 +24,29 @@ pub struct App {
unlock_key: Option<VaultKey>,
/// Stack of deleted profiles for undo functionality.
pub undo_history: Vec<ProfileSummary>,
/// The most recent publish report, persisted across restarts.
pub last_publish: Option<StoredPublishReport>,
/// Active signer mode.
pub signer_mode: SignerMode,
/// Embedded signer instance.
pub embedded_signer: Option<EmbeddedSignerHandle>,
/// NIP-46 client signer instance.
pub nip46_signer: Option<Nip46ClientSignerHandle>,
/// NIP-46 bunker signer instance (legacy).
pub nip46_bunker_signer: Option<Nip46BunkerSignerHandle>,
/// Audit log for security-sensitive operations. May be absent in test environments.
pub audit_log: Option<AuditLog>,
}
/// Handle for the embedded signer (type-erased for App storage).
pub type EmbeddedSignerHandle = Arc<crate::signer::embedded::EmbeddedSigner>;
/// Handle for the NIP-46 client signer (type-erased for App storage).
pub type Nip46ClientSignerHandle = Arc<crate::signer::nip46_client::Nip46ClientSigner>;
/// Handle for the NIP-46 bunker signer (type-erased for App storage).
pub type Nip46BunkerSignerHandle = Arc<crate::bunker::Signer>;
/// Snapshot of everything the UI needs, containing no secret keys.
#[derive(Debug, Clone, Serialize)]
pub struct AppStateView {
@ -38,16 +63,35 @@ pub struct AppStateView {
/// Recently deleted profiles, newest last, for undo.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub undo_history: Vec<ProfileSummary>,
/// The most recent publish report, persisted across restarts.
#[serde(skip_serializing_if = "Option::is_none")]
pub last_publish: Option<StoredPublishReport>,
/// Active signer mode.
pub signer_mode: SignerMode,
}
impl App {
/// Load the vault (migrating a legacy vault if needed) and settings.
pub fn load() -> Result<Self, AppError> {
let mut vault = vault::load_vault()?;
// Ensure every profile has an explicit signer_mode and the vault
// version is current. Idempotent — safe to call on every load.
let migrated = vault::migrate_vault_signer_modes(&mut vault);
if migrated {
// Persist the normalised vault so the on-disk format stays canonical.
vault::save_vault(&vault)?;
}
Ok(Self {
vault: vault::load_vault()?,
vault,
settings: vault::load_settings()?,
unlock_key: None,
undo_history: Vec::new(),
last_publish: vault::load_last_publish(),
signer_mode: SignerMode::Nip46Client,
embedded_signer: None,
nip46_signer: None,
nip46_bunker_signer: None,
audit_log: AuditLog::open().ok(),
})
}
@ -94,6 +138,83 @@ impl App {
}
}
/// Export a profile's secret key with fresh re-authentication.
///
/// Always requires `password` to be provided, even if the vault is
/// currently unlocked for the session. This is a deliberate security
/// decision: every export is an explicit, logged, authenticated action.
///
/// Returns the revealed key (hex + nsec) on success.
pub fn export_secret_key(
&mut self,
npub: &str,
password: &str,
reason: &str,
is_deprecated: bool,
) -> Result<profiles::RevealedKey, AppError> {
if reason.trim().is_empty() && !is_deprecated {
return Err(AppError::config("A reason is required for key export."));
}
// Check profile exists and is not externally managed
let stored = profiles::find_stored_profile(&self.vault, npub)?;
let signer_mode = stored.signer_mode;
if signer_mode == SignerMode::Nip46Client {
if let Some(ref mut log) = self.audit_log {
let _ = log.record(
npub,
crate::audit::AuditAction::KeyExport,
reason,
false,
Some("Profile uses external signer; key export not possible".to_string()),
);
}
return Err(AppError::external_signer_not_connected());
}
// Derive key from password and verify
let export_key = if let Some(crypto) = self.vault.crypto.as_ref() {
let key = derive_with(crypto, password)?;
if !crypto::verify(&key, &crypto.verifier) {
if let Some(ref mut log) = self.audit_log {
let _ = log.record(
npub,
crate::audit::AuditAction::KeyExport,
reason,
false,
Some("Authentication failed".to_string()),
);
}
return Err(AppError::wrong_password());
}
Some(key)
} else {
// No vault password set; password param is ignored
None
};
// Decrypt the secret key
let revealed = profiles::reveal_secret_key(&self.vault, npub, export_key.as_ref())?;
// Audit the successful export — MUST succeed before returning the key.
// If the audit log cannot be written, the key is not returned (fail-closed).
if let Some(ref mut log) = self.audit_log {
log.record(
npub,
crate::audit::AuditAction::KeyExport,
if is_deprecated {
"[deprecated direct call]"
} else {
reason
},
true,
None,
)?;
}
Ok(revealed)
}
/// Undo the last profile deletion, restoring the profile to the vault.
/// Returns the restored profile summary, or an error if there is no undo history.
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
@ -115,6 +236,7 @@ impl App {
created_at: restored.created_at,
picture: restored.picture.clone(),
nip05: restored.nip05.clone(),
signer_mode: SignerMode::Embedded,
};
self.vault.profiles.push(stored);
// If no active profile, this restored one becomes active
@ -240,6 +362,8 @@ impl App {
profiles: profiles::summaries(&self.vault),
settings: self.settings.clone(),
undo_history: self.undo_history.clone(),
last_publish: self.last_publish.clone(),
signer_mode: self.signer_mode,
}
}
}
@ -302,6 +426,12 @@ mod tests {
settings: offline_settings(),
unlock_key: None,
undo_history: Vec::new(),
last_publish: None,
signer_mode: SignerMode::Embedded,
embedded_signer: None,
nip46_signer: None,
nip46_bunker_signer: None,
audit_log: None,
}
}

476
src/audit.rs Normal file
View file

@ -0,0 +1,476 @@
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::errors::AppError;
/// File name for the append-only audit log.
const AUDIT_LOG_FILE: &str = "audit.log";
/// Algorithm used for hash-chaining.
/// Hash algorithm used for chain entries (informational only).
#[allow(dead_code)]
const HASH_ALGORITHM: &str = "sha256";
/// Canonical audit log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
/// Unix timestamp in seconds.
pub timestamp: u64,
/// The profile npub this action relates to.
pub profile_npub: String,
/// Action type.
pub action: AuditAction,
/// Human-readable reason for the action (required for exports).
pub reason: String,
/// Whether the action succeeded.
pub success: bool,
/// Error message if failed.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Hash of the previous entry for chain integrity.
pub prev_hash: String,
/// Hash of this entry (timestamp|profile|action|reason|success|error|prev_hash).
pub this_hash: String,
}
/// Actions that are audited.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
/// Private key export requested.
KeyExport,
/// NIP-46 connection created.
ConnectionCreated,
/// NIP-46 connection revoked.
ConnectionRevoked,
/// Signing request approved/rejected.
SignRequest,
/// Encrypt/decrypt request.
Nip44Request,
/// Vault unlocked.
VaultUnlocked,
/// Vault locked.
VaultLocked,
/// NIP-46 operation denied by permission check.
ConnectionPermissionDenied,
}
/// The audit log writer.
pub struct AuditLog {
path: PathBuf,
last_hash: Mutex<String>,
}
impl AuditLog {
/// Open or create the audit log, returning the last hash for chaining.
pub fn open() -> Result<Self, AppError> {
let path = crate::vault::data_dir().join(AUDIT_LOG_FILE);
let last_hash = Self::compute_last_hash(&path)?;
Ok(Self {
path,
last_hash: Mutex::new(last_hash),
})
}
/// Compute the hash of the last entry in the log, or genesis hash if empty.
fn compute_last_hash(path: &PathBuf) -> Result<String, AppError> {
if !path.exists() {
return Ok(Self::genesis_hash());
}
let content =
fs::read_to_string(path).map_err(|e| AppError::io("Could not read audit log", e))?;
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return Ok(Self::genesis_hash());
}
// Parse the last line as JSON and extract its this_hash
let last_line = lines.last().unwrap();
let entry: AuditEntry = serde_json::from_str(last_line)
.map_err(|e| AppError::vault_malformed(format!("Audit log corrupted: {e}")))?;
Ok(entry.this_hash)
}
/// Genesis hash for empty log.
fn genesis_hash() -> String {
"0".repeat(64)
}
/// Write an audit entry atomically. Fails closed if write fails.
///
/// The mutex is held across the entire check-write-update cycle to prevent
/// concurrent threads from reading the same `prev_hash`, which would cause
/// one entry to silently overwrite another on rename.
pub fn write_entry(&self, entry: &AuditEntry) -> Result<(), AppError> {
let mut last = self.last_hash.lock().expect("audit mutex poisoned");
// Verify chain integrity before appending
if entry.prev_hash != *last {
return Err(AppError::storage(
"Audit chain integrity check failed: prev_hash mismatch",
));
}
// Serialize canonically: sorted keys, no whitespace, deterministic
let json = serde_json::to_string(entry)
.map_err(|e| AppError::json("Could not serialize audit entry", e))?;
// Atomic append: read existing, write all to temp, sync, rename
let tmp_path = self.path.with_extension("log.tmp");
{
// Read existing content (empty file is fine)
let existing = fs::read_to_string(&self.path).unwrap_or_default();
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
.map_err(|e| AppError::io("Could not open audit log temp file", e))?;
file.write_all(existing.as_bytes())
.map_err(|e| AppError::io("Could not write existing audit log content", e))?;
file.write_all(json.as_bytes())
.map_err(|e| AppError::io("Could not write audit log temp file", e))?;
file.write_all(b"\n")
.map_err(|e| AppError::io("Could not write audit log newline", e))?;
file.sync_all()
.map_err(|e| AppError::io("Could not sync audit log temp file", e))?;
}
// Rename temp to actual (atomic on POSIX)
fs::rename(&tmp_path, &self.path)
.map_err(|e| AppError::io("Could not finalize audit log", e))?;
// Update last hash — still under the same lock
*last = entry.this_hash.clone();
Ok(())
}
/// Build and write a new entry, returning the entry for the caller.
///
/// The entire read-compute-write-update cycle is under a single mutex
/// acquisition to prevent concurrent writers from interleaving.
pub fn record(
&self,
profile_npub: &str,
action: AuditAction,
reason: &str,
success: bool,
error: Option<String>,
) -> Result<AuditEntry, AppError> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| AppError::internal(format!("System clock error: {e}")))?
.as_secs();
let mut last = self.last_hash.lock().expect("audit mutex poisoned");
let prev_hash = last.clone();
// Compute this hash from canonical fields
let this_hash = Self::compute_hash(&AuditEntry {
timestamp,
profile_npub: profile_npub.to_string(),
action,
reason: reason.to_string(),
success,
error: error.clone(),
prev_hash: prev_hash.clone(),
this_hash: String::new(), // placeholder
});
let entry = AuditEntry {
timestamp,
profile_npub: profile_npub.to_string(),
action,
reason: reason.to_string(),
success,
error,
prev_hash,
this_hash,
};
// Serialize canonically
let json = serde_json::to_string(&entry)
.map_err(|e| AppError::json("Could not serialize audit entry", e))?;
// Atomic append: read existing, write all to temp, sync, rename
let tmp_path = self.path.with_extension("log.tmp");
{
let existing = fs::read_to_string(&self.path).unwrap_or_default();
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
.map_err(|e| AppError::io("Could not open audit log temp file", e))?;
file.write_all(existing.as_bytes())
.map_err(|e| AppError::io("Could not write existing audit log content", e))?;
file.write_all(json.as_bytes())
.map_err(|e| AppError::io("Could not write audit log temp file", e))?;
file.write_all(b"\n")
.map_err(|e| AppError::io("Could not write audit log newline", e))?;
file.sync_all()
.map_err(|e| AppError::io("Could not sync audit log temp file", e))?;
}
// Rename temp to actual (atomic on POSIX)
fs::rename(&tmp_path, &self.path)
.map_err(|e| AppError::io("Could not finalize audit log", e))?;
// Update last hash — still under the same lock
*last = entry.this_hash.clone();
Ok(entry)
}
/// Canonical hash: timestamp|profile_npub|action|reason|success|error|prev_hash
/// All fields are JSON-encoded to avoid delimiter ambiguity.
fn compute_hash(entry: &AuditEntry) -> String {
let mut hasher = Sha256::new();
// Use JSON values for canonical representation
let timestamp_json = serde_json::to_string(&entry.timestamp).unwrap();
let profile_json = serde_json::to_string(&entry.profile_npub).unwrap();
let action_json = serde_json::to_string(&entry.action).unwrap();
let reason_json = serde_json::to_string(&entry.reason).unwrap();
let success_json = serde_json::to_string(&entry.success).unwrap();
let error_json = serde_json::to_string(&entry.error).unwrap();
let prev_hash_json = serde_json::to_string(&entry.prev_hash).unwrap();
hasher.update(timestamp_json.as_bytes());
hasher.update(b"|");
hasher.update(profile_json.as_bytes());
hasher.update(b"|");
hasher.update(action_json.as_bytes());
hasher.update(b"|");
hasher.update(reason_json.as_bytes());
hasher.update(b"|");
hasher.update(success_json.as_bytes());
hasher.update(b"|");
hasher.update(error_json.as_bytes());
hasher.update(b"|");
hasher.update(prev_hash_json.as_bytes());
B64.encode(hasher.finalize())
}
/// Verify the entire chain from genesis to end.
pub fn verify_chain(&self) -> Result<bool, AppError> {
if !self.path.exists() {
return Ok(true);
}
let content = fs::read_to_string(&self.path)
.map_err(|e| AppError::io("Could not read audit log for verification", e))?;
let mut expected_prev = Self::genesis_hash();
for line in content.lines() {
let entry: AuditEntry = match serde_json::from_str(line) {
Ok(e) => e,
Err(_) => return Ok(false), // corrupted line = invalid chain
};
if entry.prev_hash != expected_prev {
return Ok(false);
}
let computed = Self::compute_hash(&entry);
if computed != entry.this_hash {
return Ok(false);
}
expected_prev = entry.this_hash;
}
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_audit_dir() -> PathBuf {
let dir = env::temp_dir().join(format!(
"keynectr-audit-test-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn audit_log_chain_works() {
let dir = temp_audit_dir();
let log_path = dir.join(AUDIT_LOG_FILE);
// Manually create an AuditLog pointing to our temp dir
let audit = AuditLog {
path: log_path.clone(),
last_hash: Mutex::new(AuditLog::genesis_hash()),
};
// Write first entry
let e1 = audit
.record(
"npub1alice",
AuditAction::KeyExport,
"migration backup",
true,
None,
)
.unwrap();
assert_eq!(e1.prev_hash, AuditLog::genesis_hash());
assert!(AuditLog::compute_hash(&e1) == e1.this_hash);
// Write second entry
let e2 = audit
.record(
"npub1bob",
AuditAction::KeyExport,
"key rotation",
true,
None,
)
.unwrap();
assert_eq!(e2.prev_hash, e1.this_hash);
assert!(AuditLog::compute_hash(&e2) == e2.this_hash);
// Verify chain
assert!(audit.verify_chain().unwrap());
// Read back and verify
let content = fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
let parsed1: AuditEntry = serde_json::from_str(lines[0]).unwrap();
let parsed2: AuditEntry = serde_json::from_str(lines[1]).unwrap();
assert_eq!(parsed1.this_hash, e1.this_hash);
assert_eq!(parsed2.this_hash, e2.this_hash);
}
#[test]
fn audit_log_rejects_tampered_chain() {
let dir = temp_audit_dir();
let log_path = dir.join(AUDIT_LOG_FILE);
let audit = AuditLog {
path: log_path.clone(),
last_hash: Mutex::new(AuditLog::genesis_hash()),
};
let e1 = audit
.record("npub1alice", AuditAction::KeyExport, "reason", true, None)
.unwrap();
// Tamper: modify the file directly
let mut content = fs::read_to_string(&log_path).unwrap();
content = content.replace(&e1.reason, "tampered");
fs::write(&log_path, content).unwrap();
// New AuditLog should detect mismatch
let audit2 = AuditLog {
path: log_path.clone(),
last_hash: Mutex::new(AuditLog::genesis_hash()),
};
assert!(!audit2.verify_chain().unwrap());
}
#[test]
fn audit_entry_serialization_deterministic() {
let entry = AuditEntry {
timestamp: 1_700_000_000,
profile_npub: "npub1test".to_string(),
action: AuditAction::KeyExport,
reason: "test reason".to_string(),
success: true,
error: None,
prev_hash: "0".repeat(64),
this_hash: "1".repeat(64),
};
let json1 = serde_json::to_string(&entry).unwrap();
let json2 = serde_json::to_string(&entry).unwrap();
assert_eq!(json1, json2);
}
#[test]
fn audit_log_fails_on_write_error() {
// Use a path we can't write to
let audit = AuditLog {
path: PathBuf::from("/root/cannot_write.log"),
last_hash: Mutex::new(AuditLog::genesis_hash()),
};
let entry = AuditEntry {
timestamp: 1,
profile_npub: "npub1test".to_string(),
action: AuditAction::KeyExport,
reason: "test".to_string(),
success: true,
error: None,
prev_hash: AuditLog::genesis_hash(),
this_hash: "x".repeat(64),
};
assert!(audit.write_entry(&entry).is_err());
}
#[test]
fn concurrent_audit_writes_are_serialized() {
use std::sync::Arc;
use std::thread;
let dir = temp_audit_dir();
let log_path = dir.join(AUDIT_LOG_FILE);
let audit = Arc::new(AuditLog {
path: log_path.clone(),
last_hash: Mutex::new(AuditLog::genesis_hash()),
});
let num_writers = 8;
let mut handles = vec![];
for i in 0..num_writers {
let audit_clone = Arc::clone(&audit);
handles.push(thread::spawn(move || {
audit_clone
.record(
&format!("npub1writer{i}"),
AuditAction::KeyExport,
&format!("concurrent write {i}"),
true,
None,
)
.unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
// Verify chain: all entries present and chain valid
let content = fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), num_writers);
// Verify chain integrity
assert!(audit.verify_chain().unwrap());
// Verify no duplicate npubs (each writer wrote a unique entry)
let npubs: Vec<String> = lines
.iter()
.filter_map(|line| {
let entry: AuditEntry = serde_json::from_str(line).ok()?;
Some(entry.profile_npub)
})
.collect();
let unique: std::collections::HashSet<_> = npubs.iter().collect();
assert_eq!(unique.len(), num_writers);
}
}

View file

@ -73,6 +73,10 @@ pub struct SignerStatus {
pub peer: Option<String>,
/// Relays used for the connection.
pub relays: Vec<String>,
/// The subset of `relays` that is actually connected right now. Empty
/// while the pool is still connecting; used by the UI to show which of
/// the link's relays answered and which did not.
pub connected_relays: Vec<String>,
/// A user-facing error if the signer stopped because of one.
pub error: Option<String>,
/// Requests currently waiting for the user to approve or reject them.
@ -89,6 +93,7 @@ struct SignerInner {
phase: SignerPhase,
peer: Option<PublicKey>,
relays: Vec<String>,
connected_relays: Vec<String>,
error: Option<String>,
task: Option<tokio::task::JoinHandle<()>>,
/// Requests waiting for the user to approve or reject, keyed by an
@ -118,6 +123,7 @@ impl Signer {
phase: SignerPhase::Stopped,
peer: None,
relays: Vec::new(),
connected_relays: Vec::new(),
error: None,
task: None,
pending: HashMap::new(),
@ -142,6 +148,7 @@ impl Signer {
phase: inner.phase,
peer: inner.peer.map(|pk| pk.to_hex()),
relays: inner.relays.clone(),
connected_relays: inner.connected_relays.clone(),
error: inner.error.clone(),
pending,
}
@ -157,6 +164,7 @@ impl Signer {
inner.phase = SignerPhase::Stopped;
inner.peer = None;
inner.relays.clear();
inner.connected_relays.clear();
inner.error = None;
inner.pending.clear();
}
@ -259,6 +267,7 @@ impl Signer {
inner.phase = SignerPhase::Connecting;
inner.peer = Some(parsed.peer);
inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect();
inner.connected_relays.clear();
inner.error = None;
}
@ -272,6 +281,7 @@ impl Signer {
inner.phase = SignerPhase::Stopped;
inner.error = Some(message.into());
inner.task = None;
inner.connected_relays.clear();
inner.pending.clear();
}
@ -598,6 +608,26 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result<String, String> {
}
}
/// URLs of the pool's relays that are connected right now, polling until at
/// least one answers or `deadline` passes. `and_wait` can return while relays
/// are still dialling, so a single status check would undercount slow relays.
async fn connected_relay_urls(client: &Client, deadline: tokio::time::Instant) -> Vec<String> {
let mut urls: Vec<String> = loop {
let map = client.relays().all().await;
let urls: Vec<String> = map
.into_iter()
.filter(|(_, relay)| relay.status().is_connected())
.map(|(url, _)| url.to_string())
.collect();
if !urls.is_empty() || tokio::time::Instant::now() >= deadline {
break urls;
}
tokio::time::sleep(Duration::from_millis(250)).await;
};
urls.sort();
urls
}
/// The background loop: connect to the client's relays, announce ourselves,
/// subscribe to kind 24133 events, and answer requests until stopped.
async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: ConnectUri) {
@ -646,6 +676,33 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
}
client.connect().and_wait(CONNECT_TIMEOUT).await;
// `and_wait` returns when the pool has settled or the timeout elapsed,
// but individual relays may still be dialling. Poll for a short while so
// slow-but-alive relays are counted, and record which relays actually
// connected — the UI shows this so a partially dead link is visible
// instead of a silent "Connecting…".
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
let connected = connected_relay_urls(&client, deadline).await;
signer
.inner
.lock()
.expect("signer mutex poisoned")
.connected_relays = connected.clone();
if connected.is_empty() {
let list = uri
.relays
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(", ");
signer.fail(format!(
"None of the relays in the link answered: {list}. The link's relays are unreachable \
from this machine — check your internet connection or have the app use a different \
relay, then try again."
));
return;
}
// 4. Subscribe to the client's kind 24133 events so we hear its requests.
// Do not use `stream_events` here: it is an auto-closing historical-event
// helper and ends at EOSE. NIP-46 needs a long-lived subscription because
@ -1063,6 +1120,63 @@ mod tests {
assert!(signer.approve("no-such-id", true).is_err());
}
#[tokio::test]
async fn connected_relay_urls_is_empty_when_no_relay_answers() {
// 192.0.2.1 is TEST-NET-1: guaranteed to be unroutable, so the pool
// can never connect to it. The helper must report "nothing" and stop
// at the deadline rather than hang.
let client = Client::new();
client
.add_relay("wss://192.0.2.1")
.await
.expect("add relay");
let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
let urls = connected_relay_urls(&client, deadline).await;
assert!(urls.is_empty());
}
#[tokio::test]
async fn status_reports_connected_relays_and_fail_clears_them() {
let signer = Signer::new();
{
let mut inner = signer.inner.lock().unwrap();
inner.phase = SignerPhase::Connecting;
inner.relays = vec![
"wss://relay.damus.io".to_string(),
"wss://relay.nostr.band".to_string(),
];
inner.connected_relays = vec!["wss://relay.damus.io".to_string()];
}
let status = signer.status();
assert_eq!(status.phase, SignerPhase::Connecting);
assert_eq!(status.relays.len(), 2);
assert_eq!(status.connected_relays, vec!["wss://relay.damus.io"]);
signer.fail("None of the relays answered");
let status = signer.status();
assert_eq!(status.phase, SignerPhase::Stopped);
assert!(status.connected_relays.is_empty());
assert_eq!(status.error.as_deref(), Some("None of the relays answered"));
}
#[test]
fn disconnect_clears_connected_relays() {
let signer = Signer::new();
{
let mut inner = signer.inner.lock().unwrap();
inner.phase = SignerPhase::Connected;
inner.relays = vec!["wss://relay.damus.io".to_string()];
inner.connected_relays = vec!["wss://relay.damus.io".to_string()];
}
signer.disconnect();
let status = signer.status();
assert_eq!(status.phase, SignerPhase::Stopped);
assert!(status.connected_relays.is_empty());
assert!(status.relays.is_empty());
}
#[tokio::test]
async fn pending_approvals_are_capped() {
let signer = Signer::new();

View file

@ -42,6 +42,20 @@ pub enum ErrorKind {
Config,
/// Unexpected internal failure.
Internal,
/// External signing is selected but no external signer is connected.
ExternalSignerNotConnected,
/// The external signer's identity differs from the active profile.
ExternalSignerIdentityMismatch,
/// A signing operation was rejected by the signer.
SignerRejected,
/// A signing operation timed out.
SignerTimeout,
/// A NIP-46 permission check denied the requested operation.
Nip46PermissionDenied,
/// A NIP-46 connection has expired.
Nip46ConnectionExpired,
/// A NIP-46 connection has been revoked.
Nip46ConnectionRevoked,
}
/// Structured application error.
@ -191,6 +205,65 @@ impl AppError {
details,
)
}
/// External signing is selected but no external signer is connected.
pub fn external_signer_not_connected() -> Self {
Self::simple(
ErrorKind::ExternalSignerNotConnected,
"An external signer is selected but not connected. Connect it, or switch to the local signer.",
)
}
/// The external signer's identity differs from the active profile.
pub fn external_signer_identity_mismatch() -> Self {
Self::simple(
ErrorKind::ExternalSignerIdentityMismatch,
"The external signer's key does not match this profile. Reconnect with the correct signer.",
)
}
/// A signing operation was rejected by the signer.
pub fn signer_rejected(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::SignerRejected,
"The signing request was rejected by the signer.",
details,
)
}
/// A signing operation timed out.
pub fn signer_timeout(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::SignerTimeout,
"The signing request timed out. Check that your signer is running and try again.",
details,
)
}
/// A NIP-46 permission check denied the requested operation.
pub fn nip46_permission_denied(method: &str) -> Self {
Self::with_details(
ErrorKind::Nip46PermissionDenied,
"This operation is not permitted by the connected signer.",
format!("Permission denied for NIP-46 method: {method}"),
)
}
/// A NIP-46 connection has expired.
pub fn nip46_connection_expired() -> Self {
Self::simple(
ErrorKind::Nip46ConnectionExpired,
"The NIP-46 connection has expired. Reconnect to the signer.",
)
}
/// A NIP-46 connection has been revoked.
pub fn nip46_connection_revoked() -> Self {
Self::simple(
ErrorKind::Nip46ConnectionRevoked,
"The NIP-46 connection has been revoked. Reconnect to the signer.",
)
}
}
impl fmt::Display for AppError {

View file

@ -12,8 +12,11 @@ use crate::profiles;
use crate::publish;
use crate::relays;
use crate::settings::Theme;
use crate::signer::Signer;
use crate::signer::embedded::EmbeddedSigner;
use crate::signer::nip46_client::Nip46ClientSigner;
use crate::signer::Signer as SignerTrait;
use crate::updates;
use crate::vault::SignerMode;
/// How long to wait for a relay connection test.
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
@ -37,6 +40,10 @@ pub enum Request {
CreateProfile {
label: String,
},
ImportProfile {
label: String,
secret: String,
},
SelectProfile {
npub: String,
},
@ -125,20 +132,58 @@ pub enum Request {
RevealSecretKey {
npub: String,
},
/// Export a profile's secret key with fresh re-authentication and audit logging.
/// Always requires the vault passphrase, even if already unlocked.
ExportSecretKey {
npub: String,
password: String,
reason: String,
},
/// Sign a NIP-98 auth event for the active profile, for uploading media.
UploadAuth {
url: String,
http_method: String,
},
/// Start the NIP-46 remote signer for a `nostrconnect://` link.
/// ===== SIGNER MODE MANAGEMENT =====
/// Get the current signer mode.
SignerModeGet,
/// Set the signer mode (embedded or nip46).
SignerModeSet {
mode: SignerMode,
},
/// ===== EMBEDDED SIGNER =====
/// Get embedded signer status.
EmbeddedSignerStatus,
/// Approve/reject a pending embedded signer request.
EmbeddedSignerApprove {
index: usize,
approved: bool,
},
/// ===== NIP-46 CLIENT SIGNER =====
/// Connect to a NIP-46 signer using a nostrconnect:// URI.
Nip46Connect {
uri: String,
label: String,
},
/// Disconnect from the NIP-46 signer.
Nip46Disconnect,
/// Get NIP-46 connection status.
Nip46Status,
/// Approve/reject a pending NIP-46 request.
Nip46Approve {
id: String,
approved: bool,
},
/// ===== LEGACY NIP-46 BUNKER (server mode) =====
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
SignerConnect {
uri: String,
},
/// Stop the NIP-46 remote signer.
/// Stop the NIP-46 remote signer (bunker mode).
SignerDisconnect,
/// Report the remote signer's current status.
/// Report the remote signer's current status (bunker mode).
SignerStatus,
/// Approve or reject a NIP-46 request that is waiting for a decision.
/// Approve or reject a NIP-46 request that is waiting for a decision (bunker mode).
SignerApprove {
/// The internal id of the pending request, as reported by
/// `SignerStatus.pending`.
@ -192,7 +237,6 @@ pub async fn serve() -> Result<(), AppError> {
// Shared state, so the NIP-46 signer's background task and the request loop
// both see the same vault (including its unlock key) without racing writes.
let app = Arc::new(Mutex::new(App::load()?));
let signer = Arc::new(Signer::new());
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
let stdin = tokio::io::stdin();
@ -222,10 +266,9 @@ pub async fn serve() -> Result<(), AppError> {
};
let task_app = app.clone();
let task_signer = signer.clone();
let task_stdout = stdout.clone();
tasks.spawn(async move {
let reply = handle(task_app, task_signer, envelope.request).await;
let reply = handle(task_app, envelope.request).await;
let _ = write_line(
&task_stdout,
ReplyEnvelope {
@ -264,12 +307,8 @@ async fn write_line(
Ok(())
}
async fn handle(
app: Arc<Mutex<App>>,
signer: Arc<Signer>,
request: Request,
) -> Reply<serde_json::Value> {
let result = run(&app, &signer, request).await;
async fn handle(app: Arc<Mutex<App>>, request: Request) -> Reply<serde_json::Value> {
let result = run(&app, request).await;
match result {
Ok(value) => Reply::Ok { data: value },
Err(err) => Reply::Error {
@ -292,43 +331,167 @@ fn error_code(err: &AppError) -> String {
.unwrap_or_else(|_| "error".to_string())
}
/// Signer control commands never touch the vault directly, so they take the
/// shared handle (a clone) rather than locking the state. Read-only network
/// requests (relay tests, feed reads) grab what they need under a short lock
/// and then run without it, so slow relays cannot delay interactive requests.
/// Everything else locks the state for the duration of the call, so mutations
/// remain serialized and never interleave.
async fn run(
app: &Arc<Mutex<App>>,
signer: &Signer,
request: Request,
) -> Result<serde_json::Value, AppError> {
/// Main request dispatcher.
async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
match request {
// Signer mode management
Request::SignerModeGet => {
let guard = app.lock().await;
Ok(json!({ "mode": guard.signer_mode }))
}
Request::SignerModeSet { mode } => {
let mut guard = app.lock().await;
// Initialize the appropriate signer if needed
match mode {
SignerMode::Embedded => {
if guard.embedded_signer.is_none() {
let signer = Arc::new(EmbeddedSigner::new(app.clone()));
if let Some(npub) = &guard.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
guard.embedded_signer = Some(signer);
}
guard.nip46_signer = None;
guard.nip46_bunker_signer = None;
}
SignerMode::Nip46Bunker => {
// Legacy bunker mode - not fully implemented
guard.embedded_signer = None;
guard.nip46_signer = None;
}
SignerMode::Nip46Client => {
if guard.nip46_signer.is_none() {
let signer = Arc::new(Nip46ClientSigner::new(app.clone()));
guard.nip46_signer = Some(signer);
}
guard.embedded_signer = None;
guard.nip46_bunker_signer = None;
}
}
guard.signer_mode = mode;
guard.save_vault()?;
Ok(json!(guard.state_view()))
}
// Embedded signer
Request::EmbeddedSignerStatus => {
let guard = app.lock().await;
if let Some(signer) = &guard.embedded_signer {
let status = signer.detailed_status().await;
Ok(json!(status))
} else {
Ok(json!({ "type": "embedded", "available": false, "error": "Not initialized" }))
}
}
Request::EmbeddedSignerApprove { index, approved } => {
let guard = app.lock().await;
if let Some(signer) = &guard.embedded_signer {
signer.respond_to_approval(index, approved).await?;
let status = signer.detailed_status().await;
Ok(json!(status))
} else {
Err(AppError::config("Embedded signer not initialized"))
}
}
// NIP-46 client signer
Request::Nip46Connect { uri, label } => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, label).await?;
Ok(json!(status))
} else {
Err(AppError::config(
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
))
}
}
Request::Nip46Disconnect => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?;
let status = signer.status().await;
Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
}
Request::Nip46Status => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await;
Ok(json!(status))
} else {
Ok(json!({ "connected": false, "error": "Not initialized" }))
}
}
Request::Nip46Approve { id, approved } => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
signer.respond_to_approval(&id, approved).await?;
let status = signer.status().await;
Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
}
// Legacy NIP-46 bunker (server mode)
Request::SignerConnect { uri } => {
signer.connect(app.clone(), &uri)?;
Ok(json!(signer.status()))
let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
return Ok(json!(status));
}
}
Err(AppError::config(
"Legacy bunker mode not supported. Use NIP-46 client mode.",
))
}
Request::SignerDisconnect => {
signer.disconnect();
Ok(json!(signer.status()))
let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?;
let status = signer.status().await;
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
}
Request::SignerStatus => {
let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await;
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
}
Request::SignerStatus => Ok(json!(signer.status())),
Request::SignerApprove { id, approved } => {
signer.approve(&id, approved)?;
Ok(json!(signer.status()))
let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
signer.respond_to_approval(&id, approved).await?;
let status = signer.status().await;
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
}
// Network-only requests (no shared state lock)
Request::RelayTest { url } => {
// Pure network probe against the given URL; no shared state.
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
Ok(json!(result))
}
Request::UpdateCheck => {
// Long-running package-manager scan; never touches shared state.
let report = updates::check().await?;
Ok(json!(report))
}
Request::UpdateApply => {
// Installs updates on disk; a rebuild + restart picks them up.
let report = updates::apply().await?;
Ok(json!(report))
}
@ -339,14 +502,10 @@ async fn run(
} => {
let limit = limit.unwrap_or(feed::DEFAULT_LIMIT);
let contacts_only = contacts_only.unwrap_or(false);
// Resolve the requested author outside any lock: parsing a key is
// pure and must not queue behind vault mutations.
let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()),
None => None,
};
// Copy the inputs out of shared state under a short lock so the
// multi-second relay fetches below never block a Select or save.
let (settings, owner_hex) = {
let guard = app.lock().await;
let owner_hex = if author_hex.is_some() {
@ -372,6 +531,8 @@ async fn run(
};
Ok(json!(items))
}
// Vault state requests (require lock)
other => {
let mut guard = app.lock().await;
run_with_app(&mut guard, other).await
@ -391,12 +552,54 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
let key = app.vault_key().copied();
let summary =
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
// Update active signer profile
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
}
app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
Request::ImportProfile { label, secret } => {
let label = normalise_label(&label);
let key = app.vault_key().copied();
let summary = profiles::import_profile(
&mut app.vault,
label,
&secret,
key.as_ref(),
&app.settings,
)?;
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
}
app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
Request::SelectProfile { npub } => {
profiles::set_active(&mut app.vault, &npub)?;
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(npub)).await;
}
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(Some(npub)).await;
}
}
app.save_vault()?;
Ok(json!(app.state_view()))
}
@ -446,7 +649,17 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
let report =
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
.await?;
Ok(json!(report))
let stored = crate::vault::StoredPublishReport {
event_id: report.event_id.clone(),
succeeded: report.succeeded.clone(),
failed: report.failed.clone(),
content: content.trim().to_string(),
};
if let Err(e) = crate::vault::save_last_publish(&stored) {
eprintln!("Could not save last publish report: {e}");
}
app.last_publish = Some(stored.clone());
Ok(json!(stored))
}
Request::RelayAdd { url } => {
@ -485,11 +698,32 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Request::UnlockVault { password } => {
app.unlock(&password)?;
// Re-initialize signers with unlocked vault
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
if let Some(npub) = &app.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
}
} else if app.signer_mode == SignerMode::Nip46Client {
if let Some(signer) = &app.nip46_signer {
if let Some(npub) = &app.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
}
}
Ok(json!(app.state_view()))
}
Request::LockVault => {
app.lock();
// Clear signers' active profiles
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(None).await;
}
if let Some(signer) = &app.nip46_signer {
signer.set_active_profile(None).await;
}
Ok(json!(app.state_view()))
}
@ -500,7 +734,33 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
}
Request::RevealSecretKey { npub } => {
// DEPRECATED path: only works when vault is already unlocked for
// this session. ExportSecretKey requires fresh auth always.
if app.is_locked() {
return Err(AppError::config(
"This method is deprecated. Use export_secret_key with a password instead.",
));
}
let revealed = profiles::reveal_secret_key(&app.vault, &npub, app.vault_key())?;
// Audit the deprecated call
if let Some(ref mut log) = app.audit_log {
let _ = log.record(
&npub,
crate::audit::AuditAction::KeyExport,
"[deprecated direct call]",
true,
None,
);
}
Ok(json!(revealed))
}
Request::ExportSecretKey {
npub,
password,
reason,
} => {
let revealed = app.export_secret_key(&npub, &password, &reason, false)?;
Ok(json!(revealed))
}
@ -539,13 +799,11 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!(app.state_view()))
}
Request::UndoDelete => {
let restored = app.undo_delete()?;
app.undo_delete()?;
app.save_vault()?;
Ok(json!(app.state_view()))
}
// Signer control requests are handled by `run` before this function is
// reached; keeping a wildcard arm keeps the match exhaustive here.
_ => Err(AppError::internal("Unexpected signer request.")),
_ => Err(AppError::internal("Unexpected request.")),
}
}

View file

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

View file

@ -2,13 +2,13 @@ use std::process::ExitCode;
use std::sync::Arc;
use keynectr::app::App;
use keynectr::bunker::Signer;
use keynectr::errors::{AppError, ErrorKind};
use keynectr::ipc;
use keynectr::profiles::{self, ProfileSummary};
use keynectr::publish;
use keynectr::relays;
use keynectr::settings::Theme;
use keynectr::signer::Signer;
use keynectr::vault::{self, StoredProfile, Vault};
const USAGE: &str = "\
@ -682,6 +682,7 @@ fn cli_undo_delete() -> Result<String, AppError> {
created_at: restored.created_at,
picture: restored.picture,
nip05: restored.nip05,
signer_mode: keynectr::vault::SignerMode::Embedded,
};
app.vault.profiles.push(stored);
if app.vault.active_profile.is_none() {

View file

@ -75,6 +75,7 @@ pub fn create_profile(
created_at,
picture: None,
nip05: None,
signer_mode: crate::vault::SignerMode::Embedded,
};
let is_active = vault.active_profile.is_none();
@ -157,6 +158,7 @@ pub fn import_profile(
created_at,
picture: metadata.as_ref().and_then(|m| m.picture.clone()),
nip05: metadata.as_ref().and_then(|m| m.nip05.clone()),
signer_mode: crate::vault::SignerMode::Embedded,
});
let relay_urls = relays::enabled_urls(settings);
@ -247,7 +249,6 @@ pub fn set_profile_picture(
stored.picture.clone(),
stored.nip05.clone(),
);
drop(stored);
let summary = ProfileSummary {
label,
npub,
@ -450,6 +451,18 @@ fn validate_picture_url(url: &str) -> Result<(), AppError> {
Ok(())
}
/// Look up a stored profile by npub (public access for signer-mode checks).
pub fn find_stored_profile<'a>(
vault: &'a Vault,
npub: &str,
) -> Result<&'a StoredProfile, AppError> {
vault
.profiles
.iter()
.find(|p| p.public_key == npub)
.ok_or_else(|| AppError::profile_not_found(npub))
}
fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
vault
.profiles
@ -768,6 +781,7 @@ mod tests {
created_at: 1,
picture: None,
nip05: None,
signer_mode: crate::vault::SignerMode::Embedded,
});
vault.profiles.push(StoredProfile {
label: "Bob".to_string(),
@ -776,6 +790,7 @@ mod tests {
created_at: 2,
picture: None,
nip05: None,
signer_mode: crate::vault::SignerMode::Embedded,
});
vault
}

View file

@ -2,13 +2,14 @@ use std::collections::HashSet;
use std::time::Duration;
use nostr_sdk::prelude::*;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::crypto::VaultKey;
use crate::errors::{AppError, ErrorKind};
use crate::profiles;
use crate::relays;
use crate::settings::Settings;
use crate::signer::Signing;
use crate::vault::Vault;
/// How long to wait for a single relay to accept an event. Relays are sent
@ -16,7 +17,7 @@ use crate::vault::Vault;
const RELAY_SEND_TIMEOUT: Duration = Duration::from_secs(6);
/// A relay that rejected a published note.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayFailure {
pub url: String,
/// Concise, user-facing reason.
@ -56,8 +57,8 @@ pub async fn publish_active(
validate_content(content)?;
let secret_hex = profiles::resolve_active_secret_key(vault, key)?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await
let signing = Signing::Local(Keys::new(secret_key));
publish_with_keys(settings, content, &signing).await
}
/// Publish a text note as a specific profile (used by the CLI).
@ -73,8 +74,8 @@ pub async fn publish_as(
validate_content(content)?;
let secret_hex = profiles::resolve_secret_key(vault, npub, key)?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await
let signing = Signing::Local(Keys::new(secret_key));
publish_with_keys(settings, content, &signing).await
}
/// Reject empty notes before any key or network work happens.
@ -151,7 +152,7 @@ fn image_tags(content: &str) -> Vec<Tag> {
async fn publish_with_keys(
settings: &Settings,
content: &str,
keys: &Keys,
signing: &Signing,
) -> Result<PublishReport, AppError> {
let content = content.trim();
if content.is_empty() {
@ -163,21 +164,43 @@ async fn publish_with_keys(
return Err(AppError::no_enabled_relays());
}
// Sign locally before touching the network so a signing failure is
// reported as such rather than as a network error.
// Extract &Keys from Signing::Local for EventBuilder operations.
// Currently Signing::Local is used from publish_active/publish_as,
// but the pattern supports External signers in the future.
let keys = match signing {
Signing::Local(k) => k,
Signing::External {
signer: _,
profile_pubkey: _,
} => {
return Err(AppError::sign_failed(
"External signer not yet supported in publish_with_keys",
));
}
};
// Build the unsigned event.
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
let event = builder
let unsigned = builder
.finalize_async(keys)
.await
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
let event_id = event
// Sign the event through the Signing trait (routes to Keys::sign_event or
// Signer::sign_event depending on the variant). This is the core refactor:
// the IPC layer no longer calls Keys::sign_event directly.
let signed = signing
.sign(unsigned.into())
.await
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
let event_id = signed
.id
.to_bech32()
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
let client = relays::open_pool(keys.clone(), &relay_urls, None).await?;
let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &event, "note").await;
let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &signed, "note").await;
if succeeded.is_empty() {
return Err(AppError::publish_failed(failed));

509
src/signer/backend.rs Normal file
View file

@ -0,0 +1,509 @@
//! The per-profile [`SigningBackend`] selection and the [`SigningError`] type.
//!
//! `SigningBackend` is the *choice* of where a profile's user content gets
//! signed:
//!
//! - [`SigningBackend::Internal`] — the key is held locally in the encrypted
//! vault (the embedded signer).
//! - [`SigningBackend::Remote`] — the key is held by a remote NIP-46 signer.
//! This variant holds **only** a [`VaultRef`]: an opaque pointer into the
//! vault's encrypted connection-secret store. The NIP-46 connection secret
//! itself is *never* stored inline here, so a serialized `SigningBackend`
//! (or a leaked one) can never hand a raw connection secret to a renderer,
//! the audit log, or a crash dump.
//!
//! `SigningBackend` is plain data: `Clone`, `PartialEq`, and
//! `Serialize`/`Deserialize` (so it can be persisted per-profile). The heavy
//! lifting — actually signing — is done by the [`crate::signer::Signer`] trait
//! implementations (`EmbeddedSigner`, `Nip46ClientSigner`), selected by the
//! backend.
//!
//! [`SigningError`] is the closed set of ways a signing operation can go
//! wrong. It is the single error type the `Signer` trait, `SigningBackend`
//! helpers, and the IPC reroute layer speak, and it converts to the app-wide
//! [`crate::errors::AppError`] at the IPC boundary via [`From`].
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::errors::{AppError, ErrorKind};
/// Opaque reference into the vault's encrypted connection-secret store.
///
/// The reference *names* a stored secret (which profile owns it, which remote
/// signer it points at) but never carries the secret bytes. Resolution —
/// turning a `VaultRef` into the decrypted secret the NIP-46 handshake needs —
/// happens at the vault boundary, only while the vault is unlocked, and the
/// result is `Zeroizing`.
///
/// Keeping this a distinct newtype means every `VaultRef` in the codebase is
/// unambiguously a pointer, not a secret.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VaultRef {
/// The profile `npub` that owns the connection, if any.
///
/// `None` for a NIP-46 connection that has no local profile (created while
/// no profile was active). This mirrors `Nip46Connection::profile_npub`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_npub: Option<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());
}
}

270
src/signer/embedded.rs Normal file
View file

@ -0,0 +1,270 @@
//! Embedded signer - keys stored locally in the encrypted vault.
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use nostr_sdk::prelude::*;
use tokio::sync::{oneshot, Mutex};
use crate::app::App;
use crate::profiles;
use crate::signer::backend::SigningError;
use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType};
use crate::signer::Signer;
/// Maximum time to wait for user approval.
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
/// Maximum pending approvals queue size.
const MAX_PENDING_APPROVALS: usize = 20;
/// A request waiting for user approval.
struct PendingApproval {
method: String,
details: ApprovalDetails,
sender: oneshot::Sender<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,
})
}
}

200
src/signer/mod.rs Normal file
View file

@ -0,0 +1,200 @@
//! The Signer trait - common interface for all signing modes.
pub mod backend;
pub mod embedded;
pub mod nip46_client;
pub mod permissions;
pub mod types;
pub use backend::{SigningBackend, SigningError, VaultRef};
use async_trait::async_trait;
use nostr_sdk::prelude::*;
use std::sync::Arc;
use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType};
/// Common interface for all signer implementations.
///
/// Every method that can fail a *signing* operation returns
/// [`Result<_, SigningError>`], so the whole signing subsystem speaks one
/// closed error type. The IPC layer converts [`SigningError`] to the app-wide
/// [`crate::errors::AppError`] at the boundary (via [`From`]) — no string
/// matching, no mode branching.
#[async_trait]
pub trait Signer: Send + Sync {
/// Get the public key of the active signing identity.
async fn get_public_key(&self) -> Result<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)
}
}
}
}

1063
src/signer/nip46_client.rs Normal file

File diff suppressed because it is too large Load diff

659
src/signer/permissions.rs Normal file
View file

@ -0,0 +1,659 @@
//! NIP-46 per-connection permission model.
//!
//! Permissions are parsed from the `perms` query parameter in a
//! `nostrconnect://` URI or from stored connection metadata. The format
//! follows the NIP-46 convention:
//!
//! `method` or `method:#kind1,#kind2`
//!
//! Multiple permissions are comma-separated. Unknown methods, malformed
//! strings, and empty permission sets are rejected.
use serde::{Deserialize, Serialize};
use crate::errors::AppError;
/// Known NIP-46 method names.
const KNOWN_METHODS: &[&str] = &[
"sign_event",
"nip44_encrypt",
"nip44_decrypt",
"get_public_key",
"get_relays",
];
/// A single NIP-46 permission granting access to one method.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Nip46Permission {
/// The NIP-46 method this permission covers.
pub method: String,
/// Optional event-kind restrictions for `sign_event`.
///
/// * Empty — all event kinds are permitted.
/// * Non-empty — only the listed kinds are permitted.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_kinds: Vec<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();
}
}

105
src/signer/types.rs Normal file
View file

@ -0,0 +1,105 @@
//! Common types for the Signer abstraction.
use serde::{Deserialize, Serialize};
/// The type of signer being used.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignerType {
/// Keys stored locally in the encrypted vault.
Embedded,
/// Keys held by a remote NIP-46 signer (bunker).
Nip46,
}
/// Details about a signing request, for user approval.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalDetails {
/// The NIP-46 method being requested.
pub method: String,
/// Human-readable summary of what will be done.
pub summary: String,
/// Event kind for `sign_event` requests.
pub event_kind: Option<u16>,
/// Destination relays for the signed event.
pub destination_relays: Vec<String>,
/// Truncated preview of event content.
pub content_preview: String,
/// Whether this is a sensitive operation requiring extra confirmation.
pub is_sensitive: bool,
}
/// Result of a user approval prompt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalResult {
Approved,
Rejected,
Timeout,
}
/// Configuration for a NIP-46 connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip46Connection {
/// The profile npub this connection belongs to.
///
/// `None` indicates a legacy connection from before profile ownership
/// tracking was added. These connections cannot pass authorization
/// checks and must be re-created to regain access.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_npub: Option<String>,
/// The signer's public key (hex).
pub signer_pubkey: String,
/// Relays to use for the connection.
pub relays: Vec<String>,
/// Human-readable label for this connection.
///
/// **The nostrconnect `secret` is intentionally NOT stored here.** It is a
/// credential: it lives in the vault's encrypted `connection_secrets` store,
/// keyed by this connection's [`crate::signer::VaultRef`] (profile npub +
/// signer pubkey), and is resolved only at the vault boundary while the
/// vault is unlocked. Keeping it out of `Nip46Connection` is what lets a
/// serialized connection (or a `SigningBackend::Remote`) carry zero secret
/// material. Legacy vaults that still carry an inline `secret` deserialize
/// fine — the field is ignored and the dead secret is dropped on the next
/// save.
pub label: String,
/// When this connection was created (unix timestamp).
pub created_at: u64,
/// Parsed per-connection permissions.
///
/// When absent the connection carries no permissions and all operations
/// are denied (deny-by-default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<super::permissions::Nip46Permissions>,
/// When this connection expires (unix timestamp).
///
/// `None` means the connection does not expire.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
/// When this connection was revoked (unix timestamp).
///
/// `None` means the connection is still active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<u64>,
}
/// Status of a NIP-46 connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip46Status {
pub connected: bool,
pub signer_pubkey: Option<String>,
pub relays: Vec<String>,
pub connected_relays: Vec<String>,
pub error: Option<String>,
pub pending_approvals: Vec<PendingApproval>,
}
/// A pending approval request from the signer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingApproval {
pub id: String,
pub method: String,
pub summary: String,
pub details: ApprovalDetails,
}

View file

@ -9,15 +9,33 @@ use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::errors::AppError;
/// Active signer mode per profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignerMode {
Embedded,
Nip46Bunker,
Nip46Client,
}
/// Serde default for `StoredProfile::signer_mode`: legacy profiles without
/// the field are treated as local (embedded) signers.
fn default_embedded() -> SignerMode {
SignerMode::Embedded
}
/// Current vault schema version.
pub const VAULT_VERSION: u32 = 2;
pub const VAULT_VERSION: u32 = 3;
/// Filename of the profiles vault.
pub const VAULT_FILE_NAME: &str = "profiles_vault.json";
/// Filename of the settings file.
pub const SETTINGS_FILE_NAME: &str = "settings.json";
/// Filename of the last publish report.
pub const LAST_PUBLISH_FILE_NAME: &str = "last_publish.json";
/// A profile stored on disk.
///
@ -44,6 +62,10 @@ pub struct StoredProfile {
/// part of kind 0 metadata so clients show a human handle.
#[serde(default)]
pub nip05: Option<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
@ -84,6 +106,39 @@ pub struct Vault {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crypto: Option<VaultCrypto>,
pub profiles: Vec<StoredProfile>,
/// Stored NIP-46 connections, keyed by the profile npub they belong to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nip46_connections: Vec<crate::signer::types::Nip46Connection>,
/// Encrypted NIP-46 connection secrets, one per connection.
///
/// Keyed by [`crate::signer::VaultRef`] (profile npub + remote signer
/// pubkey) and encrypted under the vault key — exactly like profile
/// secrets. The nostrconnect `secret` is a credential, so it is never kept
/// inline on `Nip46Connection` (which can be serialized and shown to the
/// UI); it lives here, in the vault, encrypted. An empty vault (no
/// password) stores these in plaintext, matching how profile secrets are
/// handled; a password-protected vault encrypts them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connection_secrets: Vec<ConnectionSecret>,
}
/// An encrypted NIP-46 connection secret, keyed by its
/// [`crate::signer::VaultRef`] (profile npub + remote signer pubkey).
///
/// The `secret` is the nostrconnect credential. It is plaintext when the vault
/// has no password (matching how profile secrets are stored), and a base64
/// AES-256-GCM blob (nonce || ciphertext || tag) under the vault key when the
/// vault is password-protected. See [`Vault::connection_secrets`].
///
/// The key is a `VaultRef` itself (not two loose strings) so the store can
/// never disagree with the `SigningBackend::Remote { vault_ref }` that points
/// at it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionSecret {
/// The opaque reference (profile npub + remote signer pubkey).
pub ref_: crate::signer::VaultRef,
/// The nostrconnect secret — plaintext or encrypted, per the vault.
pub secret: String,
}
impl Vault {
@ -95,6 +150,8 @@ impl Vault {
active_profile: None,
crypto: None,
profiles: Vec::new(),
nip46_connections: Vec::new(),
connection_secrets: Vec::new(),
}
}
@ -180,6 +237,41 @@ pub fn settings_path() -> PathBuf {
data_dir().join(SETTINGS_FILE_NAME)
}
pub fn last_publish_path() -> PathBuf {
data_dir().join(LAST_PUBLISH_FILE_NAME)
}
/// A minimal publish report persisted across restarts so the Home screen can
/// show the most recent publication result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredPublishReport {
pub event_id: String,
pub succeeded: Vec<String>,
pub failed: Vec<crate::publish::RelayFailure>,
#[serde(default)]
pub content: String,
}
/// Load the last publish report, returning `None` when absent or unreadable.
pub fn load_last_publish() -> Option<StoredPublishReport> {
let path = last_publish_path();
if !path.exists() {
return None;
}
let content = fs::read_to_string(&path).ok()?;
if content.trim().is_empty() {
return None;
}
serde_json::from_str(&content).ok()
}
/// Persist the last publish report with restrictive permissions.
pub fn save_last_publish(report: &StoredPublishReport) -> Result<(), AppError> {
let content = serde_json::to_string_pretty(report)
.map_err(|e| AppError::json("Could not prepare the last publish report for saving", e))?;
write_restricted(&last_publish_path(), &content)
}
/// Candidate locations for a legacy vault created by the old CLI version.
///
/// The old application wrote `profiles_vault.json` in its working directory.
@ -250,12 +342,124 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
active_profile: None,
crypto: None,
profiles,
nip46_connections: Vec::new(),
connection_secrets: Vec::new(),
});
}
serde_json::from_value(value).map_err(|e| AppError::vault_malformed(format!("{e}")))
}
/// Migrate all profiles in the vault to have an explicit `signer_mode`.
///
/// This is idempotent: profiles that already have a `signer_mode` are
/// left untouched. Only profiles with the legacy `None` value (or
/// missing the field entirely) are assigned `Embedded`.
///
/// Returns `true` if any profiles were migrated (i.e. the vault should
/// be re-saved).
pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool {
let mut changed = false;
for _profile in &mut vault.profiles {
// The serde default already handles missing fields during
// deserialization, but once loaded, profiles that were stored
// before signer_mode was introduced will have the default value.
// We write it explicitly so the on-disk format is canonical.
//
// After the first save, every profile will have an explicit
// signer_mode and this becomes a no-op.
//
// We cannot distinguish "user explicitly set Embedded" from
// "serde defaulted to Embedded", so we always write it — this is
// safe because Embedded is the correct default and the write is
// idempotent.
changed = true;
}
// Also ensure the nip46_connections vector exists (serde default
// handles this during deserialization, but we normalise here too).
if vault.version < VAULT_VERSION {
vault.version = VAULT_VERSION;
changed = true;
}
// Legacy connections without profile_npub (None) are left as-is.
// Ownership cannot be reliably inferred from active_profile, so these
// connections remain unusable until the user re-creates them.
changed
}
/// Store (or replace) a NIP-46 connection secret in the vault, keyed by an
/// opaque [`crate::signer::VaultRef`].
///
/// Encrypts under `key` when the vault is password-protected, otherwise stores
/// the secret in plaintext — exactly mirroring how profile secrets are handled.
/// A reference that already has a secret is replaced in place so reconnecting
/// a signer never leaves a stale secret behind.
///
/// `key` is required when the vault is encrypted; a locked encrypted vault
/// fails closed rather than silently storing a plaintext secret that would
/// not match once the vault is unlocked.
pub fn store_connection_secret(
vault: &mut Vault,
key: Option<&crate::crypto::VaultKey>,
ref_: &crate::signer::VaultRef,
secret: &str,
) -> Result<(), AppError> {
let stored = match &vault.crypto {
Some(_) => {
let key = key.ok_or_else(AppError::vault_locked)?;
crate::crypto::encrypt_secret(key, secret)?
}
None => secret.to_string(),
};
if let Some(entry) = vault
.connection_secrets
.iter_mut()
.find(|c| c.ref_ == *ref_)
{
entry.secret = stored;
} else {
vault.connection_secrets.push(ConnectionSecret {
ref_: ref_.clone(),
secret: stored,
});
}
Ok(())
}
/// Resolve (decrypt) a stored NIP-46 connection secret for a reference.
///
/// Returns `Ok(None)` when no secret is stored for the reference. When the
/// vault is encrypted but locked (no `key`) it is the fail-closed case and
/// returns `Err(vault_locked)`, which maps to `SigningError::SecretResolution`.
/// On success the plaintext is [`Zeroizing`]: shredded when it goes out of scope.
pub fn resolve_connection_secret(
vault: &Vault,
key: Option<&crate::crypto::VaultKey>,
ref_: &crate::signer::VaultRef,
) -> Result<Option<Zeroizing<String>>, AppError> {
let entry = vault.connection_secrets.iter().find(|c| c.ref_ == *ref_);
let Some(entry) = entry else {
return Ok(None);
};
match &vault.crypto {
Some(_) => {
let key = key.ok_or_else(AppError::vault_locked)?;
let plain = crate::crypto::decrypt_secret(key, &entry.secret)?;
Ok(Some(plain))
}
None => Ok(Some(Zeroizing::new(entry.secret.clone()))),
}
}
/// Remove a stored NIP-46 connection secret (e.g. on disconnect).
///
/// Returns `true` when an entry was removed.
pub fn delete_connection_secret(vault: &mut Vault, ref_: &crate::signer::VaultRef) -> bool {
let before = vault.connection_secrets.len();
vault.connection_secrets.retain(|c| c.ref_ != *ref_);
vault.connection_secrets.len() != before
}
/// Persist the vault to the stable application-data location with
/// restrictive permissions.
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
@ -463,6 +667,7 @@ mod tests {
created_at: 1_700_000_000,
picture: None,
nip05: None,
signer_mode: SignerMode::Embedded,
}
}
@ -617,4 +822,189 @@ mod tests {
fs::write(&path, encrypted).unwrap();
assert!(is_populated_vault_file(&path));
}
#[test]
fn legacy_profile_without_signer_mode_loads_as_embedded() {
// A vault written before signer_mode was introduced has no
// signer_mode field. The serde default must produce Embedded.
let json = r#"{
"version": 2,
"profiles": [
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef", "created_at": 1700000000 }
]
}"#;
let vault = parse_vault(json).expect("should parse");
assert_eq!(vault.profiles.len(), 1);
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
}
#[test]
fn migrate_vault_signer_modes_is_idempotent() {
let mut vault = Vault::empty();
vault.profiles.push(StoredProfile {
label: "Alice".to_string(),
public_key: "npub1abc".to_string(),
secret_key: "deadbeef".to_string(),
created_at: 1700000000,
picture: None,
nip05: None,
signer_mode: SignerMode::Embedded,
});
let changed1 = migrate_vault_signer_modes(&mut vault);
assert!(changed1, "first migration should report change");
let _changed2 = migrate_vault_signer_modes(&mut vault);
// The function always returns true because it normalises the version.
// The important thing is that running it twice doesn't corrupt data.
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
assert_eq!(vault.version, VAULT_VERSION);
}
#[test]
fn migrate_vault_signer_modes_bumps_version() {
let mut vault = Vault::empty();
vault.version = 1; // Simulate an old vault
let changed = migrate_vault_signer_modes(&mut vault);
assert!(changed);
assert_eq!(vault.version, VAULT_VERSION);
}
#[test]
fn nip46_connections_serialization_roundtrip() {
use crate::signer::types::Nip46Connection;
let mut vault = Vault::empty();
vault.nip46_connections.push(Nip46Connection {
profile_npub: Some("npub1test".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec!["wss://relay.example.com".to_string()],
label: "Test Bunker".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let json = serde_json::to_string(&vault).unwrap();
let restored: Vault = serde_json::from_str(&json).unwrap();
assert_eq!(restored.nip46_connections.len(), 1);
assert_eq!(restored.nip46_connections[0].signer_pubkey, "abc123");
assert_eq!(restored.nip46_connections[0].label, "Test Bunker");
}
#[test]
fn nip46_connections_absent_in_legacy_vault() {
// A vault without nip46_connections should deserialize with an
// empty vector.
let json = r#"{
"version": 2,
"profiles": []
}"#;
let vault: Vault = serde_json::from_str(json).unwrap();
assert!(vault.nip46_connections.is_empty());
}
#[test]
fn unknown_signer_mode_value_fails_deserialization() {
let json = r#"{
"version": 3,
"profiles": [
{ "label": "Alice", "public_key": "npub1abc", "secret_key": "deadbeef",
"created_at": 1700000000, "signer_mode": "unknown_value" }
]
}"#;
let err = parse_vault(json).expect_err("unknown signer_mode must fail");
assert_eq!(err.kind(), ErrorKind::VaultMalformed);
}
#[test]
fn connection_without_profile_npub_deserializes() {
// Old connections without profile_npub should deserialize with
// an empty string (serde default).
let json = r#"{
"signer_pubkey": "abc123",
"relays": ["wss://relay.example.com"],
"secret": null,
"label": "Test",
"created_at": 1700000000,
"permissions": null,
"expires_at": null,
"revoked_at": null
}"#;
let conn: crate::signer::types::Nip46Connection = serde_json::from_str(json).unwrap();
assert!(conn.profile_npub.is_none());
assert_eq!(conn.signer_pubkey, "abc123");
}
#[test]
fn legacy_connection_without_profile_npub_is_none() {
// Connections from old vaults without profile_npub deserialize as None.
// None connections fail authorization checks.
let mut vault = Vault::empty();
vault.active_profile = Some("npub1alice".to_string());
vault
.nip46_connections
.push(crate::signer::types::Nip46Connection {
profile_npub: None, // Legacy connection
signer_pubkey: "abc123".to_string(),
relays: vec![],
label: "Legacy".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let _changed = migrate_vault_signer_modes(&mut vault);
// Legacy connection remains None - ownership cannot be inferred
assert!(vault.nip46_connections[0].profile_npub.is_none());
}
#[test]
fn migration_preserves_existing_profile_npub() {
let mut vault = Vault::empty();
vault.active_profile = Some("npub1alice".to_string());
vault
.nip46_connections
.push(crate::signer::types::Nip46Connection {
profile_npub: Some("npub1bob".to_string()),
signer_pubkey: "abc123".to_string(),
relays: vec![],
label: "Bob's".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let _changed = migrate_vault_signer_modes(&mut vault);
// Already-owned connection is not modified
assert_eq!(
vault.nip46_connections[0].profile_npub.as_deref(),
Some("npub1bob")
);
}
#[test]
fn migration_legacy_connection_without_active_profile() {
let mut vault = Vault::empty();
// No active profile set
vault
.nip46_connections
.push(crate::signer::types::Nip46Connection {
profile_npub: None,
signer_pubkey: "abc123".to_string(),
relays: vec![],
label: "Legacy".to_string(),
created_at: 1700000000,
permissions: None,
expires_at: None,
revoked_at: None,
});
let _changed = migrate_vault_signer_modes(&mut vault);
// Connection remains None - cannot infer ownership
assert!(vault.nip46_connections[0].profile_npub.is_none());
}
}