diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bb3ef796bb..344e519657 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -188,11 +188,18 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { .map_err(|error| format!("encode nsec: {error}")) } -/// Generate a 6-word passphrase for a new encrypted backup (EFF short -/// wordlist, OS entropy, ≈62 bits before the scrypt work factor). +/// Generate a passphrase for a new encrypted backup (EFF short wordlist, OS +/// entropy). `words` is clamped to the range allowed by `key_backup`; +/// `separator` joins the words (defaults to a space). #[tauri::command] -pub fn generate_backup_passphrase() -> Result { - crate::key_backup::generate_passphrase() +pub fn generate_backup_passphrase( + words: Option, + separator: Option, +) -> Result { + crate::key_backup::generate_passphrase( + words.map_or(crate::key_backup::DEFAULT_PASSPHRASE_WORDS, |w| w as usize), + separator.as_deref().unwrap_or(" "), + ) } /// Core of [`create_ncryptsec_backup`], factored so tests can drive it with a @@ -259,6 +266,47 @@ pub async fn create_ncryptsec_backup( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupVerification { + pub pubkey: String, + pub npub: String, + pub matches_current_identity: bool, +} + +fn verify_ncryptsec_backup_inner( + state: &AppState, + ncryptsec: &str, + password: &str, +) -> Result { + let keys = crate::key_backup::decrypt_ncryptsec(ncryptsec, password)?; + let pubkey = keys.public_key(); + let current = state.signing_keys()?.public_key(); + Ok(BackupVerification { + pubkey: pubkey.to_hex(), + npub: pubkey + .to_bech32() + .map_err(|e| format!("encode backup identity: {e}"))?, + matches_current_identity: pubkey == current, + }) +} + +/// Decrypt and validate a NIP-49 backup without exposing its secret key. +#[tauri::command] +pub async fn verify_ncryptsec_backup( + ncryptsec: String, + password: String, + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + verify_ncryptsec_backup_inner(&state, &ncryptsec, &password) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + /// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path. /// /// The input must parse as a structurally valid NIP-49 payload. The dialog is @@ -278,7 +326,7 @@ pub async fn save_ncryptsec_copy( let dest = match crate::commands::export_util::pick_save_path( &app_handle, crate::key_backup::BACKUP_FILE_NAME, - "Encrypted key backup", + "Password-protected key backup", &["ncryptsec"], ) .await? @@ -744,209 +792,5 @@ mod nostr_identity_binding_tests { } #[cfg(test)] -mod key_backup_command_tests { - use super::create_and_persist_backup_with_log_n; - use crate::app_state::build_app_state; - use nostr::Keys; - - /// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered - /// once in key_backup_tests::round_trip_at_production_cost. - const FAST_LOG_N: u8 = 16; - const PASSWORD: &str = "correct horse battery"; - - #[test] - fn returned_bytes_equal_on_disk_bytes() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let returned = - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - - let path = crate::key_backup::backup_file_path(dir.path()); - let on_disk = std::fs::read_to_string(&path).unwrap(); - assert_eq!( - returned, on_disk, - "webview must receive the exact persisted bytes" - ); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - } - - // And the persisted blob provably recovers the live identity. - let keys = state.keys.lock().unwrap().clone(); - let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap(); - assert_eq!(recovered.public_key(), keys.public_key()); - } - - #[test] - fn overwrite_replaces_atomically() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let first = - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - let second = create_and_persist_backup_with_log_n( - &state, - dir.path(), - "another passphrase", - FAST_LOG_N, - ) - .unwrap(); - assert_ne!(first, second, "fresh salt/nonce per action"); - - let path = crate::key_backup::backup_file_path(dir.path()); - assert_eq!(std::fs::read_to_string(&path).unwrap(), second); - } - - #[test] - fn rejects_short_passphrase() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let err = create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N) - .unwrap_err(); - assert!(err.contains("at least"), "{err}"); - assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); - } - - #[test] - fn recovery_mode_blocks_backup_creation() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - - state - .identity_lost - .store(true, std::sync::atomic::Ordering::Release); - assert!( - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), - "lost identity must not be backed up" - ); - state - .identity_lost - .store(false, std::sync::atomic::Ordering::Release); - - state - .keyring_locked - .store(true, std::sync::atomic::Ordering::Release); - assert!( - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), - "locked keyring must not be backed up" - ); - assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); - } - - /// Blocker-1 regression (Wren, implementation review): a failed - /// different-key import must leave BOTH the old in-memory identity and - /// the old canonical backup intact. Persistence runs before cleanup, so - /// an `Err` from persist means nothing was mutated or deleted. - #[test] - fn failed_import_persistence_preserves_old_identity_and_backup() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let old_pubkey = state.keys.lock().unwrap().public_key(); - - // A valid canonical backup for the live (old) identity. - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - let backup_path = crate::key_backup::backup_file_path(dir.path()); - let backup_before = std::fs::read_to_string(&backup_path).unwrap(); - - // Different-key import whose durable persistence fails (both - // keyring and file fallback down). - let _guard = state.identity_mutation.lock().unwrap(); - let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| { - Err("keyring and file both unavailable".to_string()) - }) - .unwrap_err(); - assert!(err.contains("unavailable"), "{err}"); - - // Old identity still live; old backup untouched byte-for-byte. - assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey); - assert_eq!( - std::fs::read_to_string(&backup_path).unwrap(), - backup_before - ); - assert!( - crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD) - .unwrap() - .public_key() - == old_pubkey, - "surviving backup must still recover the still-live identity" - ); - } - - /// Successful different-key import removes the previous identity's - /// backup — cleanup runs after the durable commit, not before. - #[test] - fn successful_import_removes_stale_backup_after_commit() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - let backup_path = crate::key_backup::backup_file_path(dir.path()); - assert!(backup_path.exists()); - - let new_keys = Keys::generate(); - let backup_present_at_persist = std::cell::Cell::new(false); - let _guard = state.identity_mutation.lock().unwrap(); - let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| { - // Ordering probe: the old backup must still exist while - // persistence is running (cleanup has not happened yet). - backup_present_at_persist.set(backup_path.exists()); - Ok(()) - }) - .unwrap(); - - assert!( - backup_present_at_persist.get(), - "cleanup must not precede persist" - ); - assert_eq!(pubkey, new_keys.public_key()); - assert_eq!( - state.keys.lock().unwrap().public_key(), - new_keys.public_key() - ); - assert!( - !backup_path.exists(), - "stale backup must be removed post-commit" - ); - } - - /// Concurrent identity swap vs backup creation: `identity_mutation` - /// serializes both, so every persisted blob decrypts to the identity that - /// was live for the whole of its create operation — never a torn state. - #[test] - fn concurrent_identity_swap_vs_backup_is_serialized() { - let state = std::sync::Arc::new(build_app_state()); - let dir = tempfile::tempdir().unwrap(); - let key_a = state.keys.lock().unwrap().clone(); - let key_b = Keys::generate(); - - let swapper = { - let state = state.clone(); - let key_b = key_b.clone(); - std::thread::spawn(move || { - // Mirrors import_identity's locking: mutation guard held - // across the key swap. - let _guard = state.identity_mutation.lock().unwrap(); - *state.keys.lock().unwrap() = key_b; - }) - }; - - let backup = - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - swapper.join().unwrap(); - - let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD) - .unwrap() - .public_key(); - assert!( - recovered == key_a.public_key() || recovered == key_b.public_key(), - "backup must match one coherent identity" - ); - // Whichever won, the persisted file equals the returned blob. - let on_disk = - std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap(); - assert_eq!(on_disk, backup); - } -} +#[path = "identity_key_backup_tests.rs"] +mod key_backup_command_tests; diff --git a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs new file mode 100644 index 0000000000..0b31f44bac --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -0,0 +1,235 @@ +use super::{create_and_persist_backup_with_log_n, verify_ncryptsec_backup_inner}; +use crate::app_state::build_app_state; +use nostr::Keys; + +/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered +/// once in key_backup_tests::round_trip_at_production_cost. +const FAST_LOG_N: u8 = 16; +const PASSWORD: &str = "correct horse battery"; + +#[test] +fn returned_bytes_equal_on_disk_bytes() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let returned = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + + let path = crate::key_backup::backup_file_path(dir.path()); + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + returned, on_disk, + "webview must receive the exact persisted bytes" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // And the persisted blob provably recovers the live identity. + let keys = state.keys.lock().unwrap().clone(); + let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn verification_returns_only_public_identity_and_match_status() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let backup = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!( + result.pubkey, + state.keys.lock().unwrap().public_key().to_hex() + ); + assert!(result.npub.starts_with("npub1")); + assert!(result.matches_current_identity); +} + +#[test] +fn verification_reports_valid_backup_for_a_different_identity() { + let state = build_app_state(); + let other = Keys::generate(); + let backup = crate::key_backup::create_backup_blob(&other, PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!(result.pubkey, other.public_key().to_hex()); + assert!(!result.matches_current_identity); +} + +#[test] +fn verification_rejects_wrong_password() { + let state = build_app_state(); + let backup = + crate::key_backup::create_backup_blob(&Keys::generate(), PASSWORD, FAST_LOG_N).unwrap(); + assert_eq!( + verify_ncryptsec_backup_inner(&state, &backup, "wrong password").unwrap_err(), + "wrong backup password or damaged key backup" + ); +} + +#[test] +fn overwrite_replaces_atomically() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let first = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let second = + create_and_persist_backup_with_log_n(&state, dir.path(), "another passphrase", FAST_LOG_N) + .unwrap(); + assert_ne!(first, second, "fresh salt/nonce per action"); + + let path = crate::key_backup::backup_file_path(dir.path()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), second); +} + +#[test] +fn rejects_short_passphrase() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let err = + create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N).unwrap_err(); + assert!(err.contains("at least"), "{err}"); + assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); +} + +#[test] +fn recovery_mode_blocks_backup_creation() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), + "lost identity must not be backed up" + ); + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + + state + .keyring_locked + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), + "locked keyring must not be backed up" + ); + assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); +} + +/// Blocker-1 regression (Wren, implementation review): a failed +/// different-key import must leave BOTH the old in-memory identity and +/// the old canonical backup intact. Persistence runs before cleanup, so +/// an `Err` from persist means nothing was mutated or deleted. +#[test] +fn failed_import_persistence_preserves_old_identity_and_backup() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let old_pubkey = state.keys.lock().unwrap().public_key(); + + // A valid canonical backup for the live (old) identity. + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let backup_path = crate::key_backup::backup_file_path(dir.path()); + let backup_before = std::fs::read_to_string(&backup_path).unwrap(); + + // Different-key import whose durable persistence fails (both + // keyring and file fallback down). + let _guard = state.identity_mutation.lock().unwrap(); + let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| { + Err("keyring and file both unavailable".to_string()) + }) + .unwrap_err(); + assert!(err.contains("unavailable"), "{err}"); + + // Old identity still live; old backup untouched byte-for-byte. + assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey); + assert_eq!( + std::fs::read_to_string(&backup_path).unwrap(), + backup_before + ); + assert!( + crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD) + .unwrap() + .public_key() + == old_pubkey, + "surviving backup must still recover the still-live identity" + ); +} + +/// Successful different-key import removes the previous identity's +/// backup — cleanup runs after the durable commit, not before. +#[test] +fn successful_import_removes_stale_backup_after_commit() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let backup_path = crate::key_backup::backup_file_path(dir.path()); + assert!(backup_path.exists()); + + let new_keys = Keys::generate(); + let backup_present_at_persist = std::cell::Cell::new(false); + let _guard = state.identity_mutation.lock().unwrap(); + let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| { + // Ordering probe: the old backup must still exist while + // persistence is running (cleanup has not happened yet). + backup_present_at_persist.set(backup_path.exists()); + Ok(()) + }) + .unwrap(); + + assert!( + backup_present_at_persist.get(), + "cleanup must not precede persist" + ); + assert_eq!(pubkey, new_keys.public_key()); + assert_eq!( + state.keys.lock().unwrap().public_key(), + new_keys.public_key() + ); + assert!( + !backup_path.exists(), + "stale backup must be removed post-commit" + ); +} + +/// Concurrent identity swap vs backup creation: `identity_mutation` +/// serializes both, so every persisted blob decrypts to the identity that +/// was live for the whole of its create operation — never a torn state. +#[test] +fn concurrent_identity_swap_vs_backup_is_serialized() { + let state = std::sync::Arc::new(build_app_state()); + let dir = tempfile::tempdir().unwrap(); + let key_a = state.keys.lock().unwrap().clone(); + let key_b = Keys::generate(); + + let swapper = { + let state = state.clone(); + let key_b = key_b.clone(); + std::thread::spawn(move || { + // Mirrors import_identity's locking: mutation guard held + // across the key swap. + let _guard = state.identity_mutation.lock().unwrap(); + *state.keys.lock().unwrap() = key_b; + }) + }; + + let backup = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + swapper.join().unwrap(); + + let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD) + .unwrap() + .public_key(); + assert!( + recovered == key_a.public_key() || recovered == key_b.public_key(), + "backup must match one coherent identity" + ); + // Whichever won, the persisted file equals the returned blob. + let on_disk = std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap(); + assert_eq!(on_disk, backup); +} diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index cc8cd483c7..65448405e7 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -399,6 +399,7 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "src/egress_guard.rs", "src/egress_guard_tests.rs", "src/commands/identity.rs", + "src/commands/identity_key_backup_tests.rs", "src/lib.rs", // module registration + invoke handler // boundary wiring (guard call sites name the module, not the codec): "src/relay.rs", diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index 6db9eaa385..418be3bb06 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -37,9 +37,15 @@ pub const BACKUP_LOG_N: u8 = 18; /// Filename of the app-managed canonical backup inside the app data dir. pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec"; -/// Number of words in a generated backup passphrase. Six words from a -/// 1296-word list ≈ 62 bits of entropy before the scrypt work factor. -const PASSPHRASE_WORDS: usize = 6; +/// Default number of words in a generated backup passphrase. Three words +/// from a 1296-word list ≈ 31 bits of entropy before the scrypt work factor. +pub const DEFAULT_PASSPHRASE_WORDS: usize = 3; + +/// Bounds for the generator's word-count control. At the lower bound a draw +/// can fall below [`MIN_PASSPHRASE_LEN`] (three 3-char words), so +/// [`generate_passphrase`] re-draws until the phrase meets the minimum. +pub const MIN_PASSPHRASE_WORDS: usize = 3; +pub const MAX_PASSPHRASE_WORDS: usize = 10; /// EFF short wordlist 2.0 (1296 words, one per line). const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt"); @@ -100,7 +106,7 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { let encrypted = parse_ncryptsec(input)?; let secret_key = encrypted .decrypt(password) - .map_err(|_| "wrong passphrase or corrupted backup".to_string())?; + .map_err(|_| "wrong backup password or damaged key backup".to_string())?; Ok(Keys::new(secret_key)) } @@ -119,8 +125,7 @@ pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result Result { +/// `word_count` is clamped to `MIN_PASSPHRASE_WORDS..=MAX_PASSPHRASE_WORDS`. +/// Because a low-word-count draw can land under [`MIN_PASSPHRASE_LEN`] +/// (e.g. three 3-char words), whole phrases below the minimum are rejected +/// and re-drawn — the result always passes the same length gate applied to +/// user-chosen passphrases. Uses rejection sampling for a uniform +/// distribution over the 1296 words. +pub fn generate_passphrase(word_count: usize, separator: &str) -> Result { + let word_count = word_count.clamp(MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS); let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect(); if words.len() != 1296 { return Err(format!( @@ -202,19 +214,27 @@ pub fn generate_passphrase() -> Result { )); } - let mut chosen: Vec<&str> = Vec::with_capacity(PASSPHRASE_WORDS); - while chosen.len() < PASSPHRASE_WORDS { - let mut buf = [0u8; 2]; - getrandom::getrandom(&mut buf).map_err(|e| format!("entropy source: {e}"))?; - let value = u16::from_le_bytes(buf); - // Rejection sampling: accept only values below the largest multiple - // of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800). - if value < 64800 { - chosen.push(words[(value as usize) % 1296]); + // At 3 words the under-length probability per draw is small, so a few + // attempts always suffice; the cap only guards against a logic bug + // becoming an infinite loop. + for _ in 0..128 { + let mut chosen: Vec<&str> = Vec::with_capacity(word_count); + while chosen.len() < word_count { + let mut buf = [0u8; 2]; + getrandom::getrandom(&mut buf).map_err(|e| format!("entropy source: {e}"))?; + let value = u16::from_le_bytes(buf); + // Rejection sampling: accept only values below the largest + // multiple of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800). + if value < 64800 { + chosen.push(words[(value as usize) % 1296]); + } + } + let phrase = chosen.join(separator); + if phrase.chars().count() >= MIN_PASSPHRASE_LEN { + return Ok(phrase); } } - - Ok(chosen.join(" ")) + Err("could not generate a passphrase meeting the minimum length".to_string()) } #[cfg(test)] diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 2f92343f23..2367de80ec 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -41,7 +41,7 @@ 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 passphrase or corrupted backup"); + assert_eq!(err, "wrong backup password or damaged key backup"); } #[test] @@ -90,13 +90,13 @@ fn recover_keys_ncryptsec_happy_path() { #[test] fn recover_keys_ncryptsec_requires_password() { let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err(); - assert_eq!(err, "encrypted backup requires a passphrase"); + 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 passphrase or corrupted backup"); + assert_eq!(err, "wrong backup password or damaged key backup"); } /// Bech32 permits an all-uppercase encoding: `NCRYPTSEC1…` must classify as @@ -108,7 +108,7 @@ fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() { let upper = SPEC_NCRYPTSEC.to_ascii_uppercase(); // Routing proof: encrypted path demands a passphrase. let err = recover_keys_from_input(&upper, None).unwrap_err(); - assert_eq!(err, "encrypted backup requires a passphrase"); + assert_eq!(err, "key backup requires a password"); // With the passphrase, the bech32 decoder accepts the uppercase form. let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap(); assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); @@ -198,28 +198,42 @@ fn cleanup_stale_backup_removes_only_on_identity_change() { // ── Passphrase generation ───────────────────────────────────────────────────── #[test] -fn generated_passphrase_is_six_known_words() { +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 _ in 0..8 { - let phrase = generate_passphrase().unwrap(); - let parts: Vec<&str> = phrase.split(' ').collect(); - assert_eq!(parts.len(), 6); - for w in &parts { - assert!(words.contains(w), "unknown word {w:?}"); + 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() { - // 6 words × ~10.3 bits each — a collision across 8 draws would indicate a + // 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().unwrap())); + assert!(seen.insert(generate_passphrase(DEFAULT_PASSPHRASE_WORDS, "-").unwrap())); } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c75daafea5..661295fa04 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -682,6 +682,7 @@ pub fn run() { get_nsec, generate_backup_passphrase, create_ncryptsec_backup, + verify_ncryptsec_backup, save_ncryptsec_copy, import_identity, persist_current_identity, diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index bde1458222..71bd382db2 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -103,7 +103,7 @@ export function WelcomeSetup({ data-system-color-scheme={systemColorScheme} > - +
{page === "welcome" ? ( diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs index f118bbf455..1d98a98541 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -1,156 +1,117 @@ -/** - * Pure-logic tests for the encrypted-backup (NIP-49) creation state model. - * These drive the same reducer + validation helpers the BackupStep and - * settings row use, without a DOM. - */ import assert from "node:assert/strict"; import test from "node:test"; - import { - MIN_CUSTOM_PASSPHRASE_LEN, - createDisabled, - customPassphraseIssue, + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, effectivePassphrase, encryptedBackupReducer, initialEncryptedBackupState, } from "./encryptedBackup.ts"; - -function reduce(events, from = initialEncryptedBackupState) { - return events.reduce(encryptedBackupReducer, from); -} - -// ── generated-passphrase mode (default) ───────────────────────────────────── - -test("create_disabled_until_generated_passphrase_arrives", () => { - assert.equal(createDisabled(initialEncryptedBackupState), true); +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { const ready = reduce([ - { - type: "passphrase-generated", - passphrase: "alpha bravo carbon delta echo fox", - }, + { type: "set-passphrase", value: "one-two-three-four" }, ]); - assert.equal(createDisabled(ready), false); - assert.equal(effectivePassphrase(ready), "alpha bravo carbon delta echo fox"); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); }); - -test("regenerate_replaces_passphrase_and_clears_generate_error", () => { - const failed = reduce([ - { type: "passphrase-generate-failed", message: "boom" }, +test("success clears password and retains only encrypted blob", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, ]); - assert.equal(failed.generateError, "boom"); - const recovered = reduce( - [{ type: "passphrase-generated", passphrase: "a b c d e f" }], - failed, - ); - assert.equal(recovered.generateError, null); - assert.equal(recovered.generatedPassphrase, "a b c d e f"); + assert.equal(state.passphrase, ""); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.savedPassword, true); + assert.equal(state.requestId, null); }); - -// ── custom-passphrase mode ─────────────────────────────────────────────────── - -test("custom_mode_requires_min_length_and_matching_confirm", () => { - const base = reduce([ - { type: "passphrase-generated", passphrase: "gen gen gen gen gen gen" }, - { type: "set-mode", mode: "custom" }, +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, ]); - - // Too short — even though a generated passphrase exists, custom mode must - // not silently fall back to it. - const short = reduce( - [ - { type: "set-custom-passphrase", value: "short" }, - { type: "set-custom-confirm", value: "short" }, - ], - base, - ); - assert.equal(effectivePassphrase(short), null); - assert.equal(createDisabled(short), true); - - // Long enough but mismatched confirm. - const mismatched = reduce( - [ - { - type: "set-custom-passphrase", - value: "a".repeat(MIN_CUSTOM_PASSPHRASE_LEN), - }, - { - type: "set-custom-confirm", - value: "b".repeat(MIN_CUSTOM_PASSPHRASE_LEN), - }, - ], - base, - ); - assert.equal(effectivePassphrase(mismatched), null); - - // Valid. - const ok = reduce( - [ - { type: "set-custom-passphrase", value: "correct horse battery" }, - { type: "set-custom-confirm", value: "correct horse battery" }, - ], - base, - ); - assert.equal(effectivePassphrase(ok), "correct horse battery"); - assert.equal(createDisabled(ok), false); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); }); - -test("custom_passphrase_issue_messages", () => { - // Empty input: no scolding while the user hasn't typed anything. - assert.equal(customPassphraseIssue("", ""), null); - assert.match( - customPassphraseIssue("short", ""), - new RegExp(`${MIN_CUSTOM_PASSPHRASE_LEN}`), - ); - // Mismatch is only reported once confirm has content. - assert.equal(customPassphraseIssue("a".repeat(12), ""), null); - assert.match(customPassphraseIssue("a".repeat(12), "b"), /match/); - assert.equal(customPassphraseIssue("a".repeat(12), "a".repeat(12)), null); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); }); - -test("min_length_counts_code_points_not_utf16_units", () => { - // 12 astral-plane emoji = 24 UTF-16 units but 12 code points; mirrors the - // Rust chars().count() gate so both sides agree on the boundary. - const emoji = "😀".repeat(MIN_CUSTOM_PASSPHRASE_LEN); - assert.equal(customPassphraseIssue(emoji, emoji), null); - const ready = reduce([ - { type: "set-mode", mode: "custom" }, - { type: "set-custom-passphrase", value: emoji }, - { type: "set-custom-confirm", value: emoji }, +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, ]); - assert.equal(effectivePassphrase(ready), emoji); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); }); - -// ── create lifecycle ───────────────────────────────────────────────────────── - -test("create_lifecycle_happy_path_and_failure", () => { - const ready = reduce([ - { type: "passphrase-generated", passphrase: "one two three four five six" }, +test("Back preserves blob for immediate re-download without password", () => { + const made = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + { type: "back-to-password" }, ]); - - const creating = reduce([{ type: "create-started" }], ready); - assert.equal(creating.isCreating, true); + assert.equal(made.ncryptsec, "ncryptsec1abc"); + assert.equal(made.passphrase, ""); + assert.equal(downloadDisabled(made), false); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); assert.equal( - createDisabled(creating), - true, - "no double-create while KDF runs", - ); - - const failed = reduce( - [{ type: "create-failed", message: "keychain unavailable" }], - creating, - ); - assert.equal(failed.isCreating, false); - assert.equal(failed.createError, "keychain unavailable"); - assert.equal(createDisabled(failed), false, "retry allowed after failure"); - - const done = reduce( - [ - { type: "create-started" }, - { type: "create-succeeded", ncryptsec: "ncryptsec1abc" }, - ], - failed, + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, ); - assert.equal(done.isCreating, false); - assert.equal(done.ncryptsec, "ncryptsec1abc"); - assert.equal(done.createError, null); }); diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts index c8f790591a..d2aad94422 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.ts +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -1,117 +1,128 @@ -/** - * Pure state model for the encrypted-key-backup (NIP-49) creation flow, - * shared by the onboarding BackupStep and the settings Password Backup row. - * - * All validation and phase logic lives here so it can be unit-tested without - * React. Hosts wire the reducer to the Tauri commands - * (`generate_backup_passphrase`, `create_ncryptsec_backup`) and dispatch - * events; the model never touches the raw private key — by construction the - * default backup path cannot invoke `get_nsec`. - */ - -export type PassphraseMode = "generated" | "custom"; - -/** Mirrors `MIN_PASSPHRASE_LEN` in `src-tauri/src/key_backup.rs`. */ -export const MIN_CUSTOM_PASSPHRASE_LEN = 12; +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; export type EncryptedBackupState = { - /** Six-word passphrase generated in Rust; null until loaded. */ - generatedPassphrase: string | null; - generateError: string | null; - mode: PassphraseMode; - customPassphrase: string; - customConfirm: string; - isCreating: boolean; + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; createError: string | null; - /** The persisted `ncryptsec1…` blob once the backup exists. */ + downloadPending: boolean; ncryptsec: string | null; + savedPassword: boolean; }; export const initialEncryptedBackupState: EncryptedBackupState = { - generatedPassphrase: null, - generateError: null, - mode: "generated", - customPassphrase: "", - customConfirm: "", - isCreating: false, + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, createError: null, + downloadPending: false, ncryptsec: null, + savedPassword: false, }; export type EncryptedBackupEvent = - | { type: "passphrase-generated"; passphrase: string } - | { type: "passphrase-generate-failed"; message: string } - | { type: "set-mode"; mode: PassphraseMode } - | { type: "set-custom-passphrase"; value: string } - | { type: "set-custom-confirm"; value: string } - | { type: "create-started" } - | { type: "create-succeeded"; ncryptsec: string } - | { type: "create-failed"; message: string }; + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "back-to-password" } + | { type: "start-new-backup" }; export function encryptedBackupReducer( state: EncryptedBackupState, event: EncryptedBackupEvent, ): EncryptedBackupState { switch (event.type) { - case "passphrase-generated": + case "set-passphrase": + return { + ...state, + passphrase: event.value, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + return { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: state.downloadPending ? event.ncryptsec : state.ncryptsec, + downloadPending: false, + savedPassword: true, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; return { ...state, - generatedPassphrase: event.passphrase, - generateError: null, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, downloadPending: true }; + case "back-to-password": + return { ...state, createError: null }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, }; - case "passphrase-generate-failed": - return { ...state, generateError: event.message }; - case "set-mode": - // Editing state carries across toggles; validation re-derives. - return { ...state, mode: event.mode, createError: null }; - case "set-custom-passphrase": - return { ...state, customPassphrase: event.value, createError: null }; - case "set-custom-confirm": - return { ...state, customConfirm: event.value, createError: null }; - case "create-started": - return { ...state, isCreating: true, createError: null }; - case "create-succeeded": - return { ...state, isCreating: false, ncryptsec: event.ncryptsec }; - case "create-failed": - return { ...state, isCreating: false, createError: event.message }; } } -/** - * Validation issue for a custom passphrase, or null when acceptable. - * Confirm mismatch is only reported once the confirm field has content, so - * the user isn't scolded mid-typing. - */ -export function customPassphraseIssue( - passphrase: string, - confirm: string, -): string | null { +export function passphraseIssue(passphrase: string): string | null { if (passphrase.length === 0) return null; - if ([...passphrase].length < MIN_CUSTOM_PASSPHRASE_LEN) { - return `Use at least ${MIN_CUSTOM_PASSPHRASE_LEN} characters.`; - } - if (confirm.length > 0 && passphrase !== confirm) { - return "Passphrases don't match."; - } - return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; } - -/** The passphrase the Create action would submit, or null when not ready. */ export function effectivePassphrase( state: EncryptedBackupState, ): string | null { - if (state.mode === "generated") return state.generatedPassphrase; - const { customPassphrase, customConfirm } = state; - if ( - [...customPassphrase].length < MIN_CUSTOM_PASSPHRASE_LEN || - customPassphrase !== customConfirm - ) { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if (state.savedPassword || state.encrypted || state.requestId !== null) return null; - } - return customPassphrase; + return effectivePassphrase(state); } - -/** Whether the "Create backup" action is currently actionable. */ -export function createDisabled(state: EncryptedBackupState): boolean { - return state.isCreating || effectivePassphrase(state) === null; +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); } diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 4b7b176494..1d1b1d0bcf 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,82 +1,76 @@ -import { AlertTriangle, Info, RefreshCw } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, Info } from "lucide-react"; import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; +import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import { Card } from "@/shared/ui/card"; import { Spinner } from "@/shared/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { EncryptedBackupCreator } from "./EncryptedBackupCreator"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; -export type BackupStepMode = "encrypted" | "raw"; +/** + * How long the "Creating your identity key" loader holds the stage before the + * finished state fades in. Purely perceptual — the key already exists; the + * pause sells the creation moment. + */ +const INTRO_HOLD_MS = 1400; /** - * Pure helper so the disabled logic can be unit-tested without a DOM. - * - * Encrypted mode (default): Next unlocks once the backup blob exists — the - * user must either create a backup or explicitly switch to the raw key. - * Raw mode: disabled while loading or after a failed load (only the explicit - * "Skip for now" ghost advances past an error), matching the previous flow. + * The creation moment should only be sold once per app session. Module-level + * so remounts (e.g. navigating Back and returning to this step) skip the fake + * hold and show the finished state instantly. */ -export function backupNextDisabled({ - mode, - hasBackup, - isLoading, - loadError, -}: { - mode: BackupStepMode; - hasBackup: boolean; - isLoading: boolean; - loadError: string | null; -}): boolean { - if (mode === "encrypted") { - return !hasBackup; - } - return isLoading || loadError !== null; +let introPlayed = false; + +const REVEAL_ANIMATION_CLASS = + "animate-in fade-in duration-700 motion-reduce:animate-none"; + +/** Viewing the key never blocks onboarding — Next is always actionable. */ +export function backupNextDisabled(): boolean { + return false; } type BackupStepProps = { direction: OnboardingTransitionDirection; onBack: () => void; - onNext: () => void; + /** Advances to the dedicated "Download your key" onboarding step. */ + onDownload: () => void; }; /** - * Onboarding backup step — encrypted by default. The user protects their - * freshly created key with a passphrase and gets a NIP-49 `ncryptsec1…` - * backup; the raw key is only fetched (and shown) after an explicit - * "Show raw key instead" click. The default path never invokes `get_nsec`. + * Onboarding backup step — shows the freshly created key and offers a direct + * clipboard copy destined for a password manager. Next leads into the + * encrypted download step (its own onboarding page, via `onDownload`), which + * is skippable there. The raw key is fetched only when the user explicitly + * clicks Copy or Reveal, and is never held in state before that. */ -export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { - const [mode, setMode] = React.useState("encrypted"); - const [hasBackup, setHasBackup] = React.useState(false); +export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) { + const [created, setCreated] = React.useState(introPlayed); + const [copyState, setCopyState] = React.useState< + "idle" | "copying" | "copied" + >("idle"); + const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(false); - const [loadError, setLoadError] = React.useState(null); + const [isRevealed, setIsRevealed] = React.useState(false); const cancelledRef = React.useRef(false); + const copiedTimerRef = React.useRef(null); - const loadNsec = React.useCallback(async () => { - setIsLoading(true); - setLoadError(null); - try { - const value = await getNsec(); - if (!cancelledRef.current) setNsec(value); - } catch (err) { - if (!cancelledRef.current) - setLoadError( - err instanceof Error - ? err.message - : "Failed to retrieve private key.", - ); - } finally { - if (!cancelledRef.current) setIsLoading(false); - } + React.useEffect(() => { + if (introPlayed) return; + const timer = window.setTimeout(() => { + introPlayed = true; + setCreated(true); + }, INTRO_HOLD_MS); + return () => window.clearTimeout(timer); }, []); React.useEffect(() => { @@ -86,13 +80,61 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { // nsec from memory on unmount (backup step is only on the fresh-key path). cancelledRef.current = true; setNsec(null); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); }; }, []); - const showRawKey = React.useCallback(() => { - setMode("raw"); - void loadNsec(); - }, [loadNsec]); + const copyKeyToClipboard = React.useCallback(async () => { + setCopyState("copying"); + setCopyError(null); + try { + const value = nsec ?? (await getNsec()); + await writeTextToClipboard(value); + if (cancelledRef.current) return; + setCopyState("copied"); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = window.setTimeout(() => { + if (!cancelledRef.current) setCopyState("idle"); + }, 2000); + } catch (err) { + if (cancelledRef.current) return; + setCopyState("idle"); + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [nsec]); + + const toggleReveal = React.useCallback(async () => { + if (isRevealed) { + setIsRevealed(false); + return; + } + setCopyError(null); + try { + // The raw key enters the DOM only after this explicit reveal action. + const value = nsec ?? (await getNsec()); + if (cancelledRef.current) return; + setNsec(value); + setIsRevealed(true); + } catch (err) { + if (cancelledRef.current) return; + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [isRevealed, nsec]); + + // Fixed-length decorative mask (nsec keys are 63 chars) so no key material + // is fetched just to render the blurred row. Bullets are joined with a + // zero-width space: WebKit won't line-break a run of U+2022 without an + // explicit break opportunity, so the masked row would overflow otherwise. + const maskedKey = React.useMemo( + () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), + [nsec], + ); return (
-

- Your unique identity key has been created + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {created + ? "Your unique identity key has been created" + : "Creating your identity key"}

-

- {mode === "encrypted" - ? "Protect it with a passphrase and keep an encrypted backup in case you ever need to restore your account." - : "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."} -

+ {created ? ( +

+ Your identity key will be saved to your keychain. Back it up + somewhere safe so you can restore your account. Never share your + key. +

+ ) : null}
-
- {mode === "encrypted" ? ( - -
- setHasBackup(true)} - variant="spotlight" - /> -
-
- ) : isLoading ? ( -
- - Loading your private key… -
- ) : loadError ? ( -
-
- + {!created ? ( +
+ +
+ ) : ( +
+
+ +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+
+ + + + + + + Copy your key and save it somewhere safe — a password + manager is a great place for it. + + +
+
+ {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} +
+ +

+ - Could not retrieve your private key: {loadError}. You can - continue and find it later in Settings > Profile > - Identity. + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. -

- +

- ) : nsec ? ( - -
- -
-
- ) : ( -

- No key available to back up. -

- )} - - {mode === "encrypted" && !hasBackup ? ( -
- -
- ) : null} - - {mode === "raw" && nsec ? ( -

- - - Never share your private key. Anyone with this key can impersonate - you and access everything in your account. - -

- ) : null} -
+
+ )} - - + {created ? ( + + - {mode === "raw" && loadError ? ( - ) : null} - - - + + ) : null} ); } diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..dee0ea9405 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -0,0 +1,557 @@ +import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Durable progress through the test flow. Owned by the host so navigating + * away (e.g. onboarding Back) and returning doesn't force the user to + * re-drop the file. The password attempt is deliberately NOT part of this + * state — it lives only in short-lived component state and is cleared the + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ + variant?: "spotlight" | "boxed"; + /** + * When supplied, only this exact just-created file is accepted — the + * onboarding ceremony proves the user saved *that* backup. Without it the + * flow is a general-purpose tester for any key backup file. + */ + expectedNcryptsec?: string; + /** Re-open the native save dialog for another copy of the backup file. */ + onSaveCopy?: () => void; + isSaving?: boolean; + savedPath?: string | null; + saveError?: string | null; + /** Host-owned progress so it survives this component unmounting. */ + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; + /** Fired once when the user completes the test successfully. */ + onVerified?: () => void; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + variant = "spotlight", + expectedNcryptsec, + onSaveCopy, + isSaving = false, + savedPath, + saveError, + progress, + onProgressChange, + onVerified, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError( + expectedNcryptsec + ? "That doesn't look like your key backup. Choose the file you just downloaded." + : "That doesn't look like a key backup file.", + ); + return; + } + if (expectedNcryptsec && text !== expectedNcryptsec.trim()) { + setError("That's a key backup, but not the one you just downloaded."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [expectedNcryptsec, onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + onVerified?.(); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange, onVerified]); + + const isSpotlight = variant === "spotlight"; + + if (stage === "success" && result) { + // The onboarding ceremony pins the exact file, so a success there is by + // construction the current identity — celebrate and move on. The general + // tester reports which identity the backup unlocks. + const isCeremony = Boolean(expectedNcryptsec); + return ( +
+ {reduceMotion ? null : } + + + +

