mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user