Skip to content

feat(desktop): locally stored NIP-49 encrypted key backup#2937

Open
tlongwell-block wants to merge 3 commits into
mainfrom
eva/nip49-local-backup
Open

feat(desktop): locally stored NIP-49 encrypted key backup#2937
tlongwell-block wants to merge 3 commits into
mainfrom
eva/nip49-local-backup

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Summary

Adds a locally stored NIP-49 encrypted key backup (ncryptsec) to the desktop app, per the plan reviewed in buzz-development (Rev 3, approved 9/10 by Wren; implementation also reviewed and approved 9/10).

Two-artifact design — canonical bytes originate entirely in Rust:

  • create_ncryptsec_backup runs under the identity_mutation lock: encrypt → decrypt-verify against the live pubkey → atomic 0o600 write to {app_data_dir}/identity.ncryptsec → reread/byte-compare → return the exact persisted bytes. The frontend never re-derives or re-encrypts.
  • save_ncryptsec_copy writes a portable copy via the save dialog (parse-gated, secret-file semantics) and never mutates canonical state.
  • generate_backup_passphrase: 6 words from the EFF short wordlist via OsRng (custom passphrases min 12 chars).
  • Import accepts ncryptsec1 with optional password; the raw-nsec path is untouched. Different-pubkey import and sign-out wipe the app-managed backup (post-commit, best-effort — a failed import can never destroy the still-live identity's backup; regression-tested).

Never-relay guarantee (egress guard + tripwires):

  • egress_guard.rs fail-closed at all 8 /events submission boundaries (relay submit funnel, 3× relay.rs, huddle STT, both engram submitters, native WS choke point), rejecting ncryptsec1/NCRYPTSEC1 in text and binary frames. Scope is deliberately ncryptsec-only: pairing intentionally carries raw nsec inside its encrypted session.
  • Site-granular /events inventory tripwire: per-file (/events count, guard-call count) pairs; unlisted files expect zero. Mutation-style tests prove a ninth site in an existing file, a removed guard, and a new unlisted file all fail the scan.
  • ncryptsec source-allowlist scans in both trees (Rust + TS).

Frontend: onboarding BackupStep is encrypted-by-default — the default path never invokes get_nsec (e2e asserts the command log). Raw-nsec export stays behind an explicit click with prior semantics. Shared EncryptedBackupCreator powers onboarding + a new settings row; the import form auto-switches to encrypted mode on ncryptsec1 paste (case-insensitive HRP).

Open product call for @tlongwell-block: onboarding default is encrypted in this PR; flipping to raw-default is a small change either way (documented in the plan).

Review history: plan Rev 3 and the implementation were both iterated with Wren to 9/10 (two blockers from round 1 — import ordering, inventory granularity — plus an uppercase-bech32 hardening gap, all fixed in dde37183e). Thread: buzz-development.

Related issue

Follow-up to the direction explored in #385 (NIP-PB, closed) — this ships local NIP-49 (the standard) instead of a new NIP. No open duplicate found.

Testing

All at exactly dde37183e (same shell, HEAD verified):

  • cargo test — 1680 passed / 0 failed / 14 ignored (includes a deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password, NFKC, uppercase-vector decrypt, injection test per egress boundary, inventory mutation tests, import-ordering regression tests)
  • cargo clippy --all-targets -- -D warnings — clean; cargo fmt --check — clean
  • pnpm typecheck — clean; JS unit suite 3529/3529; biome (repo-pinned 2.4.16) clean
  • Playwright onboarding-backup / onboarding / onboarding-agent-defaults / profile-nsec-reveal — 86 passed, 1 known avatar-reservation flake (passed on rerun; untouched by this diff). passThroughBackupStep now exercises the encrypted default, so every downstream onboarding spec covers the new path.
  • Note: browser e2e fakes the crypto via the mock bridge (fixed spec-vector blob); decryption correctness is proven in the Rust tests.

npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d and others added 3 commits July 25, 2026 22:44
Local-only ncryptsec backup of the identity key (plan:
PLANS/NIP49_LOCAL_BACKUP_PLAN.md Rev 3, approved 9/10 by Wren).

- key_backup.rs: NIP-49 codec (nostr nip49, log_n 18), one-artifact-per-
  action create with decrypt-verify against the live pubkey, atomic 0o600
  write + reread/byte-compare, EFF-wordlist passphrase generation,
  ncryptsec import recovery, stale-backup cleanup on identity change.
- commands/identity.rs: create_ncryptsec_backup (whole body under
  identity_mutation; webview can never supply canonical blob bytes),
  save_ncryptsec_copy (dialog selection only + secret-file write, never
  mutates app state), generate_backup_passphrase, import_identity now
  accepts ncryptsec1 with a passphrase (raw-nsec path byte-for-byte
  unchanged).
- egress_guard.rs: the backup must NEVER be transmitted to a relay —
  fail-closed runtime guard rejecting ncryptsec1 at all 8 relay egress
  boundaries (submit funnel, 3x relay.rs, huddle STT, both engram
  submitters, native websocket send loop). Scope is ncryptsec1 only:
  pairing intentionally carries the raw nsec inside its encrypted
  NIP-AB session.
- Tests: injection test per boundary (8), /events inventory-completeness
  tripwire, ncryptsec source-allowlist scan, spec vector, NFKC cross-form,
  0600/atomicity/lifecycle, recovery-mode gating, concurrent identity
  swap vs backup, boot-reset wipes the backup.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Frontend half of PLANS/NIP49_LOCAL_BACKUP_PLAN.md Rev 3 (D3):

- BackupStep: encrypted by default. The default path never invokes
  get_nsec — the webview only ever sees the finished ncryptsec1 blob
  returned by create_ncryptsec_backup. The raw key path survives behind
  an explicit 'Show raw key instead' click with its previous
  loading/error/skip semantics.
- EncryptedBackupCreator (shared by onboarding + settings): generated
  6-word passphrase default with cannot-be-recovered copy, 'choose my
  own' = min 12 chars + confirmation, create -> masked display + copy +
  'Save a copy…' via the native dialog.
- encryptedBackup.ts: pure reducer/validation model, unit-tested without
  a DOM; keyImportInput.ts: HRP classification + submit gating.
- NsecMaskedDisplay: kind='nsec'|'ncryptsec' drives labels/aria/testids;
  existing nsec call sites unchanged.
- NostrKeyImportForm: ncryptsec1 paste switches to encrypted mode
  (passphrase field, no npub preview possible), decrypt errors surface;
  raw nsec flow untouched. import_identity plumbs the optional password
  through OnboardingFlow/MachineOnboardingFlow/KeyringLockedScreen.
- ProfileSettingsCard: 'Encrypted backup' row alongside the raw reveal.
- ncryptsecSourceScan.test.mjs: TS-side source-allowlist tripwire
  mirroring the Rust scan (defense-in-depth per plan D4).
- e2e: mock bridge learns the three backup commands + ncryptsec import;
  onboarding-backup.spec.ts covers the encrypted happy path (asserting
  get_nsec is never called), custom-passphrase validation, raw fallback,
  and error/retry; onboarding.spec.ts adds encrypted-import happy path
  including a wrong-passphrase rejection.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…r inventory, uppercase bech32

Blocker 1: import_identity persisted-before-cleanup ordering. New
commit_imported_identity helper runs durable persistence FIRST; a failed
different-key import now leaves both the old in-memory identity and its
valid canonical identity.ncryptsec intact. Stale-backup cleanup runs after
the durable commit and is deliberately best-effort (logged, not surfaced
as a half-applied-import error). Regression tests cover the failure path
and prove cleanup cannot precede persist.

Blocker 2: the /events inventory tripwire is now site-granular. Each
inventoried file pairs an expected non-comment /events occurrence count
with an expected egress-guard call count, so an unguarded ninth site in an
already-listed file (or a deleted guard call) fails the scan. The scan
core is pure over (path, content) pairs, with mutation-style tests
demonstrating all three drift classes.

Hardening: bech32 permits an all-uppercase encoding, so the egress guard
now rejects NCRYPTSEC1... too (mixed case stays unblocked - it cannot
decode), and both import classifiers (Rust recover_keys_from_input, TS
classifyKeyImportInput/isPlausibleNcryptsec) route uppercase-valid blobs
to the encrypted path consistently.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner July 26, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant