mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
## 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>
277 lines
11 KiB
Rust
277 lines
11 KiB
Rust
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()));
|
||
}
|
||
}
|