feat(onboarding): single password field with 1Password-style generator for key backup

Replace the two-mode (generated vs custom + confirm) encrypted-backup
passphrase UI with a single password input and an inset generator, and make
encryption eager so Download commits instantly in the common case.

- EncryptedBackupCreator.tsx: single password input (eye reveal toggle) with
  a refresh icon inset in the field that anchors a 1Password-style popover
  containing only Words (range slider, 3-10) and Separator (Spaces default,
  Hyphens/Periods/Commas) controls; every generation (open, control change,
  repeat icon clicks) writes straight into the field — no candidate preview
  or Use button. Popover stays open across re-rolls; only click-outside or
  Esc closes it. Validation hint is absolutely positioned below the field
  (text-xs, fade + slide-down) so it never shifts layout.
- encryptedBackup.ts: state model rewritten for eager background encryption —
  the KDF starts (debounced 400ms) as soon as the passphrase is valid,
  results are keyed by passphrase so stale completions never commit, and
  Download either commits a finished result instantly or queues until the
  in-flight encryption lands (downloadPending). New helpers:
  pendingEncryptPassphrase, isEncrypting, downloadDisabled, passphraseIssue.
- DownloadKeyStep.tsx: subtitle merges the password-safety hint and drops the
  encryption-mechanics sentence; Back is locked via onBusyChange while a
  queued download resolves.
- key_backup.rs / commands/identity.rs: generate_passphrase now takes word
  count (clamped 3-10, default 3) and separator (default space); phrases
  under MIN_PASSPHRASE_LEN are re-drawn so 3-word draws always pass the same
  length gate as typed passphrases.
- tauriIdentity.ts: generateBackupPassphrase accepts { words, separator }.
- e2eBridge.ts: mocked generator honors word count and separator from a
  fixed deterministic word pool so specs can assert the popover controls.