+ {isCeremony ? "Your backup works!" : "This backup works"} +

+

+ {isCeremony + ? "File and password verified. Keep them both somewhere safe — that's all you need to restore your identity." + : result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+ {isCeremony ? null : ( +
+ +
+ )} +
+ {isCeremony ? null : ( + + )} +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + <> + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the onboarding card / the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {onSaveCopy ? ( +
+ + {savedPath ? ( +

+ Saved to {savedPath} +

+ ) : null} +
+ ) : null} + {saveError ? ( +

{saveError}

+ ) : null} + + ) : ( + <> +
+
+

+ That's the one. Now enter your password to prove you can unlock it. +

+
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+ + )} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33f..805d19661a 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({ > {isProfileStage || isTeamStage ? ( - + ) : null}
void; + onNext: () => void; +}; + +/** + * Onboarding download step — the password-first encrypted key download + * flow, promoted to its own page in the machine onboarding flow. + * The raw key never enters this component: Rust builds the NIP-49 payload + * locally and the native save dialog produces the user-owned file. + */ +export function DownloadKeyStep({ + direction, + session, + onBack, + onNext, +}: DownloadKeyStepProps) { + const reduceMotion = useReducedMotion() ?? false; + // True once the encrypted payload exists — the create button (living in the + // footer's primary slot) disappears with the form, so Next takes its place. + const hasCreated = session.created; + // True once the user has passed the backup test — until then Next stays + // disabled and "Skip for now" remains the escape hatch. + const hasVerified = session.verified; + // Footer slot the creator portals its "Download" button into. + const [createButtonSlot, setCreateButtonSlot] = + React.useState(null); + + return ( + +
+ {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {hasCreated + ? "Now, test your backup" + : "Backup your key with a password"} +

+

+ {hasCreated + ? "Make sure your backup works: drop the file you just saved and unlock it with your password." + : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."} +

+
+ +
+
+ + +
+ +
+
+
+
+
+ + + {hasCreated ? ( + hasVerified ? ( + + ) : ( + /* No disabled Next while the test is unfinished — skipping is + the only way forward until verification succeeds. */ + + ) + ) : ( + /* Relative row keeps the Download CTA truly centered while Skip + hangs off its right edge without shifting the center. */ +
+
+ +
+ )} + + + + {hasCreated ? null : ( +

+ You can back up your key anytime in Settings → Profile → + Identity. +

+ )} + + + ); +} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index ced5e9e449..b080f6808b 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -1,5 +1,6 @@ -import { AlertTriangle, RefreshCw } from "lucide-react"; +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; import * as React from "react"; +import { createPortal } from "react-dom"; import { createNcryptsecBackup, @@ -9,89 +10,530 @@ import { import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; import { - createDisabled, - customPassphraseIssue, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, encryptedBackupReducer, initialEncryptedBackupState, - MIN_CUSTOM_PASSPHRASE_LEN, - effectivePassphrase, + MIN_PASSPHRASE_LEN, + type EncryptedBackupEvent, + type EncryptedBackupState, } from "../lib/encryptedBackup"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { + type BackupTestProgress, + BackupTestFlow, + initialBackupTestProgress, +} from "./BackupTestFlow"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Pause after the last keystroke before the background KDF starts, so typing + * past the minimum length doesn't launch an encryption per character. + */ +const ENCRYPT_DEBOUNCE_MS = 400; + +const PENDING_TICKER_MESSAGES = [ + "Downloading once finished", + "Encrypting your password", + "Just a bit longer...", +] as const; + +/** How long each ticker message holds before sliding to the next. */ +const PENDING_TICKER_INTERVAL_MS = 2500; + +/** Matches the `duration-300` slide transition on the ticker column. */ +const PENDING_TICKER_SLIDE_MS = 300; + +/** + * Vertical ticker for the queued-download button label — cycles through the + * pending messages by sliding a stacked column inside a one-line viewport. + * The column ends with a clone of the first message, so the wrap-around + * slides up from the bottom like every other step; once the clone settles, + * the column snaps (transition disabled) back to the real first row. All + * lines render at all times, so the button keeps the width of the longest + * message instead of resizing on each swap. + */ +function PendingDownloadTicker() { + // Index into the rendered column (messages + trailing clone of the first). + const [position, setPosition] = React.useState(0); + const [snap, setSnap] = React.useState(false); + + React.useEffect(() => { + const timer = window.setInterval( + () => setPosition((current) => current + 1), + PENDING_TICKER_INTERVAL_MS, + ); + return () => window.clearInterval(timer); + }, []); + + // The clone is visually identical to the first message: once its slide-in + // finishes, jump back to the real first row without animating. + React.useEffect(() => { + if (position !== PENDING_TICKER_MESSAGES.length) return; + const timer = window.setTimeout(() => { + setSnap(true); + setPosition(0); + }, PENDING_TICKER_SLIDE_MS); + return () => window.clearTimeout(timer); + }, [position]); + + // Re-enable the transition one frame after the snap has painted. + React.useEffect(() => { + if (!snap) return; + const raf = window.requestAnimationFrame(() => setSnap(false)); + return () => window.cancelAnimationFrame(raf); + }, [snap]); + + // The clone row duplicates the first message's text, so it carries its own + // stable key. + const column = [ + ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })), + { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] }, + ]; + + return ( + + + {column.map((row) => ( + + {row.message} + + ))} + + + ); +} + +/** + * Everything about an in-progress backup that must survive this component + * unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the + * backup test passed, where the file was saved, the save-once guard, and the + * test-flow progress. Hosts that need the state to outlive the creator (the + * onboarding flow, where Back unmounts the step) call + * `useEncryptedBackupSession` at a longer-lived level and pass it down; + * otherwise the creator owns a private session internally. + */ +export type EncryptedBackupSession = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + /** + * True once the encrypted payload has been committed AND saved to disk. + * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching + * the blob itself — keeping them outside the ncryptsec confinement scan. + */ + created: boolean; + /** True once the user has passed the backup test. */ + verified: boolean; + setVerified: React.Dispatch>; + savedPath: string | null; + setSavedPath: React.Dispatch>; + /** The committed blob a save was already kicked off for (save-once guard). */ + savedForRef: React.MutableRefObject; + test: BackupTestProgress; + setTest: React.Dispatch>; +}; + +/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */ +export function useEncryptedBackupSession(): EncryptedBackupSession { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [verified, setVerified] = React.useState(false); + const [savedPath, setSavedPath] = React.useState(null); + const savedForRef = React.useRef(null); + const [test, setTest] = React.useState( + initialBackupTestProgress, + ); + return React.useMemo( + () => ({ + state, + dispatch, + created: state.ncryptsec !== null && savedPath !== null, + verified, + setVerified, + savedPath, + setSavedPath, + savedForRef, + test, + setTest, + }), + [state, verified, savedPath, test], + ); +} + +/** + * Return to a secure saved-password placeholder. The encrypted blob survives + * for instant re-download, while no password or test attempt is retained. + */ +export function backupSessionToPasswordEntry( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "back-to-password" }); + session.setVerified(false); + session.setSavedPath(null); + session.setTest(initialBackupTestProgress); +} type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; - /** Fired once the backup blob exists (hosts gate Next / show toasts). */ - onCreated?: (ncryptsec: string) => void; + /** + * When set, the "Download" button is portaled into this element + * (e.g. the onboarding footer's primary slot) instead of rendering inline. + */ + createButtonPortal?: HTMLElement | null; + /** Extra classes for the "Download" button. */ + createButtonClassName?: string; + /** + * Host-owned session so the backup state survives this component + * unmounting (onboarding Back navigation). Omitted = private session. + */ + session?: EncryptedBackupSession; + /** Fired once the encrypted payload has been created (before saving). */ + onCreated?: () => void; + /** Fired only after the encrypted key file has been saved successfully. */ + onSaved?: (path: string) => void; + /** Whether creation continues into onboarding's guided test ceremony. */ + guidedTest?: boolean; + /** Fired once when the user completes the backup test successfully. */ + onVerified?: () => void; }; /** - * Passphrase-first NIP-49 backup creation flow, shared by the onboarding - * BackupStep and the settings Password Backup row. + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow shared by onboarding and + * Settings. The raw private key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. * - * The raw private key never enters this component: it collects a passphrase, - * asks Rust to create + persist the encrypted backup, and displays the - * returned `ncryptsec1…` blob. A generated 6-word passphrase is the default; - * "choose my own" requires ≥12 characters plus confirmation. + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly; clicking mid-encryption queues the + * download until the KDF finishes. */ export function EncryptedBackupCreator({ variant = "spotlight", + createButtonPortal, + createButtonClassName, + session: sessionProp, onCreated, + onSaved, + guidedTest = true, + onVerified, }: EncryptedBackupCreatorProps) { - const [state, dispatch] = React.useReducer( - encryptedBackupReducer, - initialEncryptedBackupState, - ); - const [savedPath, setSavedPath] = React.useState(null); + // Hosts without a longer-lived session get a private one (settings card). + const fallbackSession = useEncryptedBackupSession(); + const session = sessionProp ?? fallbackSession; + const { state, dispatch, savedPath, setSavedPath, savedForRef } = session; + const [isRevealed, setIsRevealed] = React.useState(false); const [saveError, setSaveError] = React.useState(null); const [isSaving, setIsSaving] = React.useState(false); + const [confirmNewPassword, setConfirmNewPassword] = React.useState(false); const mountedRef = React.useRef(true); - const generate = React.useCallback(async () => { - try { - const passphrase = await generateBackupPassphrase(); - if (mountedRef.current) - dispatch({ type: "passphrase-generated", passphrase }); - } catch (err) { - if (mountedRef.current) - dispatch({ - type: "passphrase-generate-failed", - message: - err instanceof Error - ? err.message - : "Failed to generate a passphrase.", - }); - } - }, []); - React.useEffect(() => { mountedRef.current = true; - void generate(); return () => { mountedRef.current = false; }; - }, [generate]); + }, []); - const handleCreate = React.useCallback(async () => { - const passphrase = effectivePassphrase(state); - if (!passphrase || state.isCreating) return; - dispatch({ type: "create-started" }); - try { - const ncryptsec = await createNcryptsecBackup(passphrase); - if (!mountedRef.current) return; - dispatch({ type: "create-succeeded", ncryptsec }); - onCreated?.(ncryptsec); - } catch (err) { - if (mountedRef.current) - dispatch({ - type: "create-failed", - message: - err instanceof Error ? err.message : "Failed to create backup.", - }); - } - }, [onCreated, state]); + // A queued download locks the form — mask the password too so it isn't + // left readable on screen while the user waits for the save dialog. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + // Correlate KDF completion by an opaque request id. The password exists only + // in this short-lived effect closure and is cleared from reducer state once + // Rust returns; stale completions cannot commit. + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let cancelled = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelled) return; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }), + ) + .catch((err: unknown) => + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }), + ); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [dispatch, pendingPassphrase, skipDebounce, state.nextRequestId]); + + // Download commit: fires once per committed blob, whether the commit was + // instant (encryption already done) or resolved a queued download. The flow + // only advances to the test view once the file is actually on disk — a + // canceled save dialog or a save failure rolls the commit back to the + // password form so "Download backup" can be clicked again. + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || savedForRef.current === ncryptsec) return; + savedForRef.current = ncryptsec; + onCreated?.(); + setIsSaving(true); + setSaveError(null); + const rollBack = () => { + savedForRef.current = null; + dispatch({ type: "back-to-password" }); + }; + void saveNcryptsecCopy(ncryptsec) + .then((path) => { + if (path) { + setSavedPath(path); + onSaved?.(path); + } else { + // User canceled the native save dialog — nothing was downloaded. + rollBack(); + } + }) + .catch((err: unknown) => { + rollBack(); + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + }) + .finally(() => { + if (mountedRef.current) setIsSaving(false); + }); + }, [ + dispatch, + onCreated, + onSaved, + savedForRef, + setSavedPath, + state.ncryptsec, + ]); const handleSaveCopy = React.useCallback(async () => { if (!state.ncryptsec || isSaving) return; @@ -99,183 +541,170 @@ export function EncryptedBackupCreator({ setSaveError(null); try { const path = await saveNcryptsecCopy(state.ncryptsec); - if (mountedRef.current && path) setSavedPath(path); + if (mountedRef.current && path) { + setSavedPath(path); + onSaved?.(path); + } } catch (err) { if (mountedRef.current) setSaveError( - err instanceof Error ? err.message : "Failed to save a copy.", + err instanceof Error ? err.message : "Failed to save your key.", ); } finally { if (mountedRef.current) setIsSaving(false); } - }, [isSaving, state.ncryptsec]); + }, [isSaving, onSaved, setSavedPath, state.ncryptsec]); - const isSpotlight = variant === "spotlight"; - const customIssue = customPassphraseIssue( - state.customPassphrase, - state.customConfirm, - ); + const { setVerified, test, setTest } = session; + const handleVerified = React.useCallback(() => { + setVerified(true); + onVerified?.(); + }, [onVerified, setVerified]); + + const issue = passphraseIssue(state.passphrase); - if (state.ncryptsec) { + // The test view requires a successful save, not just a committed blob — + // while the native save dialog is open the password form stays put. + if (state.ncryptsec && savedPath && guidedTest) { return ( -
- + void handleSaveCopy()} + onVerified={handleVerified} + progress={test} + saveError={saveError} + savedPath={savedPath} + variant={variant} /> -
- - {savedPath ? ( -

- Saved to {savedPath} -

- ) : null} -
- {saveError ? ( -

{saveError}

- ) : null} -

- This backup can only be unlocked with your passphrase. Without the - passphrase it cannot be recovered — not even by Buzz. -

); } + // Without the guided test (settings), a completed save keeps the form + // visible in its saved-password state: masked input, instant re-download, + // and the change-password confirmation guarding any edit. return (
- {state.mode === "generated" ? ( -
- {state.generatedPassphrase ? ( -
-

- {state.generatedPassphrase} -

-
- ) : state.generateError ? ( -
- - - Could not generate a passphrase: {state.generateError} - -
+
+ { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onPaste={(event) => { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onChange={(event) => + dispatch({ type: "set-passphrase", value: event.target.value }) + } + placeholder={ + state.savedPassword + ? "" + : `Password (min ${MIN_PASSPHRASE_LEN} characters)` + } + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + {state.savedPassword ? ( +
+ •••••••••••••••••••••••••••••••• +
+ ) : null} + {state.savedPassword ? ( + + Backup password saved; hidden for security. + + ) : null} + - -
-

- Write this passphrase down. It protects your backup and cannot be - recovered if lost. + + setConfirmNewPassword(true) : undefined + } + onGenerated={(value) => { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + /> + {issue ? ( +

+ {issue}

-
- ) : ( -
-
- - dispatch({ - type: "set-custom-passphrase", - value: event.target.value, - }) - } - placeholder={`Passphrase (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`} - type="password" - value={state.customPassphrase} - /> - - dispatch({ - type: "set-custom-confirm", - value: event.target.value, - }) - } - placeholder="Confirm passphrase" - type="password" - value={state.customConfirm} - /> -
- {customIssue ? ( -

- {customIssue} -

- ) : null} -
- -
-

- Your passphrase protects the backup and cannot be recovered if lost. + ) : null} +

+ + {state.savedPassword && state.ncryptsec && savedPath ? ( +
+

+ Backup saved to {savedPath} +

+

+ Your password isn't kept — download another copy anytime, or start + over to choose a new password.

- )} + ) : null} {state.createError ? (

) : null} -

- -
+ {saveError} +

+ ) : null} + + {(() => { + // Absolute spinner: signals the background encryption without + // shifting the centered button while it appears and disappears. + const createButton = ( +
+ {isEncrypting(state) || state.downloadPending || isSaving ? ( + + ) : null} + +
+ ); + // `undefined` = inline (settings); `null` = slot not mounted yet + // (skip a frame rather than flashing the button inline). + if (createButtonPortal === undefined) + return
{createButton}
; + return createButtonPortal + ? createPortal(createButton, createButtonPortal) + : null; + })()} + + + + Create a new backup password? + + Starting over lets you pick a new password and download a fresh + backup file. Backups you saved earlier will still work — just use + the password you created them with. + + + + Keep current backup + { + dispatch({ type: "start-new-backup" }); + setSavedPath(null); + savedForRef.current = null; + setTest(initialBackupTestProgress); + setIsRevealed(false); + }} + > + Start with a new password + + + +
); } diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index a93d75f2ef..2576f71e10 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,6 +10,11 @@ import { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; +import { DownloadKeyStep } from "./DownloadKeyStep"; +import { + backupSessionToPasswordEntry, + useEncryptedBackupSession, +} from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm } from "./NostrKeyImportForm"; @@ -25,6 +30,7 @@ export type MachineOnboardingPage = | "identity" | "key-import" | "backup" + | "download" | "setup" | "config"; @@ -65,6 +71,9 @@ export function MachineOnboardingFlow({ null, ); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + // Owned here (not by DownloadKeyStep) so Back navigation — which unmounts + // the step — keeps the created backup, entered password, and test progress. + const backupSession = useEncryptedBackupSession(); const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -136,7 +145,15 @@ export function MachineOnboardingFlow({ {page === "identity" ? : null} {page !== "identity" ? ( ) : null} @@ -227,13 +244,29 @@ export function MachineOnboardingFlow({ setPage("identity")} + onDownload={() => setPage("download")} + /> + ) : page === "download" ? ( + setPage("backup")} onNext={() => setPage("setup")} + session={backupSession} /> ) : page === "setup" ? ( - setPage(identityWasImported ? "key-import" : "backup"), + // Fresh-key users return to the "Backup your key with a + // password" form (not the test flow they may have finished); + // imported keys skip that step entirely. + back: () => { + if (identityWasImported) { + setPage("key-import"); + return; + } + backupSessionToPasswordEntry(backupSession); + setPage("download"); + }, next: (runtimeIds) => { const ids = Array.from(runtimeIds); setReadyRuntimeIds(ids); diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index dfa68e7d5b..900b7b932e 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -96,7 +96,7 @@ export function NostrKeyImportForm({ if (file.size > NOSTR_KEY_FILE_MAX_BYTES) { setImportError( - "That file is too large to be a key. Choose a .key or .ncryptsec backup file, or paste your key.", + "That file is too large to be a key backup or private key. Choose another file.", ); return; } @@ -126,7 +126,7 @@ export function NostrKeyImportForm({ if (!isValid) { setImportError( isEncryptedInput - ? "Enter the passphrase for this encrypted backup." + ? "Enter the password for this key backup." : "That doesn't look like a valid nsec. Paste an nsec1 key.", ); return; @@ -244,8 +244,8 @@ export function NostrKeyImportForm({
{/* Hidden file input shared by both variants: the default drop zone and - the spotlight "Import from a file" button both open it. Accepts the - .ncryptsec archives our own save flow emits alongside raw .key files. */} + the spotlight "Choose a backup file" button both open it. Accepts the + .ncryptsec backups our own save flow emits alongside raw .key files. */} - Import from a file + Choose a backup file
) : ( @@ -358,7 +358,7 @@ export function NostrKeyImportForm({ className="text-sm font-medium text-foreground" htmlFor="nostr-import-passphrase" > - Backup passphrase + Backup password +

+ Your backup file and password stay on this device. +

) : null} @@ -393,7 +396,8 @@ export function NostrKeyImportForm({ data-testid="nostr-import-encrypted-badge" >
+ ); +} + +/** + * Sibling settings tools for the password-protected key backup: create a new + * backup, or test any existing backup file. The raw private key never reaches + * either flow — the password goes to Rust, which returns only the encrypted + * NIP-49 blob (create) or the derived public identity (test). + */ +export function EncryptedBackupRow() { + const [createOpen, setCreateOpen] = React.useState(false); + const [testOpen, setTestOpen] = React.useState(false); + const [progress, setProgress] = React.useState(initialBackupTestProgress); + return ( + <> + setCreateOpen((open) => !open)} + open={createOpen} + testId="profile-encrypted-backup-row" + title="Create a key backup" + > + +

+ Keep the file private and save its password somewhere safe — Buzz + cannot reset it. Creating another backup does not invalidate copies + you saved before. +

+
+ setTestOpen((open) => !open)} + open={testOpen} + testId="profile-backup-test-row" + title="Test a key backup" + > + +

+ Backups use the standard NIP-49 format, so this works for backups from + compatible Nostr apps too. +

+
+ + ); +} diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 5326243ff4..d82100026d 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -13,7 +13,6 @@ import { useUpdateProfileMutation, } from "@/features/profile/hooks"; import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay"; -import { EncryptedBackupCreator } from "@/features/onboarding/ui/EncryptedBackupCreator"; import { getNsec } from "@/shared/api/tauriIdentity"; import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -25,6 +24,7 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; import { Textarea } from "@/shared/ui/textarea"; +import { EncryptedBackupRow } from "./EncryptedBackupRow"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SignOutSection } from "./SignOutSection"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; @@ -177,42 +177,6 @@ function NsecRevealRow() { ); } -/** - * Collapsible row for creating an encrypted NIP-49 backup on demand. The raw - * private key never reaches this flow — the passphrase goes to Rust, which - * returns the persisted `ncryptsec1…` blob. - */ -function EncryptedBackupRow() { - const [isOpen, setIsOpen] = React.useState(false); - - return ( -
-
-
-

