Commit graph

296 commits

Author SHA1 Message Date
Avi
22d7c01193 chore(hygiene): ignore editor artifacts; prettier SignerScreen
- .gitignore now covers .directory, .opencode/, .impeccable/.
- SignerScreen.tsx reformatted (format:check was failing since 81b082f).
- Dead untracked stub src/signer/nip46_external.rs deleted from disk
  (superseded by nip46_client.rs, never declared in signer/mod.rs).
2026-09-12 17:55:32 -05:00
Avi
81b082f238 feat(signer): always-allow grants for external signer requests
Apps asking Keynctr to sign (NIP-46) can now be granted standing
permission per (peer pubkey, method). Approvals gained an 'Always
allow' option; existing grants are listed with a Revoke button on the
Signer screen and persist in the encrypted vault.
2026-09-12 17:00:41 -05:00
Avi
286bbcaa04 fix(signer): connect immediately after identity; fetch kind-0 metadata in background
The adopt step blocked on a best-effort relay metadata fetch before
flipping the session to Connected, so after Amber approved, the UI sat
looking dead for many seconds (the 'Amber said yes but nothing changed'
bug). Connected now flips as soon as the identity is verified and
persisted; the real display name / picture / nip05 land asynchronously
via a background task that never overrides a user-chosen label.
2026-09-12 15:29:53 -05:00
Avi
3d5302fbf3 feat(signer): adopt real display name/picture for paired NIP-46 identities
A paired profile was stored under the generic pairing label (or a bare
npub). adopt_identity now does a best-effort, 3s-capped kind-0 metadata
lookup for the learned identity and stores display_name/name plus
picture/nip05 on the profile row, falling back to the pairing label when
relays are unavailable.
2026-09-12 09:47:34 -05:00
Avi
9cfab4bce6 chore(signer): log pairing start and session failures to stderr
Pairing failures were invisible: fail() wrote nothing and the only
success-path trace was the URI. Backend stderr is captured by Electron,
so a stalled handshake is now diagnosable from /tmp logs.
2026-09-12 05:48:40 -05:00
Avi
dc58f3889f fix(ipc): lazily initialize the NIP-46 client signer handle
App startup defaults signer_mode to Nip46Client but only SignerModeSet
builds the handle, so a fresh backend answered every nip46_* request
('Show QR' included) with 'not initialized' until the user re-saved the
mode. All nip46_* handlers now ensure the handle exists first.
2026-09-12 05:09:14 -05:00
Avi
c5004ebb26 fix(electron): allow nip46_pair_start through the renderer method allowlist
The new QR pairing IPC method was rejected by the main-process allowlist
before reaching the Rust backend ('That operation is not permitted').
2026-09-12 05:01:46 -05:00
Avi
38499d4506 feat(signer): QR pairing — client-initiated nostrconnect:// flow for Amber
Keynctr is the NIP-46 client; Amber is the scanner. Amber hands out no
link — it scans one — so the signer screen now mints a pairing token:

- start_pairing(): ephemeral key + secret, nostrconnect:// token via
  NostrConnectUri::client_with_secret, status().pairing_uri for the GUI
- run_pairing_task(): listens for the signer's connect request, echoes
  the secret (anti-spoofing), persists the connection row + secret,
  then adopts identity via get_public_key and hands to the demux loop
- pairing subscription is closed at handoff so the demux loop owns the
  conversation (relay could otherwise deliver signer replies under the
  stale pairing sub id where nobody routes them)
- IPC: nip46_pair_start; status carries pairing_uri
- SignerModeScreen: 'Show QR' button, QR render (qrcode) of the token,
  copy-link fallback, cancel; paste-link flow unchanged
- e2e: fake QR scanner consumes the real pairing token end-to-end
  (scan -> secret echo -> identity -> sign -> vault persistence)
