- .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).
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.
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.
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.
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.
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.
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)
- 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
- 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).
- 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
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.
- 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/
- 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
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.
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.
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.
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.
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.
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.
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.
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).
- 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)
- 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