diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs
index bb3ef796b..344e51965 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 000000000..0b31f44ba
--- /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 cc8cd483c..65448405e 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 6db9eaa38..418be3bb0 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 2f92343f2..2367de80e 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 c75daafea..661295fa0 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 bde145822..71bd382db 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 f118bbf45..1d98a9854 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 ready = reduce([
- {
- type: "passphrase-generated",
- passphrase: "alpha bravo carbon delta echo fox",
- },
- ]);
- assert.equal(createDisabled(ready), false);
- assert.equal(effectivePassphrase(ready), "alpha bravo carbon delta echo fox");
-});
-
-test("regenerate_replaces_passphrase_and_clears_generate_error", () => {
- const failed = reduce([
- { type: "passphrase-generate-failed", message: "boom" },
- ]);
- 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");
-});
-
-// ── 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" },
- ]);
-
- // 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);
-});
-
-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("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 },
- ]);
- assert.equal(effectivePassphrase(ready), emoji);
-});
-
-// ── create lifecycle ─────────────────────────────────────────────────────────
-
-test("create_lifecycle_happy_path_and_failure", () => {
- const ready = reduce([
- { type: "passphrase-generated", passphrase: "one two three four five six" },
- ]);
-
- const creating = reduce([{ type: "create-started" }], ready);
- assert.equal(creating.isCreating, 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(
- createDisabled(creating),
- true,
- "no double-create while KDF runs",
+ effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])),
+ emoji,
+ );
+});
+test("valid password requests encryption without copying it into events", () => {
+ const ready = reduce([
+ { type: "set-passphrase", value: "one-two-three-four" },
+ ]);
+ 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("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(state.passphrase, "");
+ assert.equal(state.encrypted, "ncryptsec1abc");
+ assert.equal(state.savedPassword, true);
+ assert.equal(state.requestId, null);
+});
+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" },
+ ]);
+ assert.equal(state.requestId, 2);
+ assert.equal(state.encrypted, null);
+ assert.equal(state.passphrase, "five-six-seven-eight");
+});
+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("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(state.ncryptsec, "ncryptsec1abc");
+ assert.equal(state.passphrase, "");
+ assert.equal(state.savedPassword, true);
+});
+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" },
+ ]);
+ 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(
+ reduce(
+ [
+ {
+ type: "encrypt-succeeded",
+ requestId: 2,
+ ncryptsec: "ncryptsec1stale",
+ },
+ ],
+ fresh,
+ ).ncryptsec,
+ null,
);
-
- 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,
- );
- 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 c8f790591..d2aad9442 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,
- generatedPassphrase: event.passphrase,
- generateError: null,
+ 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,
+ 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 4b7b17649..1d1b1d0bc 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";
-
-export type BackupStepMode = "encrypted" | "raw";
+import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
/**
- * 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.
+ * 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.
*/
-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;
+const INTRO_HOLD_MS = 1400;
+
+/**
+ * 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.
+ */
+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
-
-
- {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."}
-
+ {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);
+ }}
+ >
+
+
+ Drop your backup file here
+
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {onSaveCopy ? (
+
+
+ {savedPath ? (
+
+ Saved to {savedPath}
+
+ ) : null}
+
+ ) : null}
+ {saveError ? (
+
{saveError}
+ ) : null}
+ >
+ ) : (
+ <>
+
+
+
+ {fileName}
+
+
+
+
+ That's the one. Now enter your password to prove you can unlock it.
+
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 ced5e9e44..b080f6808 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";
-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;
-};
+/** 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;
/**
- * Passphrase-first NIP-49 backup creation flow, shared by the onboarding
- * BackupStep and the settings Password Backup row.
- *
- * 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.
+ * Pause after the last keystroke before the background KDF starts, so typing
+ * past the minimum length doesn't launch an encryption per character.
*/
-export function EncryptedBackupCreator({
- variant = "spotlight",
- onCreated,
-}: EncryptedBackupCreatorProps) {
+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 [saveError, setSaveError] = React.useState(null);
- const [isSaving, setIsSaving] = React.useState(false);
- const mountedRef = React.useRef(true);
+ 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],
+ );
+}
- 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.",
- });
- }
- }, []);
+/**
+ * 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";
+ /**
+ * 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;
+};
+
+/**
+ * 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;
- 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" });
+ const generate = React.useCallback(async (wordCount: number, sep: string) => {
+ setError(null);
try {
- const ncryptsec = await createNcryptsecBackup(passphrase);
- if (!mountedRef.current) return;
- dispatch({ type: "create-succeeded", ncryptsec });
- onCreated?.(ncryptsec);
+ const passphrase = await generateBackupPassphrase({
+ words: wordCount,
+ separator: sep,
+ });
+ if (mountedRef.current) onGeneratedRef.current(passphrase);
} catch (err) {
- if (mountedRef.current)
- dispatch({
- type: "create-failed",
- message:
- err instanceof Error ? err.message : "Failed to create backup.",
- });
+ if (!mountedRef.current) return;
+ setError(
+ err instanceof Error ? err.message : "Failed to generate a password.",
+ );
}
- }, [onCreated, state]);
+ }, []);
+
+ // 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()}
+ >
+
- 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}
-
-
+ );
+ // `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 a93d75f2e..2576f71e1 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 dfa68e7d5..900b7b932 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
+ Your backup file and password stay on this device.
+
) : null}
@@ -393,7 +396,8 @@ export function NostrKeyImportForm({
data-testid="nostr-import-encrypted-badge"
>
- Encrypted key backup — enter its passphrase to import
+ Password-protected key backup · Private — enter its password to
+ restore
) : previewNpub ? (
variant === "spotlight" ? (
diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
index df4536ef1..2ab06799a 100644
--- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
+++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
@@ -10,14 +10,9 @@ type NsecMaskedDisplayProps = {
/**
* What kind of secret is displayed. Drives labels, aria and testids:
* a raw private key ("nsec", default) can impersonate its holder; an
- * encrypted backup ("ncryptsec") is only as sensitive as its passphrase.
+ * encrypted key ("ncryptsec") is only as sensitive as its passphrase.
*/
kind?: "nsec" | "ncryptsec";
- /**
- * Called when the user reveals or copies the key. Lets flows that require
- * a backup (e.g. sign-out) gate on actual interaction with the key.
- */
- onKeyInteraction?: () => void;
};
const KIND_LABELS = {
@@ -26,7 +21,7 @@ const KIND_LABELS = {
testIdPrefix: "nsec",
},
ncryptsec: {
- noun: "encrypted backup",
+ noun: "encrypted key",
testIdPrefix: "ncryptsec",
},
} as const;
@@ -48,7 +43,6 @@ export function NsecMaskedDisplay({
nsec,
variant = "boxed",
kind = "nsec",
- onKeyInteraction,
}: NsecMaskedDisplayProps) {
const labels = KIND_LABELS[kind];
const [isRevealed, setIsRevealed] = React.useState(false);
@@ -65,13 +59,11 @@ export function NsecMaskedDisplay({
}, []);
function handleRevealToggle() {
- if (!isRevealed) onKeyInteraction?.();
setIsRevealed((prev) => !prev);
}
async function handleCopy() {
await writeTextToClipboard(nsec);
- onKeyInteraction?.();
setIsCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
@@ -113,7 +105,9 @@ export function NsecMaskedDisplay({
- actions.next(readyRuntimeIds)}
- type="button"
- >
- Next
-
-
- actions.next([])}
- type="button"
- variant="ghost"
- >
- Skip for now
-
+ {/* Relative row keeps the primary CTA truly centered while Skip
+ hangs off its right edge without shifting the center. */}
+
+ actions.next(readyRuntimeIds)}
+ type="button"
+ >
+ Next
+
+ actions.next([])}
+ type="button"
+ variant="ghost"
+ >
+ Skip for now
+
+
{
});
// ---------------------------------------------------------------------------
-// BackupStep gating: backupNextDisabled() pure helper
+// BackupStep gating: saving a password-protected backup is recommended, not required
// ---------------------------------------------------------------------------
-test("backup_next_disabled_in_encrypted_mode_until_backup_exists", () => {
- // Encrypted (default) mode: the user must create a backup — or explicitly
- // switch to the raw key — before Next unlocks. Loading/error state belongs
- // to the raw path and must not leak into the encrypted gate.
- assert.equal(
- backupNextDisabled({
- mode: "encrypted",
- hasBackup: false,
- isLoading: false,
- loadError: null,
- }),
- true,
- );
-});
-
-test("backup_next_enabled_in_encrypted_mode_once_backup_created", () => {
- assert.equal(
- backupNextDisabled({
- mode: "encrypted",
- hasBackup: true,
- isLoading: false,
- loadError: null,
- }),
- false,
- );
-});
-
-test("backup_next_disabled_while_loading_raw_key", () => {
- // During a slow keychain read, Next must be blocked — user cannot race past
- // the key display before it is shown.
- assert.equal(
- backupNextDisabled({
- mode: "raw",
- hasBackup: false,
- isLoading: true,
- loadError: null,
- }),
- true,
- );
-});
-
-test("backup_next_disabled_on_raw_load_error", () => {
- // Error state: only the explicit "Skip for now" ghost advances; Next blocked.
- assert.equal(
- backupNextDisabled({
- mode: "raw",
- hasBackup: false,
- isLoading: false,
- loadError: "IPC error",
- }),
- true,
- );
-});
-
-test("backup_next_enabled_after_clean_raw_load", () => {
- // Key shown (or backend cleanly returned none) — user may proceed.
- assert.equal(
- backupNextDisabled({
- mode: "raw",
- hasBackup: false,
- isLoading: false,
- loadError: null,
- }),
- false,
- );
+test("backup_next_is_always_enabled", () => {
+ assert.equal(backupNextDisabled(), false);
});
// ---------------------------------------------------------------------------
diff --git a/desktop/src/features/settings/ui/EncryptedBackupRow.tsx b/desktop/src/features/settings/ui/EncryptedBackupRow.tsx
new file mode 100644
index 000000000..ec3fb970d
--- /dev/null
+++ b/desktop/src/features/settings/ui/EncryptedBackupRow.tsx
@@ -0,0 +1,101 @@
+import * as React from "react";
+
+import {
+ BackupTestFlow,
+ initialBackupTestProgress,
+} from "@/features/onboarding/ui/BackupTestFlow";
+import { EncryptedBackupCreator } from "@/features/onboarding/ui/EncryptedBackupCreator";
+
+/**
+ * Collapsible settings row shared by the two backup tools. `relative`
+ * anchors the backup-test drop overlay (BackupTestFlow) so a file drag takes
+ * over the whole row, mirroring the composer treatment.
+ */
+function ToolRow({
+ title,
+ description,
+ action,
+ open,
+ onToggle,
+ children,
+ testId,
+}: {
+ title: string;
+ description: string;
+ action: string;
+ open: boolean;
+ onToggle: () => void;
+ children: React.ReactNode;
+ testId: string;
+}) {
+ return (
+
+
+
+
{title}
+
{description}
+
+
+ {open ? "Close" : action}
+
+
+ {open ?
{children}
: null}
+
+ );
+}
+
+/**
+ * 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.
+
- );
-}
-
function EditProfileMetadataButton({
label,
testId,
diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx
index 8d4dc1c48..55479ec5d 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.
- 1. Back up your private key (nsec)
+ 1. Confirm you can restore your identity
{isNsecLoading ? (
Loading…
@@ -187,10 +180,7 @@ export function SignOutSection() {
{nsecError}
) : nsec ? (
- setHasInteractedWithKey(true)}
- />
+
) : null}
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts
index 1d961aa16..57224a238 100644
--- a/desktop/src/shared/api/tauriIdentity.ts
+++ b/desktop/src/shared/api/tauriIdentity.ts
@@ -40,9 +40,21 @@ export async function importIdentity(
);
}
-/** Generate a 6-word passphrase (EFF short wordlist, OS entropy) in Rust. */
-export async function generateBackupPassphrase(): Promise {
- return invokeTauri("generate_backup_passphrase");
+export type GeneratePassphraseOptions = {
+ /** Word count; Rust clamps to its allowed range (currently 4–10). */
+ words?: number;
+ /** Separator joined between words. Defaults to a space in Rust. */
+ separator?: string;
+};
+
+/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */
+export async function generateBackupPassphrase(
+ options?: GeneratePassphraseOptions,
+): Promise {
+ return invokeTauri("generate_backup_passphrase", {
+ words: options?.words,
+ separator: options?.separator,
+ });
}
/**
@@ -83,3 +95,20 @@ export async function persistCurrentIdentity(): Promise {
export async function signOut(): Promise {
await invokeTauri("sign_out");
}
+
+export type BackupVerification = {
+ pubkey: string;
+ npub: string;
+ matchesCurrentIdentity: boolean;
+};
+
+/** Decrypt a NIP-49 backup in Rust and return only its public identity. */
+export async function verifyNcryptsecBackup(
+ ncryptsec: string,
+ password: string,
+): Promise {
+ return invokeTauri("verify_ncryptsec_backup", {
+ ncryptsec,
+ password,
+ });
+}
diff --git a/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
index a30baa6a0..a64bbea7d 100644
--- a/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
+++ b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
@@ -26,6 +26,7 @@ const ALLOWLIST = [
"features/onboarding/lib/keyImportInput.ts",
"features/onboarding/lib/keyImportInput.test.mjs",
"features/onboarding/ui/BackupStep.tsx",
+ "features/onboarding/ui/BackupTestFlow.tsx",
"features/onboarding/ui/EncryptedBackupCreator.tsx",
"features/onboarding/ui/NostrKeyImportForm.tsx",
"features/onboarding/ui/NsecMaskedDisplay.tsx",
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 741e57573..ff241fc7e 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -7167,6 +7167,21 @@ const MOCK_NCRYPTSEC =
// The single passphrase the mocked backup commands accept/emit.
const MOCK_BACKUP_PASSPHRASE = "mock horse battery staple lake orbit";
+// Fixed word pool for the mocked passphrase generator (deterministic —
+// specs assert word count and separator, never entropy).
+const MOCK_PASSPHRASE_WORDS = [
+ "mock",
+ "horse",
+ "battery",
+ "staple",
+ "lake",
+ "orbit",
+ "cedar",
+ "plume",
+ "raven",
+ "tundra",
+];
+
// Per-page confirm_team_snapshot_import call counter for sequenced error testing.
let teamSnapshotConfirmCallCount = 0;
@@ -9589,11 +9604,19 @@ export function maybeInstallE2eTauriMocks() {
}
return "nsec1mock000000000000000000000000000000000000000000000000000000";
}
- case "generate_backup_passphrase":
- // Deterministic mock: production generates 6 EFF short-wordlist words
- // from OS entropy in Rust. Specs only assert display/flow, never
- // entropy quality.
- return MOCK_BACKUP_PASSPHRASE;
+ case "generate_backup_passphrase": {
+ // Deterministic mock: production draws EFF short-wordlist words from
+ // OS entropy in Rust. The mock honors the generator popover's word
+ // count and separator (clamped like Rust) so specs can assert the
+ // controls, but never entropy quality.
+ const request = payload as {
+ words?: number;
+ separator?: string;
+ } | null;
+ const wordCount = Math.min(Math.max(request?.words ?? 3, 3), 10);
+ const separator = request?.separator ?? " ";
+ return MOCK_PASSPHRASE_WORDS.slice(0, wordCount).join(separator);
+ }
case "create_ncryptsec_backup": {
// Production encrypts the live key under the passphrase, persists
// `identity.ncryptsec`, and returns the exact persisted blob. The
@@ -9605,10 +9628,29 @@ export function maybeInstallE2eTauriMocks() {
}
return MOCK_NCRYPTSEC;
}
+ case "verify_ncryptsec_backup": {
+ const request = payload as {
+ ncryptsec?: string;
+ password?: string;
+ } | null;
+ if (
+ request?.password !== MOCK_BACKUP_PASSPHRASE ||
+ !request.ncryptsec?.startsWith("ncryptsec1")
+ )
+ throw new Error("Wrong backup password or damaged key backup.");
+ const different = request.ncryptsec !== MOCK_NCRYPTSEC;
+ return {
+ pubkey: different ? "f".repeat(64) : DEFAULT_MOCK_IDENTITY.pubkey,
+ npub: different
+ ? "npub1differentmockidentity000000000000000000000000000000000"
+ : "npub1mockcurrentidentity0000000000000000000000000000000000",
+ matchesCurrentIdentity: !different,
+ };
+ }
case "save_ncryptsec_copy": {
const blob = (payload as { ncryptsec?: string } | null)?.ncryptsec;
if (!blob?.startsWith("ncryptsec1")) {
- throw new Error("Not a valid encrypted key backup.");
+ throw new Error("Not a valid key backup.");
}
// Production opens a native save dialog; the harness pretends the
// user picked a path.
@@ -9644,7 +9686,7 @@ export function maybeInstallE2eTauriMocks() {
input.trim() !== MOCK_NCRYPTSEC ||
request?.password !== MOCK_BACKUP_PASSPHRASE
) {
- throw new Error("Wrong passphrase or corrupted backup.");
+ throw new Error("Wrong backup password or damaged key backup.");
}
mockIdentityLostCleared = true;
mockIdentityLockedCleared = true;
diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts
index 167e274bf..dff9b012b 100644
--- a/desktop/tests/e2e/onboarding-backup.spec.ts
+++ b/desktop/tests/e2e/onboarding-backup.spec.ts
@@ -1,6 +1,11 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
+import {
+ dropFileOnTestId,
+ endWindowFileDrag,
+ startWindowFileDrag,
+} from "../helpers/fileDrag";
async function enterMachineBackup(page: import("@playwright/test").Page) {
await installMockBridge(page, undefined, {
@@ -11,125 +16,298 @@ async function enterMachineBackup(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Create a new identity key" }).click();
}
+async function invokedCommands(page: import("@playwright/test").Page) {
+ return page.evaluate(
+ () =>
+ (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
+ .__BUZZ_E2E_COMMANDS__ ?? [],
+ );
+}
+
const SHOTS = "test-results/screenshots-onboarding";
+// Mirrors the mock bridge's MOCK_NCRYPTSEC (e2eBridge.ts): the blob the
+// mocked `create_ncryptsec_backup` returns, i.e. the "downloaded file"
+// contents the test-your-backup dropzone expects.
+const MOCK_NCRYPTSEC =
+ "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
+
test("backup step appears on fresh-key path after profile submit", async ({
page,
}) => {
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
+
+ // Perceived-loading intro: the animated logo and "Creating" title show
+ // first, then the finished state replaces them after the hold.
+ await expect(
+ page.getByRole("heading", { name: "Creating your identity key" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("backup-intro-logo")).toBeVisible();
+
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
+ await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
});
// ---------------------------------------------------------------------------
-// Encrypted-by-default path (plan D3): passphrase → create → ncryptsec shown.
-// The raw key must never be fetched on this path.
+// Chooser: masked key with reveal toggle and inline copy. The raw key is
+// fetched only on explicit reveal/copy, and Next is never blocked.
// ---------------------------------------------------------------------------
-test("encrypted backup happy path: generated passphrase, create, save copy, Next", async ({
+test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
+ page,
+}) => {
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
+ await enterMachineBackup(page);
+
+ await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
+
+ // Masked by default: decorative mask only, no key material in the DOM.
+ const key = page.getByTestId("backup-key-value");
+ await expect(key).toBeVisible();
+ await expect(key).toHaveClass(/blur/);
+ await expect(key).not.toContainText("nsec1");
+ expect(await invokedCommands(page)).not.toContain("get_nsec");
+
+ // Reveal fetches the key; box must not reflow (same-length monospace mask).
+ await page.getByTestId("backup-key-reveal-toggle").click();
+ await expect(key).toContainText("nsec1mock");
+ await expect(key).toHaveClass(/select-text/);
+
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` });
+
+ // Hide again.
+ await page.getByTestId("backup-key-reveal-toggle").click();
+ await expect(key).not.toContainText("nsec1");
+
+ // Inline copy goes straight to the clipboard.
+ await page.getByTestId("backup-copy-key").click();
+ await expect
+ .poll(async () => invokedCommands(page))
+ .toContain("copy_text_to_clipboard");
+ expect(await invokedCommands(page)).toContain("get_nsec");
+
+ // Next leads into the download step, where backup stays skippable.
+ await expect(page.getByTestId("onboarding-next")).toBeEnabled();
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+ await page.getByTestId("onboarding-skip").click();
+ await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
+});
+
+// ---------------------------------------------------------------------------
+// Encrypted download path ("Backup your key" step): password → encrypt
+// locally → native save → saved confirmation. The raw key must never be
+// fetched on this path.
+// ---------------------------------------------------------------------------
+
+test("download happy path: generated password, encrypt, native save, Next", async ({
page,
}) => {
await enterMachineBackup(page);
- // Default mode: generated passphrase shown, Next locked until backup exists.
- await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
- await expect(page.getByTestId("onboarding-next")).toBeDisabled();
+ // The download flow is its own onboarding step behind the footer's Next.
+ await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+
+ // The password field starts empty; the create button sits in the footer's
+ // primary slot and stays disabled until a valid password exists.
+ const input = page.getByTestId("backup-passphrase-input");
+ await expect(input).toHaveValue("");
+ await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
+ await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-skip")).toBeVisible();
+
+ // The inset refresh icon opens the generator popover and immediately
+ // fills the field (mock default: 3 words, spaces).
+ await page.getByTestId("backup-passphrase-generate").click();
+ await expect(input).toHaveValue("mock horse battery");
+
+ // Popover controls regenerate in place: word count (slider) and separator.
+ await page.getByTestId("backup-passphrase-words").focus();
+ await page.keyboard.press("ArrowRight");
+ await expect(input).toHaveValue("mock horse battery staple");
+ await page
+ .getByTestId("backup-passphrase-separator")
+ .selectOption({ label: "Hyphens" });
+ await expect(input).toHaveValue("mock-horse-battery-staple");
+
+ // Clicking the inset icon again re-rolls without closing the popover.
+ await page.getByTestId("backup-passphrase-generate").click();
+ await expect(page.getByTestId("backup-passphrase-separator")).toBeVisible();
await waitForAnimations(page);
- await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` });
+ await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
+ // Encryption may finish while the generator popover is still open. Once it
+ // does, the real password is cleared and only the fixed saved-password mask
+ // remains over an empty read-only input.
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("backup-passphrase-separator")).toHaveCount(0);
+ await expect(input).toHaveValue("");
+ await expect(input).toHaveAttribute("readonly", "");
+ await expect(page.getByTestId("backup-saved-password-mask")).toBeVisible();
+
+ // Saving uses the retained encrypted blob; it does not re-encrypt or need
+ // the cleared password.
await page.getByTestId("encrypted-backup-create").click();
- // The persisted blob is displayed masked; copy + save-a-copy available.
- const blob = page.getByTestId("ncryptsec-value");
- await expect(blob).toBeVisible();
- await expect(blob).toHaveCSS("filter", /blur/);
- await page.getByTestId("ncryptsec-reveal-toggle").click();
- await expect(blob).toContainText("ncryptsec1");
-
- await waitForAnimations(page);
- await page.screenshot({ path: `${SHOTS}/03-backup-step-encrypted.png` });
-
- await page.getByTestId("encrypted-backup-save-copy").click();
+ // Only a successful save (the mock "picks" a path) advances to the
+ // "Now, test your backup" flow: a select-file button for the saved file
+ // (a composer-style drop overlay takes over the card while a file drag is
+ // over the window), then the password to unlock it.
+ await expect(
+ page.getByRole("heading", { name: "Now, test your backup" }),
+ ).toBeVisible();
+ const dropzone = page.getByTestId("backup-test-dropzone");
+ await expect(dropzone).toBeVisible();
await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText(
"identity.ncryptsec",
);
- // The default path must never have fetched the raw key.
- const commands = await page.evaluate(
- () =>
- (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
- .__BUZZ_E2E_COMMANDS__ ?? [],
- );
+ // Until the test passes there is no Next at all — Skip is the only way
+ // forward.
+ await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-skip")).toBeVisible();
+
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` });
+
+ // A wrong file is rejected with an inline error; the dropzone stays.
+ await page.getByTestId("backup-test-file-input").setInputFiles({
+ name: "notes.txt",
+ mimeType: "text/plain",
+ buffer: Buffer.from("not a key backup"),
+ });
+ await expect(page.getByTestId("backup-test-error")).toBeVisible();
+
+ // A file drag over the window swaps in the drop overlay; leaving without
+ // dropping restores the select button.
+ const dropOverlay = page.getByTestId("backup-test-drop-overlay");
+ await startWindowFileDrag(page);
+ await expect(dropOverlay).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/04b-backup-test-drop-overlay.png` });
+ await endWindowFileDrag(page);
+ await expect(dropOverlay).toHaveCount(0);
+
+ // Dropping the freshly downloaded file on the overlay advances to the
+ // password check.
+ await startWindowFileDrag(page);
+ await expect(dropOverlay).toBeVisible();
+ await dropFileOnTestId(page, "backup-test-drop-overlay", MOCK_NCRYPTSEC);
+ const password = page.getByTestId("backup-test-password");
+ await expect(password).toBeVisible();
+ await expect(dropOverlay).toHaveCount(0);
+
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/05-backup-test-password.png` });
+
+ // Verification is explicit and clears every submitted attempt.
+ await password.fill("mock-horse-battery-staplX");
+ await page.getByTestId("backup-test-verify").click();
+ await expect(page.getByTestId("backup-test-error")).toBeVisible();
+ await expect(password).toHaveValue("");
+
+ await password.fill("mock horse battery staple lake orbit");
+ await page.getByTestId("backup-test-verify").click();
+ await expect(page.getByTestId("backup-test-success")).toBeVisible();
+
+ // The celebration is driven by motion's rAF loop, which
+ // `waitForAnimations` (WAAPI-only) cannot observe — hold until the badge
+ // and copy have faded in before capturing.
+ await page.waitForTimeout(1200);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
+
+ // The download path must never have fetched the raw key.
+ const commands = await invokedCommands(page);
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
+ // A passed test unlocks Next and retires the Skip escape hatch.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
+ await expect(page.getByTestId("onboarding-skip")).toHaveCount(0);
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
-test("custom passphrase requires 12 characters and confirmation", async ({
+test("download step Back returns to the backup chooser", async ({ page }) => {
+ await enterMachineBackup(page);
+
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+ await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
+ // The chooser's footer Next belongs to the previous step; the download
+ // step only mounts its own Next once the backup exists.
+ await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
+
+ await page.getByTestId("onboarding-back").click();
+ await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
+ await expect(page.getByTestId("backup-key-value")).toBeVisible();
+ await expect(page.getByTestId("onboarding-next")).toBeVisible();
+});
+
+test("test-view Back returns to a secure saved-password placeholder", async ({
page,
}) => {
await enterMachineBackup(page);
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+
+ const input = page.getByTestId("backup-passphrase-input");
+ await input.fill("mock-horse-battery-staple");
+ await page.getByTestId("encrypted-backup-create").click();
+ await expect(
+ page.getByRole("heading", { name: "Now, test your backup" }),
+ ).toBeVisible();
+
+ // Back retains only the encrypted blob and renders a fixed visual mask over
+ // an empty readonly input.
+ await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Backup your key with a password" }),
+ ).toBeVisible();
+ await expect(input).toHaveValue("");
+ await expect(input).toHaveAttribute("readonly", "");
+ await expect(page.getByTestId("backup-saved-password-mask")).toBeVisible();
+
+ // Re-downloading needs no password or re-encryption.
+ await page.getByTestId("encrypted-backup-create").click();
+ await expect(
+ page.getByRole("heading", { name: "Now, test your backup" }),
+ ).toBeVisible();
+
+ await page.getByTestId("onboarding-back").click();
+ await page.getByTestId("onboarding-back").click();
+ await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
+});
+
+test("typed password requires 12 characters", async ({ page }) => {
+ await enterMachineBackup(page);
+ await page.getByTestId("onboarding-next").click();
- await page.getByTestId("backup-passphrase-choose-own").click();
const create = page.getByTestId("encrypted-backup-create");
+ await expect(create).toBeDisabled(); // empty field
- await page.getByTestId("backup-passphrase-custom").fill("short");
+ await page.getByTestId("backup-passphrase-input").fill("short");
await expect(page.getByTestId("backup-passphrase-issue")).toBeVisible();
await expect(create).toBeDisabled();
await page
- .getByTestId("backup-passphrase-custom")
- .fill("a much longer passphrase");
- await expect(create).toBeDisabled(); // confirm still empty
-
- await page
- .getByTestId("backup-passphrase-confirm")
+ .getByTestId("backup-passphrase-input")
.fill("a much longer passphrase");
+ await expect(page.getByTestId("backup-passphrase-issue")).toHaveCount(0);
await expect(create).toBeEnabled();
});
-// ---------------------------------------------------------------------------
-// Raw-key path: preserved behind an explicit "Show raw key instead" click.
-// ---------------------------------------------------------------------------
-
-test("raw key path is one explicit click away and shows the masked nsec", async ({
- page,
-}) => {
- await enterMachineBackup(page);
-
- await page.getByTestId("backup-show-raw-key").click();
-
- const nsecDisplay = page.getByTestId("nsec-value");
- await expect(nsecDisplay).toBeVisible();
-
- // Should start masked (blurred) — reveal button exists and eye icon visible.
- const revealBtn = page.getByTestId("nsec-reveal-toggle");
- await expect(revealBtn).toBeVisible();
- await expect(nsecDisplay).toHaveCSS("filter", /blur/);
-
- // Reveal and verify the mock nsec appears.
- await revealBtn.click();
- await expect(nsecDisplay).not.toHaveCSS("filter", /blur/);
- await expect(nsecDisplay).toContainText("nsec1mock");
-
- await waitForAnimations(page);
- await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` });
-
- // Raw mode keeps the previous gating: key shown → Next enabled.
- await expect(page.getByTestId("onboarding-next")).toBeEnabled();
- await page.getByTestId("onboarding-next").click();
- await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
-});
-
test("backup step back button returns to machine identity choice", async ({
page,
}) => {
@@ -149,10 +327,10 @@ test("backup step back button returns to machine identity choice", async ({
});
// ---------------------------------------------------------------------------
-// B4: Error path coverage (raw path)
+// B4: Error path coverage (reveal/copy)
// ---------------------------------------------------------------------------
-test("raw path shows error banner and retry button when get_nsec fails", async ({
+test("reveal shows inline error when get_nsec fails and Next still advances", async ({
page,
}) => {
await installMockBridge(
@@ -164,22 +342,19 @@ test("raw path shows error banner and retry button when get_nsec fails", async (
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
- await page.getByTestId("backup-show-raw-key").click();
+ await page.getByTestId("backup-key-reveal-toggle").click();
- await expect(page.getByTestId("backup-load-error")).toBeVisible();
- await expect(page.getByTestId("backup-retry")).toBeVisible();
- // Next is blocked on error; Skip for now ghost is shown instead.
- await expect(page.getByTestId("onboarding-next")).toBeDisabled();
- await expect(page.getByTestId("backup-skip")).toBeVisible();
-
- // Skip for now still advances to machine setup.
- await page.getByTestId("backup-skip").click();
+ await expect(page.getByTestId("backup-copy-error")).toBeVisible();
+ // Keychain failure does not trap the user: Next still advances into the
+ // download step, and Skip there continues to setup.
+ await expect(page.getByTestId("onboarding-next")).toBeEnabled();
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+ await page.getByTestId("onboarding-skip").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
-test("raw path retry succeeds and shows key after initial failure", async ({
- page,
-}) => {
+test("reveal retry succeeds after initial failure", async ({ page }) => {
// First call fails, second succeeds (sequenced via nsecErrors).
await installMockBridge(
page,
@@ -188,12 +363,12 @@ test("raw path retry succeeds and shows key after initial failure", async ({
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
- await page.getByTestId("backup-show-raw-key").click();
- await expect(page.getByTestId("backup-load-error")).toBeVisible();
+ await page.getByTestId("backup-key-reveal-toggle").click();
+ await expect(page.getByTestId("backup-copy-error")).toBeVisible();
- // Retry — second call succeeds.
- await page.getByTestId("backup-retry").click();
- await expect(page.getByTestId("nsec-value")).toBeVisible();
- await expect(page.getByTestId("backup-load-error")).not.toBeVisible();
+ // Retry — second call succeeds and clears the error.
+ await page.getByTestId("backup-key-reveal-toggle").click();
+ await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock");
+ await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
});
diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
index ee6bd3fee..13aaf6e85 100644
--- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
+++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
@@ -46,7 +46,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` });
- await page.getByRole("button", { name: "Back" }).click();
+ await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(
page.getByRole("button", { name: "Create a new identity key" }),
).toBeVisible();
@@ -59,17 +59,19 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
- // Encrypted-by-default backup: the raw key sits behind an explicit click.
- await page.getByTestId("backup-show-raw-key").click();
- await expect(page.getByTestId("nsec-value")).toBeVisible();
+ // The key stays masked behind an explicit reveal toggle.
+ await expect(page.getByTestId("backup-key-value")).toBeVisible();
// Reveal the key: box must not reflow (same-length monospace mask).
- await page.getByTestId("nsec-reveal-toggle").click();
- await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/);
+ await page.getByTestId("backup-key-reveal-toggle").click();
+ await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
+ // Next leads into the download step; Skip there continues to setup.
await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+ await page.getByTestId("onboarding-skip").click();
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
).toBeVisible();
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index d82765496..de6aa0dda 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -645,7 +645,7 @@ test("first-launch encrypted backup import asks for a passphrase and continues",
await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("nostr-import-feedback")).toContainText(
- /wrong passphrase/i,
+ /wrong backup password/i,
);
await page
@@ -679,6 +679,15 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
".key,.ncryptsec,text/plain",
);
+ await fileInput.setInputFiles({
+ buffer: Buffer.alloc(1_025, "x"),
+ mimeType: "text/plain",
+ name: "not-a-backup.txt",
+ });
+ await expect(page.getByTestId("nostr-import-feedback")).toContainText(
+ /too large to be a key backup/i,
+ );
+
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
diff --git a/desktop/tests/e2e/profile-nsec-reveal.spec.ts b/desktop/tests/e2e/profile-nsec-reveal.spec.ts
index 47e4c8a2d..a91d4c574 100644
--- a/desktop/tests/e2e/profile-nsec-reveal.spec.ts
+++ b/desktop/tests/e2e/profile-nsec-reveal.spec.ts
@@ -63,3 +63,35 @@ test("reveal shows error when get_nsec fails", async ({ page }) => {
"Keychain locked",
);
});
+
+test("settings separates backup creation from general NIP-49 testing", async ({
+ page,
+}) => {
+ await installMockBridge(page);
+ await page.goto("/");
+ await openSettings(page, "profile");
+ await expandIdentity(page);
+ const createRow = page.getByTestId("profile-encrypted-backup-row");
+ const testRow = page.getByTestId("profile-backup-test-row");
+ await expect(createRow).toContainText("Create a key backup");
+ await expect(testRow).toContainText("Test a key backup");
+ await page.getByTestId("profile-backup-test-row-toggle").click();
+ await page.getByTestId("backup-test-file-input").setInputFiles({
+ name: "identity.ncryptsec",
+ mimeType: "text/plain",
+ buffer: Buffer.from(
+ "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p",
+ ),
+ });
+ await page
+ .getByTestId("backup-test-password")
+ .fill("mock horse battery staple lake orbit");
+ await page.getByTestId("backup-test-verify").click();
+ await expect(page.getByTestId("backup-test-success")).toContainText(
+ "This backup works",
+ );
+ await expect(page.getByTestId("backup-test-success")).toContainText(
+ "It restores your current Buzz identity",
+ );
+ await expect(page.getByTestId("backup-test-npub")).toBeVisible();
+});
diff --git a/desktop/tests/e2e/signout-confirmation.spec.ts b/desktop/tests/e2e/signout-confirmation.spec.ts
index b87caf597..7b6c9b576 100644
--- a/desktop/tests/e2e/signout-confirmation.spec.ts
+++ b/desktop/tests/e2e/signout-confirmation.spec.ts
@@ -3,7 +3,7 @@
*
* Signing out wipes the identity key and all local data, so the dialog gates
* "Delete My Data" behind two explicit steps:
- * 1. backup — reveal/copy the nsec, then check "I have saved my private key"
+ * 1. backup — check "I have saved my private key"
* 2. typed confirmation — type the exact phrase "wipe all my data"
*/
import { expect, type Page, test } from "@playwright/test";
@@ -13,10 +13,6 @@ import { openSettings } from "../helpers/settings";
const CONFIRM_PHRASE = "wipe all my data";
-// The mock bridge routes copy_text_to_clipboard through navigator.clipboard,
-// which requires explicit permissions in headless Chromium.
-test.use({ permissions: ["clipboard-read", "clipboard-write"] });
-
async function openSignOutDialog(page: Page) {
await openSettings(page, "profile");
const section = page.getByTestId("settings-signout");
@@ -36,12 +32,8 @@ test("delete button unlocks only after backup + typed phrase", async ({
const backupCheckbox = page.getByTestId("signout-backup-confirm");
const phraseInput = page.getByTestId("signout-confirm-phrase");
- // Everything locked initially: no key interaction yet.
+ // Delete is locked initially; the backup checkbox is immediately usable.
await expect(deleteButton).toBeDisabled();
- await expect(backupCheckbox).toBeDisabled();
-
- // Copying the key unlocks the backup checkbox.
- await page.getByTestId("nsec-copy").click();
await expect(backupCheckbox).toBeEnabled();
await backupCheckbox.click();
@@ -61,24 +53,11 @@ test("delete button unlocks only after backup + typed phrase", async ({
await expect(deleteButton).toBeDisabled();
});
-test("reveal also unlocks the backup checkbox", async ({ page }) => {
- await installMockBridge(page);
- await page.goto("/");
- await openSignOutDialog(page);
-
- const backupCheckbox = page.getByTestId("signout-backup-confirm");
- await expect(backupCheckbox).toBeDisabled();
-
- await page.getByTestId("nsec-reveal-toggle").click();
- await expect(backupCheckbox).toBeEnabled();
-});
-
test("completing both gates invokes sign_out", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openSignOutDialog(page);
- await page.getByTestId("nsec-copy").click();
await page.getByTestId("signout-backup-confirm").click();
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
@@ -104,16 +83,15 @@ test("cancel resets the gates for the next open", async ({ page }) => {
await openSignOutDialog(page);
// Satisfy both gates, then cancel.
- await page.getByTestId("nsec-copy").click();
await page.getByTestId("signout-backup-confirm").click();
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("alertdialog")).not.toBeVisible();
- // Reopen — everything must be locked again.
+ // Reopen — everything must be reset again.
await page.getByTestId("signout-open-dialog").click();
await expect(page.getByRole("alertdialog")).toBeVisible();
- await expect(page.getByTestId("signout-backup-confirm")).toBeDisabled();
+ await expect(page.getByTestId("signout-backup-confirm")).not.toBeChecked();
await expect(page.getByTestId("signout-confirm-phrase")).toHaveValue("");
await expect(page.getByTestId("signout-confirm")).toBeDisabled();
});
@@ -125,8 +103,8 @@ test("nsec load failure still allows sign-out (backup step degrades)", async ({
await page.goto("/");
await openSignOutDialog(page);
- // Error shown in place of the key; checkbox is usable so the user is not
- // permanently locked out of signing out.
+ // Error shown in place of the key; checkbox is still usable so the user is
+ // not locked out of signing out.
await expect(page.getByTestId("signout-nsec-error")).toContainText(
"Keychain locked",
);
diff --git a/desktop/tests/helpers/fileDrag.ts b/desktop/tests/helpers/fileDrag.ts
new file mode 100644
index 000000000..3a396ada0
--- /dev/null
+++ b/desktop/tests/helpers/fileDrag.ts
@@ -0,0 +1,52 @@
+import type { Page } from "@playwright/test";
+
+/**
+ * Simulate a native file drag entering the window. The backup test flow
+ * listens for window-level dragenter with a "Files" payload and swaps in a
+ * composer-style drop overlay over its host surface.
+ */
+export async function startWindowFileDrag(page: Page): Promise {
+ await page.evaluate(() => {
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(
+ new File(["x"], "identity.ncryptsec", { type: "text/plain" }),
+ );
+ window.dispatchEvent(
+ new DragEvent("dragenter", { dataTransfer, bubbles: true }),
+ );
+ });
+}
+
+/** Simulate the file drag leaving the window without dropping. */
+export async function endWindowFileDrag(page: Page): Promise {
+ await page.evaluate(() => {
+ window.dispatchEvent(new DragEvent("dragend", { bubbles: true }));
+ });
+}
+
+/** Drop a text file with the given contents onto the element with `testId`. */
+export async function dropFileOnTestId(
+ page: Page,
+ testId: string,
+ contents: string,
+ name = "identity.ncryptsec",
+): Promise {
+ await page.evaluate(
+ ({ testId, contents, name }) => {
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(
+ new File([contents], name, { type: "text/plain" }),
+ );
+ const target = document.querySelector(`[data-testid="${testId}"]`);
+ if (!target) throw new Error(`drop target ${testId} not found`);
+ target.dispatchEvent(
+ new DragEvent("drop", {
+ dataTransfer,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ },
+ { testId, contents, name },
+ );
+}
diff --git a/desktop/tests/helpers/onboarding.ts b/desktop/tests/helpers/onboarding.ts
index 7b35c29f9..afcb3e64f 100644
--- a/desktop/tests/helpers/onboarding.ts
+++ b/desktop/tests/helpers/onboarding.ts
@@ -16,13 +16,12 @@ export async function seedActiveIdentity(
);
}
-/** Navigate through the backup step (fresh-key path, encrypted default). */
+/** Navigate through the backup steps (fresh-key path). */
export async function passThroughBackupStep(page: Page) {
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
- // Encrypted-by-default: create the backup with the generated passphrase,
- // then advance. (The raw key path is behind "Show raw key instead".)
- await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
- await page.getByTestId("encrypted-backup-create").click();
- await expect(page.getByTestId("ncryptsec-value")).toBeVisible();
+ // Next always leads into the "Backup your key" step; backing up is
+ // recommended, never required — "Skip for now" there advances to setup.
await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+ await page.getByTestId("onboarding-skip").click();
}