feat(desktop): locally stored NIP-49 encrypted key backup#2937
Open
tlongwell-block wants to merge 3 commits into
Open
feat(desktop): locally stored NIP-49 encrypted key backup#2937tlongwell-block wants to merge 3 commits into
tlongwell-block wants to merge 3 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_backupruns under theidentity_mutationlock: encrypt → decrypt-verify against the live pubkey → atomic0o600write to{app_data_dir}/identity.ncryptsec→ reread/byte-compare → return the exact persisted bytes. The frontend never re-derives or re-encrypts.save_ncryptsec_copywrites 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 viaOsRng(custom passphrases min 12 chars).ncryptsec1with optional password; the raw-nsecpath 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.rsfail-closed at all 8/eventssubmission boundaries (relay submit funnel, 3×relay.rs, huddle STT, both engram submitters, native WS choke point), rejectingncryptsec1/NCRYPTSEC1in text and binary frames. Scope is deliberately ncryptsec-only: pairing intentionally carries raw nsec inside its encrypted session./eventsinventory tripwire: per-file (/eventscount, 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.Frontend: onboarding
BackupStepis encrypted-by-default — the default path never invokesget_nsec(e2e asserts the command log). Raw-nsec export stays behind an explicit click with prior semantics. SharedEncryptedBackupCreatorpowers onboarding + a new settings row; the import form auto-switches to encrypted mode onncryptsec1paste (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— cleanpnpm typecheck— clean; JS unit suite 3529/3529; biome (repo-pinned 2.4.16) cleanonboarding-backup/onboarding/onboarding-agent-defaults/profile-nsec-reveal— 86 passed, 1 known avatar-reservation flake (passed on rerun; untouched by this diff).passThroughBackupStepnow exercises the encrypted default, so every downstream onboarding spec covers the new path.