- onboarding-backup.spec.ts: happy path drives the generator popover (open,
  slider, separator select, re-roll, Esc) and the custom-password test drops
  the confirm field; encryptedBackup.test.mjs rewritten for the eager
  lifecycle (queued download, stale results, failure retry).

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-28 23:20:30 -07:00
parent 0d704d9904
commit 302b5aa672
10 changed files with 777 additions and 424 deletions
+11 -4
View File
@@ -188,11 +188,18 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
.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<String, String> {
crate::key_backup::generate_passphrase()
pub fn generate_backup_passphrase(
words: Option<u32>,
separator: Option<String>,
) -> Result<String, String> {
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
+38 -17
View File
@@ -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<String, String> {
/// `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<String, String> {
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<String, String> {
));
}
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)]
+23 -9
View File
@@ -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()));
}
}
@@ -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);
});
@@ -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;
}
@@ -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<HTMLElement | null>(null);
@@ -96,9 +96,9 @@ export function DownloadKeyStep({
Backup your key
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
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.
</p>
</div>
@@ -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<string>(DEFAULT_SEPARATOR);
const [error, setError] = React.useState<string | null>(null);
const anchorRef = React.useRef<HTMLButtonElement | null>(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 (
<Popover onOpenChange={setOpen} open={open}>
{/* 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. */}
<PopoverAnchor asChild>
<Button
aria-label="Generate a password"
className="absolute right-9 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
data-testid="backup-passphrase-generate"
disabled={disabled}
onClick={() => {
// The open effect below generates the first password; later
// clicks re-roll with the current controls.
if (!open) setOpen(true);
else void generate(words, separator);
}}
ref={anchorRef}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
</Button>
</PopoverAnchor>
<PopoverContent
align="end"
className="w-72 space-y-3"
onInteractOutside={(event) => {
// 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()}
>
<div className="flex items-center justify-between gap-4">
<label
className="text-sm text-muted-foreground"
htmlFor="backup-passphrase-words"
>
Words
</label>
<div className="flex flex-1 items-center justify-end gap-3">
<input
className="h-1.5 w-full max-w-30 cursor-pointer appearance-none rounded-full bg-foreground/15 accent-primary"
id="backup-passphrase-words"
data-testid="backup-passphrase-words"
max={MAX_GENERATED_WORDS}
min={MIN_GENERATED_WORDS}
onChange={(event) => setWords(Number(event.target.value))}
type="range"
value={words}
/>
<span className="w-6 text-right text-sm tabular-nums text-foreground">
{words}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<label
className="text-sm text-muted-foreground"
htmlFor="backup-passphrase-separator"
>
Separator
</label>
<select
className="h-8 rounded-lg border border-border bg-background px-2 text-sm text-foreground outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
id="backup-passphrase-separator"
data-testid="backup-passphrase-separator"
onChange={(event) => setSeparator(event.target.value)}
value={separator}
>
{SEPARATOR_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{error ? (
<p
className="flex items-start gap-1.5 text-xs text-destructive"
data-testid="backup-passphrase-generate-error"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{error}
</p>
) : null}
</PopoverContent>
</Popover>
);
}
/**
* 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<string | null>(null);
const [saveError, setSaveError] = React.useState<string | null>(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<string | null>(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 (
<div
className={cn("mx-auto w-full max-w-[500px] space-y-4 text-left")}
className={cn("mx-auto w-full max-w-[500px] space-y-3 text-left")}
data-testid="encrypted-backup-creator"
>
{state.mode === "generated" ? (
<div className="space-y-3">
{state.generatedPassphrase ? (
<div className="rounded-xl bg-white/50 px-6 py-5 text-center">
<p
className="font-mono text-lg leading-8 text-foreground"
data-testid="backup-passphrase-generated"
>
{state.generatedPassphrase}
</p>
</div>
) : state.generateError ? (
<div
className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="backup-passphrase-generate-error"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>Could not generate a password: {state.generateError}</span>
</div>
<div className="relative">
<Input
aria-label="Encryption password"
autoComplete="new-password"
className="h-10 bg-background pr-19 font-mono"
data-testid="backup-passphrase-input"
disabled={state.downloadPending}
onChange={(event) =>
dispatch({ type: "set-passphrase", value: event.target.value })
}
placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`}
type={isRevealed ? "text" : "password"}
value={state.passphrase}
/>
<Button
aria-label={isRevealed ? "Hide password" : "Reveal password"}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
data-testid="backup-passphrase-reveal-toggle"
onClick={() => setIsRevealed((revealed) => !revealed)}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-foreground/70">
<Spinner className="h-4 w-4 border-2" />
Generating a password…
</div>
<Eye className="h-4 w-4" aria-hidden="true" />
)}
<div className="flex items-center justify-center gap-3">
<Button
className="h-8 gap-1.5 text-sm"
data-testid="backup-passphrase-regenerate"
onClick={() => void generate()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
New generated password
</Button>
<Button
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
data-testid="backup-passphrase-choose-own"
onClick={() => dispatch({ type: "set-mode", mode: "custom" })}
size="sm"
type="button"
variant="ghost"
>
Choose my own
</Button>
</div>
<p className="text-center text-xs leading-5 text-muted-foreground">
Save this generated passphrase as your encryption password. Store it
separately from the downloaded key file.
</Button>
<PassphraseGeneratorPopover
disabled={state.downloadPending}
onGenerated={(value) => {
dispatch({ type: "set-passphrase", value });
// A generated password must be visible so the user can save it.
setIsRevealed(true);
}}
/>
{issue ? (
<p
className="absolute left-1 top-full mt-1 animate-in text-xs text-muted-foreground fade-in slide-in-from-top-1 duration-200 motion-reduce:animate-none"
data-testid="backup-passphrase-issue"
>
{issue}
</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-2">
<Input
aria-label="Encryption password"
autoComplete="new-password"
className="h-10 bg-background"
data-testid="backup-passphrase-custom"
onChange={(event) =>
dispatch({
type: "set-custom-passphrase",
value: event.target.value,
})
}
placeholder={`Password (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`}
type="password"
value={state.customPassphrase}
/>
<Input
aria-label="Confirm encryption password"
autoComplete="new-password"
className="h-10 bg-background"
data-testid="backup-passphrase-confirm"
onChange={(event) =>
dispatch({
type: "set-custom-confirm",
value: event.target.value,
})
}
placeholder="Confirm password"
type="password"
value={state.customConfirm}
/>
</div>
{customIssue ? (
<p
className="text-sm text-muted-foreground"
data-testid="backup-passphrase-issue"
>
{customIssue}
</p>
) : null}
<div className="flex items-center justify-center">
<Button
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
data-testid="backup-passphrase-use-generated"
onClick={() => dispatch({ type: "set-mode", mode: "generated" })}
size="sm"
type="button"
variant="ghost"
>
Use a generated password
</Button>
</div>
<p className="text-center text-xs leading-5 text-muted-foreground">
Your password protects your downloaded key. Buzz cannot reset it if
lost.
</p>
</div>
)}
) : null}
</div>
{state.createError ? (
<p
@@ -315,23 +453,27 @@ export function EncryptedBackupCreator({
) : null}
{(() => {
// Absolute spinner: signals the background encryption without
// shifting the centered button while it appears and disappears.
const createButton = (
<Button
className={cn("h-9 rounded-full px-6", createButtonClassName)}
data-testid="encrypted-backup-create"
disabled={createDisabled(state)}
onClick={() => void handleCreate()}
type="button"
>
{state.isCreating ? (
<>
<Spinner className="h-4 w-4 border-2" />
Encrypting… this takes a couple of seconds
</>
) : (
"Encrypt and download"
)}
</Button>
<div className="relative">
{isEncrypting(state) || state.downloadPending ? (
<Spinner
aria-label="Encrypting your key"
className="absolute right-full top-1/2 mr-3 h-4 w-4 -translate-y-1/2 border-2"
data-testid="encrypted-backup-encrypting"
/>
) : null}
<Button
className={cn("h-9 rounded-full px-6", createButtonClassName)}
data-testid="encrypted-backup-create"
disabled={downloadDisabled(state)}
onClick={() => dispatch({ type: "download-clicked" })}
type="button"
>
{state.downloadPending ? "Downloading once finished" : "Download"}
</Button>
</div>
);
// `undefined` = inline (settings); `null` = slot not mounted yet
// (skip a frame rather than flashing the button inline).
+15 -3
View File
@@ -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<string> {
return invokeTauri<string>("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<string> {
return invokeTauri<string>("generate_backup_passphrase", {
words: options?.words,
separator: options?.separator,
});
}
/**
+28 -5
View File
@@ -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
+34 -15
View File
@@ -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();
});