Files
buzz/desktop/src-tauri/src/key_backup_tests.rs
Diem Nguyen 886cef7f72 test(desktop): use a wordlist-safe separator in passphrase word-count test (#6356)
## Summary

Fixes #6249.


`key_backup::tests::generated_passphrase_respects_word_count_and_separator`
joined words with `-` and asserted the phrase splits back into exactly
`count` parts. The EFF short wordlist contains exactly one hyphenated
entry (`yo-yo`, line 1281 of 1296), so drawing it into either
hyphen-joined arm yields one extra part — a ~1-in-186 flake per full
suite run (`1 - (1 - 1/1296)^7 ≈ 0.539%`).

This switches the two hyphen arms to `|`, which cannot appear in the
wordlist — the exact guard the sibling test
`generated_passphrase_clamps_word_count` already documents and uses. The
space, dot, and empty-separator arms are untouched (no wordlist entry
contains a space or a dot), so the test still covers word count,
wordlist membership, and minimum length.

`generate_passphrase` itself is unchanged — a hyphenated word in a
hyphen-joined passphrase is not a product defect, only an ambiguity the
test's parsing could not handle.

## Verification

At `main` (196d62f), compiled the desktop test binary once and looped it
2000× per state:

| State | Failures / 2000 runs | Expected |
|---|---|---|
| old code | 15 | ~10.8 (P ≈ 0.539%) |
| fixed | 0 | 0 |

Full desktop Tauri suite (`cargo test --workspace` in
`desktop/src-tauri`): **2693 passed, 0 failed**.

Signed-off-by: Fizz <1f3b09af3c417274e5516bf95fadd3c118f35a31ae922257d550c69159ba931c@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <1f3b09af3c417274e5516bf95fadd3c118f35a31ae922257d550c69159ba931c@buzz.block.builderlab.xyz>
2026-08-20 11:53:09 -06:00

277 lines
11 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 write_portable_backup_file_persists_0600_without_a_sibling() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("portable.ncryptsec");
write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC);
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(
entries,
vec![std::ffi::OsString::from("portable.ncryptsec")]
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "portable backup must be owner-only");
}
}
#[test]
fn write_portable_backup_file_preserves_an_existing_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("portable.ncryptsec");
std::fs::write(&path, "ncryptsec1existing").unwrap();
let error = write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap_err();
assert!(error.contains("already exists"), "{error}");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"ncryptsec1existing"
);
}
#[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");
// Use separators that cannot appear in the EFF wordlist so a generated
// word such as "yo-yo" cannot be mistaken for two words (see the same
// guard in generated_passphrase_clamps_word_count and issue #6249).
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() {
// Use a separator that cannot appear in the EFF wordlist so a generated
// word such as "yo-yo" cannot be mistaken for two words.
const SEPARATOR: &str = "|";
// Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter.
let phrase = generate_passphrase(1, SEPARATOR).unwrap();
assert_eq!(phrase.split(SEPARATOR).count(), MIN_PASSPHRASE_WORDS);
// Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS.
let phrase = generate_passphrase(50, SEPARATOR).unwrap();
assert_eq!(phrase.split(SEPARATOR).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()));
}
}