diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index b5074f443..9abecbd2c 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 diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index c8b16c156..0a19fb143 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"); @@ -189,10 +195,17 @@ pub fn cleanup_stale_backup( Ok(()) } -/// Generate a 6-word passphrase from the EFF short wordlist using OS entropy. +/// Generate a passphrase of `word_count` EFF short-wordlist words joined by +/// `separator`, using OS entropy. /// -/// Uses rejection sampling for a uniform distribution over the 1296 words. -pub fn generate_passphrase() -> 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!( @@ -201,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 41e8fa487..a4e20cd56 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -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/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs index f118bbf45..d300ab852 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -1,15 +1,17 @@ /** * Pure-logic tests for the encrypted-backup (NIP-49) creation state model. - * These drive the same reducer + validation helpers the BackupStep and + * These drive the same reducer + validation helpers the DownloadKeyStep 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, @@ -19,138 +21,207 @@ function reduce(events, from = initialEncryptedBackupState) { return events.reduce(encryptedBackupReducer, from); } -// ── generated-passphrase mode (default) ───────────────────────────────────── +// ── passphrase validation ──────────────────────────────────────────────────── -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("download_disabled_until_passphrase_meets_min_length", () => { + assert.equal(downloadDisabled(initialEncryptedBackupState), true); -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, - ); + const short = reduce([{ type: "set-passphrase", value: "short" }]); assert.equal(effectivePassphrase(short), null); - assert.equal(createDisabled(short), true); + assert.equal(downloadDisabled(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); + const ready = reduce([ + { type: "set-passphrase", value: "regency-dawes-bilal-sit" }, + ]); + assert.equal(effectivePassphrase(ready), "regency-dawes-bilal-sit"); + assert.equal(downloadDisabled(ready), false); }); -test("custom_passphrase_issue_messages", () => { +test("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); + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + assert.equal(passphraseIssue("a".repeat(MIN_PASSPHRASE_LEN)), 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 }, - ]); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + const ready = reduce([{ type: "set-passphrase", 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" }, +test("editing_passphrase_clears_create_error", () => { + const failed = reduce([ + { type: "set-passphrase", value: "regency-dawes-bilal-sit" }, + { type: "encrypt-started", passphrase: "regency-dawes-bilal-sit" }, + { + type: "encrypt-failed", + passphrase: "regency-dawes-bilal-sit", + message: "keychain unavailable", + }, ]); - - const creating = reduce([{ type: "create-started" }], ready); - assert.equal(creating.isCreating, true); - assert.equal( - createDisabled(creating), - true, - "no double-create while KDF runs", - ); - - const failed = reduce( - [{ type: "create-failed", message: "keychain unavailable" }], - creating, - ); - assert.equal(failed.isCreating, false); assert.equal(failed.createError, "keychain unavailable"); - assert.equal(createDisabled(failed), false, "retry allowed after failure"); + const edited = reduce( + [{ type: "set-passphrase", value: "regency-dawes-bilal-sat" }], + failed, + ); + assert.equal(edited.createError, null); +}); + +// ── eager encryption lifecycle ─────────────────────────────────────────────── + +test("valid_passphrase_requests_background_encryption", () => { + assert.equal(pendingEncryptPassphrase(initialEncryptedBackupState), null); + + const short = reduce([{ type: "set-passphrase", value: "short" }]); + assert.equal(pendingEncryptPassphrase(short), null); + + 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", passphrase: "one-two-three-four" }], + ready, + ); + assert.equal(isEncrypting(started), true); + assert.equal( + pendingEncryptPassphrase(started), + null, + "no duplicate start while the KDF runs", + ); const done = reduce( [ - { type: "create-started" }, - { type: "create-succeeded", ncryptsec: "ncryptsec1abc" }, + { + type: "encrypt-succeeded", + passphrase: "one-two-three-four", + ncryptsec: "ncryptsec1abc", + }, ], - failed, + started, + ); + assert.equal(isEncrypting(done), false); + assert.equal(pendingEncryptPassphrase(done), null, "result cached"); + assert.equal(done.ncryptsec, null, "nothing commits before Download"); +}); + +test("stale_encryption_results_are_dropped_and_retriggered", () => { + const editedMidFlight = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", passphrase: "one-two-three-four" }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + ]); + // The new passphrase needs its own run even while the old one is in flight. + assert.equal( + pendingEncryptPassphrase(editedMidFlight), + "five-six-seven-eight", + ); + + const staleLanded = reduce( + [ + { type: "encrypt-started", passphrase: "five-six-seven-eight" }, + { + type: "encrypt-succeeded", + passphrase: "one-two-three-four", + ncryptsec: "ncryptsec1stale", + }, + ], + editedMidFlight, + ); + assert.equal(staleLanded.encrypted.passphrase, "one-two-three-four"); + assert.equal(staleLanded.ncryptsec, null, "stale result never commits"); + assert.equal( + isEncrypting(staleLanded), + true, + "current passphrase still encrypting", + ); + + const staleFailed = reduce( + [ + { + type: "encrypt-failed", + passphrase: "one-two-three-four", + message: "boom", + }, + ], + editedMidFlight, + ); + assert.equal(staleFailed.createError, null, "stale failures are silent"); +}); + +// ── download commit ────────────────────────────────────────────────────────── + +test("download_commits_instantly_when_encryption_is_done", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", passphrase: "one-two-three-four" }, + { + type: "encrypt-succeeded", + passphrase: "one-two-three-four", + ncryptsec: "ncryptsec1abc", + }, + { type: "download-clicked" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.downloadPending, false); +}); + +test("download_during_encryption_queues_then_commits_on_success", () => { + const queued = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", passphrase: "one-two-three-four" }, + { type: "download-clicked" }, + ]); + assert.equal(queued.downloadPending, true); + assert.equal(queued.ncryptsec, null); + assert.equal(downloadDisabled(queued), true, "no double-click while queued"); + + const committed = reduce( + [ + { + type: "encrypt-succeeded", + passphrase: "one-two-three-four", + ncryptsec: "ncryptsec1abc", + }, + ], + queued, + ); + assert.equal(committed.ncryptsec, "ncryptsec1abc"); + assert.equal(committed.downloadPending, false); +}); + +test("queued_download_clears_on_failure_so_user_can_retry", () => { + const failed = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", passphrase: "one-two-three-four" }, + { type: "download-clicked" }, + { + type: "encrypt-failed", + passphrase: "one-two-three-four", + message: "keychain unavailable", + }, + ]); + assert.equal(failed.downloadPending, false); + assert.equal(failed.createError, "keychain unavailable"); + assert.equal(downloadDisabled(failed), false, "retry allowed after failure"); +}); + +test("download_click_before_encryption_starts_still_queues", () => { + // Click lands inside the debounce window: nothing started yet, but the + // pending flag makes the eventual result commit. + const queued = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "download-clicked" }, + ]); + assert.equal(queued.downloadPending, true); + assert.equal( + pendingEncryptPassphrase(queued), + "one-two-three-four", + "host still needs to start the KDF", ); - 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..ff616bf96 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.ts +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -1,117 +1,161 @@ /** * Pure state model for the encrypted-key-backup (NIP-49) creation flow, - * shared by the onboarding BackupStep and the settings Password Backup row. + * shared by the onboarding DownloadKeyStep 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`. + * + * Encryption is eager: the moment the passphrase is valid the host kicks off + * the KDF in the background (`pendingEncryptPassphrase` tells it what to + * start). Clicking Download either commits an already-finished result + * immediately or flags `downloadPending` so the commit happens as soon as the + * in-flight encryption lands. Results are keyed by passphrase, so stale + * completions for an edited passphrase are ignored. + * + * The flow is a single password field. The "Generate password" popover is + * host-local UI state (word count, separator, candidate) — accepting a + * candidate simply dispatches `set-passphrase`, so the model doesn't + * distinguish typed from generated passwords. */ -export type PassphraseMode = "generated" | "custom"; - /** Mirrors `MIN_PASSPHRASE_LEN` in `src-tauri/src/key_backup.rs`. */ -export const MIN_CUSTOM_PASSPHRASE_LEN = 12; +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; + /** Passphrase whose KDF is currently running in the background, if any. */ + encryptingPassphrase: string | null; + /** Most recent completed encryption, keyed by the passphrase it used. */ + encrypted: { passphrase: string; ncryptsec: string } | null; createError: string | null; - /** The persisted `ncryptsec1…` blob once the backup exists. */ + /** True when Download was clicked while encryption was still in flight. */ + downloadPending: boolean; + /** The committed `ncryptsec1…` blob once the user downloads the backup. */ ncryptsec: string | null; }; export const initialEncryptedBackupState: EncryptedBackupState = { - generatedPassphrase: null, - generateError: null, - mode: "generated", - customPassphrase: "", - customConfirm: "", - isCreating: false, + passphrase: "", + encryptingPassphrase: null, + encrypted: null, createError: null, + downloadPending: false, ncryptsec: null, }; 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"; passphrase: string } + | { type: "encrypt-succeeded"; passphrase: string; ncryptsec: string } + | { type: "encrypt-failed"; passphrase: string; message: string } + | { type: "download-clicked" }; export function encryptedBackupReducer( state: EncryptedBackupState, event: EncryptedBackupEvent, ): EncryptedBackupState { switch (event.type) { - case "passphrase-generated": + case "set-passphrase": + return { ...state, passphrase: event.value, createError: null }; + case "encrypt-started": return { ...state, - generatedPassphrase: event.passphrase, - generateError: null, + encryptingPassphrase: event.passphrase, + createError: null, }; - 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 }; + case "encrypt-succeeded": { + const next: EncryptedBackupState = { + ...state, + encryptingPassphrase: + state.encryptingPassphrase === event.passphrase + ? null + : state.encryptingPassphrase, + encrypted: { + passphrase: event.passphrase, + ncryptsec: event.ncryptsec, + }, + }; + // A pending download commits the moment the matching result lands; + // results for an edited (stale) passphrase never commit. + if (state.downloadPending && event.passphrase === state.passphrase) { + return { + ...next, + downloadPending: false, + ncryptsec: event.ncryptsec, + }; + } + return next; + } + case "encrypt-failed": { + const next: EncryptedBackupState = { + ...state, + encryptingPassphrase: + state.encryptingPassphrase === event.passphrase + ? null + : state.encryptingPassphrase, + }; + // Stale failures (passphrase already edited) are silent — a fresh + // encryption for the current passphrase is already on its way. + if (event.passphrase !== state.passphrase) return next; + return { ...next, createError: event.message, downloadPending: false }; + } + case "download-clicked": { + const passphrase = effectivePassphrase(state); + if (!passphrase || state.downloadPending || state.ncryptsec) return state; + if (state.encrypted?.passphrase === passphrase) { + return { ...state, ncryptsec: state.encrypted.ncryptsec }; + } + return { ...state, downloadPending: true }; + } } } /** - * 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. + * Validation issue for the passphrase, or null when acceptable. An empty + * field reports nothing so the user isn't scolded before 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."; + if ([...passphrase].length < MIN_PASSPHRASE_LEN) { + return `Use at least ${MIN_PASSPHRASE_LEN} characters.`; } return null; } -/** The passphrase the Create action would submit, or null when not ready. */ +/** The passphrase a download would use, or null when not yet valid. */ 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 null; - } - return customPassphrase; + if ([...state.passphrase].length < MIN_PASSPHRASE_LEN) return null; + return state.passphrase; } -/** Whether the "Create backup" action is currently actionable. */ -export function createDisabled(state: EncryptedBackupState): boolean { - return state.isCreating || effectivePassphrase(state) === null; +/** + * The passphrase the host should start encrypting now, or null when nothing + * needs to start (invalid, already encrypted, already in flight, or done). + */ +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if (state.ncryptsec) return null; + const passphrase = effectivePassphrase(state); + if (!passphrase) return null; + if (state.encrypted?.passphrase === passphrase) return null; + if (state.encryptingPassphrase === passphrase) return null; + return passphrase; +} + +/** Whether the current passphrase's encryption is still running. */ +export function isEncrypting(state: EncryptedBackupState): boolean { + const passphrase = effectivePassphrase(state); + return passphrase !== null && state.encryptingPassphrase === passphrase; +} + +/** Whether the Download action is currently actionable. */ +export function downloadDisabled(state: EncryptedBackupState): boolean { + return state.downloadPending || effectivePassphrase(state) === null; } diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index 40f5e503f..ec6ac72a4 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -78,7 +78,7 @@ export function DownloadKeyStep({ // 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, setHasCreated] = React.useState(false); - // Footer slot the creator portals its "Encrypt and download" button into. + // Footer slot the creator portals its "Download" button into. const [createButtonSlot, setCreateButtonSlot] = React.useState(null); @@ -96,9 +96,9 @@ export function DownloadKeyStep({ Backup your key

- Your key is encrypted with your password before it downloads. Keep the - file private — you need both it and the password to restore your - identity. + Keep the downloaded file private — you need both it and your password + to restore your identity. Save the password somewhere safe; Buzz + cannot reset it if lost.

diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index d0704e6fd..d2d24df7d 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, RefreshCw } from "lucide-react"; +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; import * as React from "react"; import { createPortal } from "react-dom"; @@ -10,26 +10,48 @@ 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, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, encryptedBackupReducer, initialEncryptedBackupState, - MIN_CUSTOM_PASSPHRASE_LEN, - effectivePassphrase, + MIN_PASSPHRASE_LEN, } from "../lib/encryptedBackup"; import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Pause after the last keystroke before the background KDF starts, so typing + * past the minimum length doesn't launch an encryption per character. + */ +const ENCRYPT_DEBOUNCE_MS = 400; + type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; /** - * When set, the "Encrypt and download" button is portaled into this element + * 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 "Encrypt and download" button. */ + /** Extra classes for the "Download" button. */ createButtonClassName?: string; /** Fired once the encrypted payload has been created (before saving). */ onCreated?: () => void; @@ -37,11 +59,177 @@ type EncryptedBackupCreatorProps = { onSaved?: (path: string) => 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, + onGenerated, +}: { + disabled?: boolean; + onGenerated: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + /** * Password-first encrypted key download flow shared by onboarding and * Settings. The raw private key never enters this component. Rust creates the * NIP-49 payload locally, then the native save dialog produces the user-owned * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly; clicking mid-encryption queues the + * download until the KDF finishes. */ export function EncryptedBackupCreator({ variant = "spotlight", @@ -54,70 +242,90 @@ export function EncryptedBackupCreator({ encryptedBackupReducer, initialEncryptedBackupState, ); + const [isRevealed, setIsRevealed] = 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 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 password.", - }); - } - }, []); + // The committed blob we've already kicked a save off for — guards the + // commit effect against re-running on unrelated re-renders. + const savedForRef = React.useRef(null); React.useEffect(() => { mountedRef.current = true; - void generate(); return () => { mountedRef.current = false; }; - }, [generate]); + }, []); - const handleCreate = React.useCallback(async () => { - const passphrase = effectivePassphrase(state); - if (!passphrase || state.isCreating) return; - dispatch({ type: "create-started" }); - try { - const ncryptsec = await createNcryptsecBackup(passphrase); - if (!mountedRef.current) return; - dispatch({ type: "create-succeeded", ncryptsec }); - onCreated?.(); - setIsSaving(true); - setSaveError(null); - try { - const path = await saveNcryptsecCopy(ncryptsec); + // Eager background encryption: start the KDF as soon as the passphrase is + // valid (debounced against typing). Results are keyed by passphrase in the + // reducer, so a completion for an edited passphrase is dropped there. + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let cancelled = false; + const start = () => { + if (cancelled || !mountedRef.current) return; + dispatch({ type: "encrypt-started", passphrase: pendingPassphrase }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => { + if (mountedRef.current) + dispatch({ + type: "encrypt-succeeded", + passphrase: pendingPassphrase, + ncryptsec, + }); + }) + .catch((err: unknown) => { + if (mountedRef.current) + dispatch({ + type: "encrypt-failed", + passphrase: pendingPassphrase, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }); + }); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [pendingPassphrase, skipDebounce]); + + // Download commit: fires once per committed blob, whether the commit was + // instant (encryption already done) or resolved a queued download. + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || savedForRef.current === ncryptsec) return; + savedForRef.current = ncryptsec; + onCreated?.(); + setIsSaving(true); + setSaveError(null); + void saveNcryptsecCopy(ncryptsec) + .then((path) => { if (mountedRef.current && path) { setSavedPath(path); onSaved?.(path); } - } catch (err) { + }) + .catch((err: unknown) => { if (mountedRef.current) setSaveError( err instanceof Error ? err.message : "Failed to save your key.", ); - } finally { + }) + .finally(() => { if (mountedRef.current) setIsSaving(false); - } - } catch (err) { - if (mountedRef.current) - dispatch({ - type: "create-failed", - message: - err instanceof Error ? err.message : "Failed to encrypt your key.", - }); - } - }, [onCreated, onSaved, state]); + }); + }, [onCreated, onSaved, state.ncryptsec]); const handleSaveCopy = React.useCallback(async () => { if (!state.ncryptsec || isSaving) return; @@ -140,10 +348,7 @@ export function EncryptedBackupCreator({ }, [isSaving, onSaved, state.ncryptsec]); const isSpotlight = variant === "spotlight"; - const customIssue = customPassphraseIssue( - state.customPassphrase, - state.customConfirm, - ); + const issue = passphraseIssue(state.passphrase); if (state.ncryptsec) { return ( @@ -188,122 +393,55 @@ export function EncryptedBackupCreator({ return (
- {state.mode === "generated" ? ( -
- {state.generatedPassphrase ? ( -
-

- {state.generatedPassphrase} -

-
- ) : state.generateError ? ( -
- - Could not generate a password: {state.generateError} -
+
+ + dispatch({ type: "set-passphrase", value: event.target.value }) + } + placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`} + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + - -
-

- Save this generated passphrase as your encryption password. Store it - separately from the downloaded key file. + + { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + /> + {issue ? ( +

+ {issue}

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

- {customIssue} -

- ) : null} -
- -
-

- Your password protects your downloaded key. Buzz cannot reset it if - lost. -

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

{ + // Absolute spinner: signals the background encryption without + // shifting the centered button while it appears and disappears. const createButton = ( - +

+ {isEncrypting(state) || state.downloadPending ? ( + + ) : null} + +
); // `undefined` = inline (settings); `null` = slot not mounted yet // (skip a frame rather than flashing the button inline). diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index 1d961aa16..91b3a4007 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, + }); } /** diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9f6d4c5dd..678464526 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7203,6 +7203,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 explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; @@ -9837,11 +9852,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 diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index ed3a60cc0..2d583692a 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -104,14 +104,39 @@ test("download happy path: generated password, encrypt, native save, Next", asyn await page.getByTestId("backup-option-download").click(); await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); - // Default mode: generated password shown; the create button sits in the - // footer's primary slot until the encrypted payload exists. - await expect(page.getByTestId("backup-passphrase-generated")).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); + // 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}/03-backup-download-passphrase.png` }); + // Esc closes the popover; the generated password stays in the field. + await page.keyboard.press("Escape"); + await expect(page.getByTestId("backup-passphrase-separator")).toHaveCount(0); + await expect(input).toHaveValue("mock-horse-battery-staple"); + await page.getByTestId("encrypted-backup-create").click(); // The locally created blob stays masked; the portable save action is explicit. @@ -143,7 +168,7 @@ test("download step Back returns to the backup chooser", async ({ page }) => { await page.getByTestId("backup-option-download").click(); await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); - await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible(); + await expect(page.getByTestId("backup-passphrase-input")).toBeVisible(); // The chooser's footer CTA belongs to the previous step. await expect(page.getByTestId("backup-option-download")).toHaveCount(0); @@ -153,27 +178,21 @@ test("download step Back returns to the backup chooser", async ({ page }) => { await expect(page.getByTestId("backup-option-download")).toBeVisible(); }); -test("custom passphrase requires 12 characters and confirmation", async ({ - page, -}) => { +test("typed password requires 12 characters", async ({ page }) => { await enterMachineBackup(page); await page.getByTestId("backup-option-download").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(); });