Files
buzz/desktop/src-tauri/src/key_backup_tests.rs
Tyler 468647a51f feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
## 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.

## Latest onboarding integration

The current head adds an additive `IdentityInfo.storage` field
(`ephemeral`, `system-keyring`, `local-file`, or `environment`) so
onboarding can accurately explain where the active identity is
protected. It surfaces storage metadata only—never key material—and
leaves the existing lost/keyring-locked recovery behavior intact.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-07-30 20:58:03 -04:00

231 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::*;
/// NIP-49 spec vector (same as rust-nostr's upstream test): decrypts with
/// password "nostr" at our call sites.
const SPEC_NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
const SPEC_SECRET_HEX: &str = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683";
/// Fast scrypt tier for tests. log_n 18 is exercised once in
/// `round_trip_at_production_cost`.
const FAST_LOG_N: u8 = 16;
// ── Codec ─────────────────────────────────────────────────────────────────────
#[test]
fn spec_vector_decrypts_at_our_call_site() {
let keys = decrypt_ncryptsec(SPEC_NCRYPTSEC, "nostr").unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
}
#[test]
fn round_trip_fast_tier() {
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "correct horse battery", FAST_LOG_N).unwrap();
assert!(blob.starts_with("ncryptsec1"));
let recovered = decrypt_ncryptsec(&blob, "correct horse battery").unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}
#[test]
fn round_trip_at_production_cost() {
// One log_n 18 round trip: proves the production constant works end to
// end (slow — several seconds — but deliberate; see plan D5).
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "production cost tier check", BACKUP_LOG_N).unwrap();
let recovered = decrypt_ncryptsec(&blob, "production cost tier check").unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}
#[test]
fn wrong_password_is_a_friendly_error() {
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap();
let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err();
assert_eq!(err, "wrong backup password or damaged key backup");
}
#[test]
fn nfkc_cross_form_passphrase_round_trips() {
// "é" composed (U+00E9) vs decomposed (e + U+0301): NIP-49 mandates NFKC
// normalization, so a passphrase entered in either form must decrypt.
let keys = Keys::generate();
let composed = "caf\u{00e9} passphrase";
let decomposed = "cafe\u{0301} passphrase";
assert_ne!(composed, decomposed);
let blob = create_backup_blob(&keys, composed, FAST_LOG_N).unwrap();
let recovered = decrypt_ncryptsec(&blob, decomposed).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}
#[test]
fn parse_rejects_garbage_and_wrong_hrp() {
assert!(parse_ncryptsec("garbage").is_err());
assert!(parse_ncryptsec("").is_err());
// Valid bech32, wrong HRP (an nsec is not an encrypted backup).
let nsec = Keys::generate().secret_key().to_bech32().unwrap();
assert!(parse_ncryptsec(&nsec).is_err());
// Truncated blob.
assert!(parse_ncryptsec(&SPEC_NCRYPTSEC[..SPEC_NCRYPTSEC.len() - 10]).is_err());
}
#[test]
fn verify_backup_blob_catches_pubkey_mismatch() {
// Corrupted-blob simulation: the blob decrypts fine but recovers a key
// that is not the live identity — verification must fail.
let other = Keys::generate();
let blob = create_backup_blob(&other, "some password", FAST_LOG_N).unwrap();
let live = Keys::generate();
let err = verify_backup_blob(&blob, "some password", &live.public_key()).unwrap_err();
assert!(err.contains("does not match identity"), "{err}");
}
// ── Import key recovery ───────────────────────────────────────────────────────
#[test]
fn recover_keys_ncryptsec_happy_path() {
let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
}
#[test]
fn recover_keys_ncryptsec_requires_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err();
assert_eq!(err, "key backup requires a password");
}
#[test]
fn recover_keys_ncryptsec_wrong_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err();
assert_eq!(err, "wrong backup password or damaged key backup");
}
#[test]
fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() {
let upper = SPEC_NCRYPTSEC.to_ascii_uppercase();
assert_eq!(
recover_keys_from_input(&upper, None).unwrap_err(),
"key backup requires a password"
);
let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
let mut mixed = SPEC_NCRYPTSEC.to_string();
mixed.replace_range(0..1, "N");
let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err();
assert!(err.contains("invalid ncryptsec"), "{err}");
}
#[test]
fn recover_keys_raw_nsec_path_unchanged() {
let keys = Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
let recovered = recover_keys_from_input(&nsec, None).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
assert!(recover_keys_from_input("garbage", None).is_err());
}
// ── File lifecycle ────────────────────────────────────────────────────────────
#[test]
fn write_backup_file_persists_0600_and_verifies() {
let dir = tempfile::tempdir().unwrap();
let path = backup_file_path(dir.path());
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(on_disk, SPEC_NCRYPTSEC);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "backup file must be owner-only");
}
}
#[test]
fn write_backup_file_overwrites_atomically() {
let dir = tempfile::tempdir().unwrap();
let path = backup_file_path(dir.path());
write_backup_file(&path, "ncryptsec1old").unwrap();
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC);
// No leftover temp files from the atomic write.
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]);
}
#[test]
fn delete_backup_file_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
delete_backup_file(dir.path()).unwrap();
let path = backup_file_path(dir.path());
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
delete_backup_file(dir.path()).unwrap();
assert!(!path.exists());
}
#[test]
fn cleanup_stale_backup_removes_only_on_identity_change() {
let dir = tempfile::tempdir().unwrap();
let path = backup_file_path(dir.path());
let a = Keys::generate().public_key();
let b = Keys::generate().public_key();
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
cleanup_stale_backup(&a, &a, dir.path()).unwrap();
assert!(path.exists(), "same identity must keep the backup");
cleanup_stale_backup(&a, &b, dir.path()).unwrap();
assert!(
!path.exists(),
"identity change must remove the stale backup"
);
}
#[test]
fn generated_passphrase_respects_word_count_and_separator() {
let words: std::collections::HashSet<&str> =
WORDLIST.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(words.len(), 1296, "EFF short wordlist 2.0 has 1296 words");
for (count, separator) in [(3, "-"), (4, "-"), (6, " "), (5, "."), (10, "")] {
let phrase = generate_passphrase(count, separator).unwrap();
if separator.is_empty() {
// No separator to split on; length gate below still applies.
} else {
let parts: Vec<&str> = phrase.split(separator).collect();
assert_eq!(parts.len(), count);
for w in &parts {
assert!(words.contains(w), "unknown word {w:?}");
}
}
assert!(phrase.chars().count() >= MIN_PASSPHRASE_LEN);
}
}
#[test]
fn generated_passphrase_clamps_word_count() {
// Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter.
let phrase = generate_passphrase(1, "-").unwrap();
assert_eq!(phrase.split('-').count(), MIN_PASSPHRASE_WORDS);
// Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS.
let phrase = generate_passphrase(50, "-").unwrap();
assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS);
}
#[test]
fn generated_passphrases_are_not_repeated() {
// 3 words × ~10.3 bits each — a collision across 8 draws would indicate a
// broken entropy source, not bad luck.
let mut seen = std::collections::HashSet::new();
for _ in 0..8 {
assert!(seen.insert(generate_passphrase(DEFAULT_PASSPHRASE_WORDS, "-").unwrap()));
}
}