Password Backup

-

- Protect your key with a password and save a recoverable backup. -

-
- -
- {isOpen ? ( -
- -
- ) : null} -
- ); -} - function EditProfileMetadataButton({ label, testId, diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx index 8d4dc1c481..55479ec5d9 100644 --- a/desktop/src/features/settings/ui/SignOutSection.tsx +++ b/desktop/src/features/settings/ui/SignOutSection.tsx @@ -31,9 +31,9 @@ export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data"; * Signing out wipes the identity key and all local data, so the confirm * dialog gates the delete button behind two explicit steps: * - * 1. Back up the key — the nsec is shown inline (masked, with reveal/copy); - * the "I have saved my private key" checkbox unlocks only after the user - * actually reveals or copies the key. + * 1. Confirm recovery — Settings offers a tested password-protected backup; + * the dialog also shows the raw nsec as a last-chance fallback, and the + * user checks a box confirming they can restore their identity. * 2. Typed confirmation — the user must type the exact phrase * "wipe all my data". * @@ -47,7 +47,6 @@ export function SignOutSection() { const [nsec, setNsec] = React.useState(null); const [nsecError, setNsecError] = React.useState(null); const [isNsecLoading, setIsNsecLoading] = React.useState(false); - const [hasInteractedWithKey, setHasInteractedWithKey] = React.useState(false); const [hasConfirmedBackup, setHasConfirmedBackup] = React.useState(false); // Guards against a late-resolving getNsec() repopulating state after the // dialog closes. @@ -58,20 +57,13 @@ export function SignOutSection() { const isPhraseConfirmed = confirmText.trim().toLowerCase() === SIGNOUT_CONFIRM_PHRASE; - // The backup checkbox unlocks after real interaction with the key - // (reveal or copy). If the key cannot be loaded at all there is nothing to - // interact with — let the user proceed past the backup step rather than - // locking them out of sign-out entirely. - const isBackupGateSatisfied = hasConfirmedBackup; - const canConfirmBackup = hasInteractedWithKey || nsecError !== null; - const canDelete = isBackupGateSatisfied && isPhraseConfirmed && !isPending; + const canDelete = hasConfirmedBackup && isPhraseConfirmed && !isPending; function resetDialogState() { fetchCancelledRef.current = true; setNsec(null); setNsecError(null); setIsNsecLoading(false); - setHasInteractedWithKey(false); setHasConfirmedBackup(false); setConfirmText(""); } @@ -137,7 +129,8 @@ export function SignOutSection() {

Sign out

Removes your identity key and all local app data from this device. - Back up your private key (nsec) first — this cannot be undone. + Before signing out, create and test a password-protected key backup + above — this cannot be undone.