2026-09-12 04:47:20 -05:00
Avi
764406e5a9 checkpoint: NIP-46 e2e test + bunker:// frontend support (85756df) 2026-09-12 02:41:11 -05:00
Avi
85756df081 feat(signer): accept bunker:// URIs, async signer permissions, NIP-46 e2e test
- SignerManager/SignerModeScreen parse both nostrconnect:// and bunker://
  (Amber presents bunker://; signer pubkey extracted before '@')
- Signer permission surface made async (permissions, can_*, is_connection_valid)
- tests/nip46_e2e.rs: full client handshake against fake Amber over a local
  relay — NIP-44 round-trip, get_public_key identity, signed-event verification,
  vault persistence asserting no secret material for remote profiles
- prettier formatting of touched frontend files
2026-09-12 02:40:40 -05:00
Avi
84f11e615d checkpoint: vault-load rewrite fix (c096705) + Amber verify as next step 2026-09-11 15:13:16 -05:00
Avi
c09670530c fix(vault): stop rewriting the vault on every load; clippy cleanup
- migrate_vault_signer_modes now reports a change only when the vault
  version actually moves. The unconditional 'changed = true' made
  App::load re-save the vault on every start (harmless, idempotent,
  but wasteful). Per-profile signer_mode normalisation was already a
  no-op: the serde default fills missing fields at parse time and the
  current version serialises it explicitly.
- idempotency test tightened to assert changed == false for a
  current-version vault (previously ducked the question).
- nip46_client.rs: drop clone-on-Copy in get_public_key (clippy).
2026-09-11 15:11:00 -05:00
Avi
f917e5ecfd fix(signer): Amber-compatible handshake — bunker:// URIs, deferred identity, ack wait
- parse_connect_uri accepts bunker:// as well as nostrconnect://
- URI authority key is no longer treated as identity (Amber mints a
  per-connection comms key); real identity learned via get_public_key
  after the connect ack, then persisted (profile row + secret re-key)
- connect ack awaited in a spawned handshake task with a 120s human
  approval window; session stays Connecting (all signing fails closed)
  until identity is verified
- absent perms= no longer locally denies signing; enforcement is
  delegated to the signer's approval UI
- send_rpc honours its timeout parameter
2026-09-11 11:08:09 -05:00
Avi
6e5d80ba0b checkpoint: external NIP-46 signing end-to-end (Step 3 sub-step 2 done) 2026-09-10 21:55:09 -05:00
Avi
1af79d81cd feat(signer): end-to-end external NIP-46 signing in publish and upload auth
Step 3 sub-step 2 (IPC reroute) — the publish path now actually signs
remotely instead of returning 'not yet supported':

- src/signer/nip46_client.rs: outbound NIP-46 request half — send
  sign_event over the encrypted channel, demux responses to waiting
  callers, 30s timeout, waiters woken on disconnect/fail. Signer::sign_event
  verifies the returned event matches the requested unsigned event, is
  signed by the connected identity, and carries a valid signature; no
  local fallback. Permission-denied audit uses try_lock so a denied
  in-flight sign cannot deadlock the dispatcher.
- src/app.rs: App::signing_for / signing_active — one place that maps a
  profile's SignerMode to a Signing source. Embedded -> Local(vault key);
  Nip46Client -> External(live signer) only when connected, otherwise
  ExternalSignerNotConnected; Nip46Bunker fails closed.
- src/publish.rs: publish_with_keys builds the unsigned event from the
  Signing's own pubkey and signs via Signing::sign; publish_signed entry
  point for IPC (CLI keeps publish_active local path).
- src/ipc.rs: PublishNote and UploadAuth route through signing_active.
  Nip46Connect/Nip46Disconnect drop the App guard before awaiting
  connect()/disconnect() (they re-lock internally — latent deadlock).
- src/relays.rs: keyless relay pool (open_pool_inner(Option<Keys>)) so
  external signing publishes without local keys.
- src/uploads.rs: nip98_authorization takes a Signing source, so upload
  auth signs remotely for external profiles too.
- src/profiles.rs: store_remote_profile — connecting a NIP-46 signer
  creates/refreshes a secretless Nip46Client profile row; refuses to
  silently convert an existing local profile.

Tests: signing selection (local, fail-closed external, no profile),
store_remote_profile create + no-clobber. 200 tests pass; clippy clean;
fmt clean; release build green.
2026-09-10 21:54:32 -05:00
Avi
8780ef3fbb checkpoint: document undo-delete secret-preserving fix (2026-09-10) 2026-09-10 12:50:24 -05:00
Avi
d101b8e236 fix(undo): restore full profile with secret key on undo-delete 2026-09-10 12:50:07 -05:00
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