mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): guide and verify password-protected backups (#3617)
**Category:** improvement **User Impact:** Users can create a password-protected key backup, save it locally, and prove that it works before finishing onboarding or signing out. **Problem:** Tyler's NIP-49 foundation made encrypted local backup possible, but the end-to-end ceremony still needed clearer progression, trustworthy verification, and a Settings treatment consistent with the rest of Identity. **Solution:** This stack adds a guided create/download/test flow, verifies the actual saved backup through Rust without returning secret key material, minimizes password lifetime in the webview, and restores warm, plain-language presentation across onboarding and Settings. > [!NOTE] > This PR is intentionally stacked on #2937 (`eva/nip49-local-backup`). Review only the 19 commits in this stack. <details> <summary>File changes</summary> **desktop/src-tauri/src/commands/identity.rs** Adds off-thread verification of NIP-49 backups and returns public identity details only. **desktop/src-tauri/src/commands/identity_key_backup_tests.rs** Covers verification, identity matching, and failure behavior at the command boundary. **desktop/src-tauri/src/egress_guard_tests.rs** Keeps the NIP-49 source inventory aligned with the new verification path. **desktop/src-tauri/src/key_backup.rs** Supports real backup decryption for verification while zeroizing submitted passwords. **desktop/src-tauri/src/key_backup_tests.rs** Exercises valid, wrong-password, damaged, and different-identity backups. **desktop/src-tauri/src/lib.rs** Registers the verification command with the desktop runtime. **desktop/src/features/communities/ui/WelcomeSetup.tsx** Routes community onboarding through the revised backup ceremony. **desktop/src/features/onboarding/lib/encryptedBackup.test.mjs** Covers the hardened backup state model and password-clearing behavior. **desktop/src/features/onboarding/lib/encryptedBackup.ts** Models creation and verification with opaque request correlation and short-lived passwords. **desktop/src/features/onboarding/ui/BackupStep.tsx** Presents the backup choice clearly and advances into the dedicated download step. **desktop/src/features/onboarding/ui/BackupTestFlow.tsx** Adds the polished select-file, enter-password, verification, error, and success experience. **desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx** Connects community onboarding to the updated backup steps. **desktop/src/features/onboarding/ui/DownloadKeyStep.tsx** Adds the dedicated encrypted-backup download step and pre-creation skip escape hatch. **desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx** Guides password generation, backup creation, native saving, safe retry, and re-download. **desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx** Sequences chooser, download, exact-file test, and setup progression. **desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx** Aligns encrypted-key import behavior with the backup flow. **desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx** Improves masked/revealed key presentation and safely wraps long private keys. **desktop/src/features/onboarding/ui/OnboardingChrome.tsx** Supports the revised onboarding layout and transitions. **desktop/src/features/onboarding/ui/SetupStep.tsx** Integrates the completed backup ceremony with final setup. **desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs** Updates flow-level assertions for the new step sequence. **desktop/src/features/settings/ui/EncryptedBackupRow.tsx** Adds sibling Create and Test rows that match the surrounding Identity settings rhythm. **desktop/src/features/settings/ui/ProfileSettingsCard.tsx** Places password-backup controls in the Identity card. **desktop/src/features/settings/ui/SignOutSection.tsx** Uses explicit backup self-attestation plus the existing wipe phrase without requiring a raw-key reveal first. **desktop/src/shared/api/tauriIdentity.ts** Exposes typed backup verification to the frontend. **desktop/src/shared/lib/ncryptsecSourceScan.test.mjs** Updates the frontend source allowlist for the verification path. **desktop/src/testing/e2eBridge.ts** Models creation, saving, and verification for browser coverage. **desktop/tests/e2e/onboarding-backup.spec.ts** Covers backup creation, exact-file testing, errors, retries, and completion. **desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts** Updates docked CTA visual coverage for the revised progression. **desktop/tests/e2e/onboarding.spec.ts** Keeps broader onboarding coverage aligned with the backup ceremony. **desktop/tests/e2e/profile-nsec-reveal.spec.ts** Covers Settings create/test behavior and updated success copy. **desktop/tests/e2e/signout-confirmation.spec.ts** Covers backup self-attestation, wipe phrase confirmation, and reset behavior. **desktop/tests/helpers/fileDrag.ts** Adds reusable file-drop support for backup test coverage. **desktop/tests/helpers/onboarding.ts** Routes tests through chooser, download, and skip behavior consistently. </details> ## Reproduction steps 1. Start fresh onboarding and choose the password-protected backup path. 2. Continue to **Backup your key**, create a password, save the `.ncryptsec` file, and confirm that **Next** remains gated until testing succeeds. 3. Select that exact file, enter its password, and verify the **Your backup works!** success state. 4. Open **Settings → Identity** and confirm that **Create password backup** and **Test password backup** appear as sibling rows. 5. Test a valid backup, then test wrong-password and different-identity cases to confirm clear, non-secret-bearing outcomes. 6. Open sign out and confirm that backup self-attestation plus typing `wipe all my data` remain required. ## Screenshots ### Onboarding flow | 1. Creating your key | 2. Key created | 3. Create backup password | |---|---|---| | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/fe5ebf60-02ad-4584-8e64-ff137bafac02" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/0b8c7c6c-2ae5-494e-bb81-0a7b47ea1b66" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/9fa8f6c0-832a-46ec-9af8-55dcae0adbb8" /> | | **4. Select the saved backup** | **5. Enter its password** | **6. Verification succeeds** | | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/7ed1528c-6a1f-4411-bd3e-c57413320cdf" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/088ec3ed-2735-4b9a-a0c5-6d176b3f06c5" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/7081ce32-9371-46b2-9692-23ff77c39d35" /> | ### Settings flow | Create and Test tools | Tested backup success | |---|---| | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/978ab672-5510-46d8-9116-6df4f1bda34c" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/56a1764d-3c3e-40e2-9f4b-962c5e96a36c" /> | ## Verification - Desktop typecheck - Full desktop JS: 3,733/3,733 - `pnpm check` - Rust desktop lib: 1,862 passed, 14 ignored; diagnostic: 3/3 - Focused Playwright: 16/16 with one worker Native save/cancel remains the residual smoke-test risk: browser E2E mocks the KDF/native save-dialog boundary, while real decrypt and identity matching are covered in Rust. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
855d25ef3a
commit
88d08e426c
@@ -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
|
||||
@@ -259,6 +266,47 @@ pub async fn create_ncryptsec_backup(
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupVerification {
|
||||
pub pubkey: String,
|
||||
pub npub: String,
|
||||
pub matches_current_identity: bool,
|
||||
}
|
||||
|
||||
fn verify_ncryptsec_backup_inner(
|
||||
state: &AppState,
|
||||
ncryptsec: &str,
|
||||
password: &str,
|
||||
) -> Result<BackupVerification, String> {
|
||||
let keys = crate::key_backup::decrypt_ncryptsec(ncryptsec, password)?;
|
||||
let pubkey = keys.public_key();
|
||||
let current = state.signing_keys()?.public_key();
|
||||
Ok(BackupVerification {
|
||||
pubkey: pubkey.to_hex(),
|
||||
npub: pubkey
|
||||
.to_bech32()
|
||||
.map_err(|e| format!("encode backup identity: {e}"))?,
|
||||
matches_current_identity: pubkey == current,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt and validate a NIP-49 backup without exposing its secret key.
|
||||
#[tauri::command]
|
||||
pub async fn verify_ncryptsec_backup(
|
||||
ncryptsec: String,
|
||||
password: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<BackupVerification, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let password = zeroize::Zeroizing::new(password);
|
||||
let state = app_handle.state::<AppState>();
|
||||
verify_ncryptsec_backup_inner(&state, &ncryptsec, &password)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path.
|
||||
///
|
||||
/// The input must parse as a structurally valid NIP-49 payload. The dialog is
|
||||
@@ -278,7 +326,7 @@ pub async fn save_ncryptsec_copy(
|
||||
let dest = match crate::commands::export_util::pick_save_path(
|
||||
&app_handle,
|
||||
crate::key_backup::BACKUP_FILE_NAME,
|
||||
"Encrypted key backup",
|
||||
"Password-protected key backup",
|
||||
&["ncryptsec"],
|
||||
)
|
||||
.await?
|
||||
@@ -744,209 +792,5 @@ mod nostr_identity_binding_tests {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod key_backup_command_tests {
|
||||
use super::create_and_persist_backup_with_log_n;
|
||||
use crate::app_state::build_app_state;
|
||||
use nostr::Keys;
|
||||
|
||||
/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered
|
||||
/// once in key_backup_tests::round_trip_at_production_cost.
|
||||
const FAST_LOG_N: u8 = 16;
|
||||
const PASSWORD: &str = "correct horse battery";
|
||||
|
||||
#[test]
|
||||
fn returned_bytes_equal_on_disk_bytes() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let returned =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
|
||||
let path = crate::key_backup::backup_file_path(dir.path());
|
||||
let on_disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(
|
||||
returned, on_disk,
|
||||
"webview must receive the exact persisted bytes"
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
// And the persisted blob provably recovers the live identity.
|
||||
let keys = state.keys.lock().unwrap().clone();
|
||||
let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap();
|
||||
assert_eq!(recovered.public_key(), keys.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_replaces_atomically() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
let second = create_and_persist_backup_with_log_n(
|
||||
&state,
|
||||
dir.path(),
|
||||
"another passphrase",
|
||||
FAST_LOG_N,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(first, second, "fresh salt/nonce per action");
|
||||
|
||||
let path = crate::key_backup::backup_file_path(dir.path());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_passphrase() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("at least"), "{err}");
|
||||
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_mode_blocks_backup_creation() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
state
|
||||
.identity_lost
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
assert!(
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
|
||||
"lost identity must not be backed up"
|
||||
);
|
||||
state
|
||||
.identity_lost
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
|
||||
state
|
||||
.keyring_locked
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
assert!(
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
|
||||
"locked keyring must not be backed up"
|
||||
);
|
||||
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
|
||||
}
|
||||
|
||||
/// Blocker-1 regression (Wren, implementation review): a failed
|
||||
/// different-key import must leave BOTH the old in-memory identity and
|
||||
/// the old canonical backup intact. Persistence runs before cleanup, so
|
||||
/// an `Err` from persist means nothing was mutated or deleted.
|
||||
#[test]
|
||||
fn failed_import_persistence_preserves_old_identity_and_backup() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_pubkey = state.keys.lock().unwrap().public_key();
|
||||
|
||||
// A valid canonical backup for the live (old) identity.
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
let backup_path = crate::key_backup::backup_file_path(dir.path());
|
||||
let backup_before = std::fs::read_to_string(&backup_path).unwrap();
|
||||
|
||||
// Different-key import whose durable persistence fails (both
|
||||
// keyring and file fallback down).
|
||||
let _guard = state.identity_mutation.lock().unwrap();
|
||||
let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| {
|
||||
Err("keyring and file both unavailable".to_string())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("unavailable"), "{err}");
|
||||
|
||||
// Old identity still live; old backup untouched byte-for-byte.
|
||||
assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&backup_path).unwrap(),
|
||||
backup_before
|
||||
);
|
||||
assert!(
|
||||
crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD)
|
||||
.unwrap()
|
||||
.public_key()
|
||||
== old_pubkey,
|
||||
"surviving backup must still recover the still-live identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// Successful different-key import removes the previous identity's
|
||||
/// backup — cleanup runs after the durable commit, not before.
|
||||
#[test]
|
||||
fn successful_import_removes_stale_backup_after_commit() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
let backup_path = crate::key_backup::backup_file_path(dir.path());
|
||||
assert!(backup_path.exists());
|
||||
|
||||
let new_keys = Keys::generate();
|
||||
let backup_present_at_persist = std::cell::Cell::new(false);
|
||||
let _guard = state.identity_mutation.lock().unwrap();
|
||||
let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| {
|
||||
// Ordering probe: the old backup must still exist while
|
||||
// persistence is running (cleanup has not happened yet).
|
||||
backup_present_at_persist.set(backup_path.exists());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
backup_present_at_persist.get(),
|
||||
"cleanup must not precede persist"
|
||||
);
|
||||
assert_eq!(pubkey, new_keys.public_key());
|
||||
assert_eq!(
|
||||
state.keys.lock().unwrap().public_key(),
|
||||
new_keys.public_key()
|
||||
);
|
||||
assert!(
|
||||
!backup_path.exists(),
|
||||
"stale backup must be removed post-commit"
|
||||
);
|
||||
}
|
||||
|
||||
/// Concurrent identity swap vs backup creation: `identity_mutation`
|
||||
/// serializes both, so every persisted blob decrypts to the identity that
|
||||
/// was live for the whole of its create operation — never a torn state.
|
||||
#[test]
|
||||
fn concurrent_identity_swap_vs_backup_is_serialized() {
|
||||
let state = std::sync::Arc::new(build_app_state());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key_a = state.keys.lock().unwrap().clone();
|
||||
let key_b = Keys::generate();
|
||||
|
||||
let swapper = {
|
||||
let state = state.clone();
|
||||
let key_b = key_b.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Mirrors import_identity's locking: mutation guard held
|
||||
// across the key swap.
|
||||
let _guard = state.identity_mutation.lock().unwrap();
|
||||
*state.keys.lock().unwrap() = key_b;
|
||||
})
|
||||
};
|
||||
|
||||
let backup =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
swapper.join().unwrap();
|
||||
|
||||
let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD)
|
||||
.unwrap()
|
||||
.public_key();
|
||||
assert!(
|
||||
recovered == key_a.public_key() || recovered == key_b.public_key(),
|
||||
"backup must match one coherent identity"
|
||||
);
|
||||
// Whichever won, the persisted file equals the returned blob.
|
||||
let on_disk =
|
||||
std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap();
|
||||
assert_eq!(on_disk, backup);
|
||||
}
|
||||
}
|
||||
#[path = "identity_key_backup_tests.rs"]
|
||||
mod key_backup_command_tests;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use super::{create_and_persist_backup_with_log_n, verify_ncryptsec_backup_inner};
|
||||
use crate::app_state::build_app_state;
|
||||
use nostr::Keys;
|
||||
|
||||
/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered
|
||||
/// once in key_backup_tests::round_trip_at_production_cost.
|
||||
const FAST_LOG_N: u8 = 16;
|
||||
const PASSWORD: &str = "correct horse battery";
|
||||
|
||||
#[test]
|
||||
fn returned_bytes_equal_on_disk_bytes() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let returned =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
|
||||
let path = crate::key_backup::backup_file_path(dir.path());
|
||||
let on_disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(
|
||||
returned, on_disk,
|
||||
"webview must receive the exact persisted bytes"
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
|
||||
// And the persisted blob provably recovers the live identity.
|
||||
let keys = state.keys.lock().unwrap().clone();
|
||||
let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap();
|
||||
assert_eq!(recovered.public_key(), keys.public_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_returns_only_public_identity_and_match_status() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let backup =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap();
|
||||
assert_eq!(
|
||||
result.pubkey,
|
||||
state.keys.lock().unwrap().public_key().to_hex()
|
||||
);
|
||||
assert!(result.npub.starts_with("npub1"));
|
||||
assert!(result.matches_current_identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_reports_valid_backup_for_a_different_identity() {
|
||||
let state = build_app_state();
|
||||
let other = Keys::generate();
|
||||
let backup = crate::key_backup::create_backup_blob(&other, PASSWORD, FAST_LOG_N).unwrap();
|
||||
let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap();
|
||||
assert_eq!(result.pubkey, other.public_key().to_hex());
|
||||
assert!(!result.matches_current_identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_rejects_wrong_password() {
|
||||
let state = build_app_state();
|
||||
let backup =
|
||||
crate::key_backup::create_backup_blob(&Keys::generate(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
assert_eq!(
|
||||
verify_ncryptsec_backup_inner(&state, &backup, "wrong password").unwrap_err(),
|
||||
"wrong backup password or damaged key backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_replaces_atomically() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
let second =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), "another passphrase", FAST_LOG_N)
|
||||
.unwrap();
|
||||
assert_ne!(first, second, "fresh salt/nonce per action");
|
||||
|
||||
let path = crate::key_backup::backup_file_path(dir.path());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_passphrase() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N).unwrap_err();
|
||||
assert!(err.contains("at least"), "{err}");
|
||||
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_mode_blocks_backup_creation() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
state
|
||||
.identity_lost
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
assert!(
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
|
||||
"lost identity must not be backed up"
|
||||
);
|
||||
state
|
||||
.identity_lost
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
|
||||
state
|
||||
.keyring_locked
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
assert!(
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
|
||||
"locked keyring must not be backed up"
|
||||
);
|
||||
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
|
||||
}
|
||||
|
||||
/// Blocker-1 regression (Wren, implementation review): a failed
|
||||
/// different-key import must leave BOTH the old in-memory identity and
|
||||
/// the old canonical backup intact. Persistence runs before cleanup, so
|
||||
/// an `Err` from persist means nothing was mutated or deleted.
|
||||
#[test]
|
||||
fn failed_import_persistence_preserves_old_identity_and_backup() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old_pubkey = state.keys.lock().unwrap().public_key();
|
||||
|
||||
// A valid canonical backup for the live (old) identity.
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
let backup_path = crate::key_backup::backup_file_path(dir.path());
|
||||
let backup_before = std::fs::read_to_string(&backup_path).unwrap();
|
||||
|
||||
// Different-key import whose durable persistence fails (both
|
||||
// keyring and file fallback down).
|
||||
let _guard = state.identity_mutation.lock().unwrap();
|
||||
let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| {
|
||||
Err("keyring and file both unavailable".to_string())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("unavailable"), "{err}");
|
||||
|
||||
// Old identity still live; old backup untouched byte-for-byte.
|
||||
assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&backup_path).unwrap(),
|
||||
backup_before
|
||||
);
|
||||
assert!(
|
||||
crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD)
|
||||
.unwrap()
|
||||
.public_key()
|
||||
== old_pubkey,
|
||||
"surviving backup must still recover the still-live identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// Successful different-key import removes the previous identity's
|
||||
/// backup — cleanup runs after the durable commit, not before.
|
||||
#[test]
|
||||
fn successful_import_removes_stale_backup_after_commit() {
|
||||
let state = build_app_state();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
let backup_path = crate::key_backup::backup_file_path(dir.path());
|
||||
assert!(backup_path.exists());
|
||||
|
||||
let new_keys = Keys::generate();
|
||||
let backup_present_at_persist = std::cell::Cell::new(false);
|
||||
let _guard = state.identity_mutation.lock().unwrap();
|
||||
let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| {
|
||||
// Ordering probe: the old backup must still exist while
|
||||
// persistence is running (cleanup has not happened yet).
|
||||
backup_present_at_persist.set(backup_path.exists());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
backup_present_at_persist.get(),
|
||||
"cleanup must not precede persist"
|
||||
);
|
||||
assert_eq!(pubkey, new_keys.public_key());
|
||||
assert_eq!(
|
||||
state.keys.lock().unwrap().public_key(),
|
||||
new_keys.public_key()
|
||||
);
|
||||
assert!(
|
||||
!backup_path.exists(),
|
||||
"stale backup must be removed post-commit"
|
||||
);
|
||||
}
|
||||
|
||||
/// Concurrent identity swap vs backup creation: `identity_mutation`
|
||||
/// serializes both, so every persisted blob decrypts to the identity that
|
||||
/// was live for the whole of its create operation — never a torn state.
|
||||
#[test]
|
||||
fn concurrent_identity_swap_vs_backup_is_serialized() {
|
||||
let state = std::sync::Arc::new(build_app_state());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key_a = state.keys.lock().unwrap().clone();
|
||||
let key_b = Keys::generate();
|
||||
|
||||
let swapper = {
|
||||
let state = state.clone();
|
||||
let key_b = key_b.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Mirrors import_identity's locking: mutation guard held
|
||||
// across the key swap.
|
||||
let _guard = state.identity_mutation.lock().unwrap();
|
||||
*state.keys.lock().unwrap() = key_b;
|
||||
})
|
||||
};
|
||||
|
||||
let backup =
|
||||
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
|
||||
swapper.join().unwrap();
|
||||
|
||||
let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD)
|
||||
.unwrap()
|
||||
.public_key();
|
||||
assert!(
|
||||
recovered == key_a.public_key() || recovered == key_b.public_key(),
|
||||
"backup must match one coherent identity"
|
||||
);
|
||||
// Whichever won, the persisted file equals the returned blob.
|
||||
let on_disk = std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap();
|
||||
assert_eq!(on_disk, backup);
|
||||
}
|
||||
@@ -399,6 +399,7 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() {
|
||||
"src/egress_guard.rs",
|
||||
"src/egress_guard_tests.rs",
|
||||
"src/commands/identity.rs",
|
||||
"src/commands/identity_key_backup_tests.rs",
|
||||
"src/lib.rs", // module registration + invoke handler
|
||||
// boundary wiring (guard call sites name the module, not the codec):
|
||||
"src/relay.rs",
|
||||
|
||||
@@ -37,9 +37,15 @@ pub const BACKUP_LOG_N: u8 = 18;
|
||||
/// Filename of the app-managed canonical backup inside the app data dir.
|
||||
pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec";
|
||||
|
||||
/// Number of words in a generated backup passphrase. Six words from a
|
||||
/// 1296-word list ≈ 62 bits of entropy before the scrypt work factor.
|
||||
const PASSPHRASE_WORDS: usize = 6;
|
||||
/// Default number of words in a generated backup passphrase. Three words
|
||||
/// from a 1296-word list ≈ 31 bits of entropy before the scrypt work factor.
|
||||
pub const DEFAULT_PASSPHRASE_WORDS: usize = 3;
|
||||
|
||||
/// Bounds for the generator's word-count control. At the lower bound a draw
|
||||
/// can fall below [`MIN_PASSPHRASE_LEN`] (three 3-char words), so
|
||||
/// [`generate_passphrase`] re-draws until the phrase meets the minimum.
|
||||
pub const MIN_PASSPHRASE_WORDS: usize = 3;
|
||||
pub const MAX_PASSPHRASE_WORDS: usize = 10;
|
||||
|
||||
/// EFF short wordlist 2.0 (1296 words, one per line).
|
||||
const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt");
|
||||
@@ -100,7 +106,7 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result<Keys, String> {
|
||||
let encrypted = parse_ncryptsec(input)?;
|
||||
let secret_key = encrypted
|
||||
.decrypt(password)
|
||||
.map_err(|_| "wrong passphrase or corrupted backup".to_string())?;
|
||||
.map_err(|_| "wrong backup password or damaged key backup".to_string())?;
|
||||
Ok(Keys::new(secret_key))
|
||||
}
|
||||
|
||||
@@ -119,8 +125,7 @@ pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result<Ke
|
||||
.get(..NCRYPTSEC_HRP.len())
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case(NCRYPTSEC_HRP));
|
||||
if hrp_match {
|
||||
let password =
|
||||
password.ok_or_else(|| "encrypted backup requires a passphrase".to_string())?;
|
||||
let password = password.ok_or_else(|| "key backup requires a password".to_string())?;
|
||||
decrypt_ncryptsec(trimmed, password)
|
||||
} else {
|
||||
Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))
|
||||
@@ -190,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!(
|
||||
@@ -202,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)]
|
||||
|
||||
@@ -41,7 +41,7 @@ fn wrong_password_is_a_friendly_error() {
|
||||
let keys = Keys::generate();
|
||||
let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap();
|
||||
let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err();
|
||||
assert_eq!(err, "wrong passphrase or corrupted backup");
|
||||
assert_eq!(err, "wrong backup password or damaged key backup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -90,13 +90,13 @@ fn recover_keys_ncryptsec_happy_path() {
|
||||
#[test]
|
||||
fn recover_keys_ncryptsec_requires_password() {
|
||||
let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err();
|
||||
assert_eq!(err, "encrypted backup requires a passphrase");
|
||||
assert_eq!(err, "key backup requires a password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_keys_ncryptsec_wrong_password() {
|
||||
let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err();
|
||||
assert_eq!(err, "wrong passphrase or corrupted backup");
|
||||
assert_eq!(err, "wrong backup password or damaged key backup");
|
||||
}
|
||||
|
||||
/// Bech32 permits an all-uppercase encoding: `NCRYPTSEC1…` must classify as
|
||||
@@ -108,7 +108,7 @@ fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() {
|
||||
let upper = SPEC_NCRYPTSEC.to_ascii_uppercase();
|
||||
// Routing proof: encrypted path demands a passphrase.
|
||||
let err = recover_keys_from_input(&upper, None).unwrap_err();
|
||||
assert_eq!(err, "encrypted backup requires a passphrase");
|
||||
assert_eq!(err, "key backup requires a password");
|
||||
// With the passphrase, the bech32 decoder accepts the uppercase form.
|
||||
let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap();
|
||||
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
|
||||
@@ -198,28 +198,42 @@ fn cleanup_stale_backup_removes_only_on_identity_change() {
|
||||
// ── Passphrase generation ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn generated_passphrase_is_six_known_words() {
|
||||
fn generated_passphrase_respects_word_count_and_separator() {
|
||||
let words: std::collections::HashSet<&str> =
|
||||
WORDLIST.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(words.len(), 1296, "EFF short wordlist 2.0 has 1296 words");
|
||||
|
||||
for _ in 0..8 {
|
||||
let phrase = generate_passphrase().unwrap();
|
||||
let parts: Vec<&str> = phrase.split(' ').collect();
|
||||
assert_eq!(parts.len(), 6);
|
||||
for w in &parts {
|
||||
assert!(words.contains(w), "unknown word {w:?}");
|
||||
for (count, separator) in [(3, "-"), (4, "-"), (6, " "), (5, "."), (10, "")] {
|
||||
let phrase = generate_passphrase(count, separator).unwrap();
|
||||
if separator.is_empty() {
|
||||
// No separator to split on; length gate below still applies.
|
||||
} else {
|
||||
let parts: Vec<&str> = phrase.split(separator).collect();
|
||||
assert_eq!(parts.len(), count);
|
||||
for w in &parts {
|
||||
assert!(words.contains(w), "unknown word {w:?}");
|
||||
}
|
||||
}
|
||||
assert!(phrase.chars().count() >= MIN_PASSPHRASE_LEN);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_passphrase_clamps_word_count() {
|
||||
// Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter.
|
||||
let phrase = generate_passphrase(1, "-").unwrap();
|
||||
assert_eq!(phrase.split('-').count(), MIN_PASSPHRASE_WORDS);
|
||||
// Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS.
|
||||
let phrase = generate_passphrase(50, "-").unwrap();
|
||||
assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_passphrases_are_not_repeated() {
|
||||
// 6 words × ~10.3 bits each — a collision across 8 draws would indicate a
|
||||
// 3 words × ~10.3 bits each — a collision across 8 draws would indicate a
|
||||
// broken entropy source, not bad luck.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..8 {
|
||||
assert!(seen.insert(generate_passphrase().unwrap()));
|
||||
assert!(seen.insert(generate_passphrase(DEFAULT_PASSPHRASE_WORDS, "-").unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +682,7 @@ pub fn run() {
|
||||
get_nsec,
|
||||
generate_backup_passphrase,
|
||||
create_ncryptsec_backup,
|
||||
verify_ncryptsec_backup,
|
||||
save_ncryptsec_copy,
|
||||
import_identity,
|
||||
persist_current_identity,
|
||||
|
||||
@@ -103,7 +103,7 @@ export function WelcomeSetup({
|
||||
data-system-color-scheme={systemColorScheme}
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
<OnboardingChrome current={5} />
|
||||
<OnboardingChrome current={6} />
|
||||
<OnboardingFooterProvider>
|
||||
<div className="relative flex min-h-0 w-full max-w-[920px] flex-1 flex-col items-center text-center">
|
||||
{page === "welcome" ? (
|
||||
|
||||
@@ -1,156 +1,117 @@
|
||||
/**
|
||||
* Pure-logic tests for the encrypted-backup (NIP-49) creation state model.
|
||||
* These drive the same reducer + validation helpers the BackupStep and
|
||||
* settings row use, without a DOM.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
MIN_CUSTOM_PASSPHRASE_LEN,
|
||||
createDisabled,
|
||||
customPassphraseIssue,
|
||||
MIN_PASSPHRASE_LEN,
|
||||
downloadDisabled,
|
||||
isEncrypting,
|
||||
passphraseIssue,
|
||||
pendingEncryptPassphrase,
|
||||
effectivePassphrase,
|
||||
encryptedBackupReducer,
|
||||
initialEncryptedBackupState,
|
||||
} from "./encryptedBackup.ts";
|
||||
|
||||
function reduce(events, from = initialEncryptedBackupState) {
|
||||
return events.reduce(encryptedBackupReducer, from);
|
||||
}
|
||||
|
||||
// ── generated-passphrase mode (default) ─────────────────────────────────────
|
||||
|
||||
test("create_disabled_until_generated_passphrase_arrives", () => {
|
||||
assert.equal(createDisabled(initialEncryptedBackupState), true);
|
||||
const ready = reduce([
|
||||
{
|
||||
type: "passphrase-generated",
|
||||
passphrase: "alpha bravo carbon delta echo fox",
|
||||
},
|
||||
]);
|
||||
assert.equal(createDisabled(ready), false);
|
||||
assert.equal(effectivePassphrase(ready), "alpha bravo carbon delta echo fox");
|
||||
});
|
||||
|
||||
test("regenerate_replaces_passphrase_and_clears_generate_error", () => {
|
||||
const failed = reduce([
|
||||
{ type: "passphrase-generate-failed", message: "boom" },
|
||||
]);
|
||||
assert.equal(failed.generateError, "boom");
|
||||
const recovered = reduce(
|
||||
[{ type: "passphrase-generated", passphrase: "a b c d e f" }],
|
||||
failed,
|
||||
);
|
||||
assert.equal(recovered.generateError, null);
|
||||
assert.equal(recovered.generatedPassphrase, "a b c d e f");
|
||||
});
|
||||
|
||||
// ── custom-passphrase mode ───────────────────────────────────────────────────
|
||||
|
||||
test("custom_mode_requires_min_length_and_matching_confirm", () => {
|
||||
const base = reduce([
|
||||
{ type: "passphrase-generated", passphrase: "gen gen gen gen gen gen" },
|
||||
{ type: "set-mode", mode: "custom" },
|
||||
]);
|
||||
|
||||
// Too short — even though a generated passphrase exists, custom mode must
|
||||
// not silently fall back to it.
|
||||
const short = reduce(
|
||||
[
|
||||
{ type: "set-custom-passphrase", value: "short" },
|
||||
{ type: "set-custom-confirm", value: "short" },
|
||||
],
|
||||
base,
|
||||
);
|
||||
assert.equal(effectivePassphrase(short), null);
|
||||
assert.equal(createDisabled(short), true);
|
||||
|
||||
// Long enough but mismatched confirm.
|
||||
const mismatched = reduce(
|
||||
[
|
||||
{
|
||||
type: "set-custom-passphrase",
|
||||
value: "a".repeat(MIN_CUSTOM_PASSPHRASE_LEN),
|
||||
},
|
||||
{
|
||||
type: "set-custom-confirm",
|
||||
value: "b".repeat(MIN_CUSTOM_PASSPHRASE_LEN),
|
||||
},
|
||||
],
|
||||
base,
|
||||
);
|
||||
assert.equal(effectivePassphrase(mismatched), null);
|
||||
|
||||
// Valid.
|
||||
const ok = reduce(
|
||||
[
|
||||
{ type: "set-custom-passphrase", value: "correct horse battery" },
|
||||
{ type: "set-custom-confirm", value: "correct horse battery" },
|
||||
],
|
||||
base,
|
||||
);
|
||||
assert.equal(effectivePassphrase(ok), "correct horse battery");
|
||||
assert.equal(createDisabled(ok), false);
|
||||
});
|
||||
|
||||
test("custom_passphrase_issue_messages", () => {
|
||||
// Empty input: no scolding while the user hasn't typed anything.
|
||||
assert.equal(customPassphraseIssue("", ""), null);
|
||||
assert.match(
|
||||
customPassphraseIssue("short", ""),
|
||||
new RegExp(`${MIN_CUSTOM_PASSPHRASE_LEN}`),
|
||||
);
|
||||
// Mismatch is only reported once confirm has content.
|
||||
assert.equal(customPassphraseIssue("a".repeat(12), ""), null);
|
||||
assert.match(customPassphraseIssue("a".repeat(12), "b"), /match/);
|
||||
assert.equal(customPassphraseIssue("a".repeat(12), "a".repeat(12)), null);
|
||||
});
|
||||
|
||||
test("min_length_counts_code_points_not_utf16_units", () => {
|
||||
// 12 astral-plane emoji = 24 UTF-16 units but 12 code points; mirrors the
|
||||
// Rust chars().count() gate so both sides agree on the boundary.
|
||||
const emoji = "😀".repeat(MIN_CUSTOM_PASSPHRASE_LEN);
|
||||
assert.equal(customPassphraseIssue(emoji, emoji), null);
|
||||
const ready = reduce([
|
||||
{ type: "set-mode", mode: "custom" },
|
||||
{ type: "set-custom-passphrase", value: emoji },
|
||||
{ type: "set-custom-confirm", value: emoji },
|
||||
]);
|
||||
assert.equal(effectivePassphrase(ready), emoji);
|
||||
});
|
||||
|
||||
// ── create lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
test("create_lifecycle_happy_path_and_failure", () => {
|
||||
const ready = reduce([
|
||||
{ type: "passphrase-generated", passphrase: "one two three four five six" },
|
||||
]);
|
||||
|
||||
const creating = reduce([{ type: "create-started" }], ready);
|
||||
assert.equal(creating.isCreating, true);
|
||||
const reduce = (events, from = initialEncryptedBackupState) =>
|
||||
events.reduce(encryptedBackupReducer, from);
|
||||
test("password validation mirrors Rust character counting", () => {
|
||||
assert.equal(passphraseIssue(""), null);
|
||||
assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`));
|
||||
const emoji = "😀".repeat(MIN_PASSPHRASE_LEN);
|
||||
assert.equal(passphraseIssue(emoji), null);
|
||||
assert.equal(
|
||||
createDisabled(creating),
|
||||
true,
|
||||
"no double-create while KDF runs",
|
||||
effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])),
|
||||
emoji,
|
||||
);
|
||||
});
|
||||
test("valid password requests encryption without copying it into events", () => {
|
||||
const ready = reduce([
|
||||
{ type: "set-passphrase", value: "one-two-three-four" },
|
||||
]);
|
||||
assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four");
|
||||
const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready);
|
||||
assert.equal(isEncrypting(started), true);
|
||||
assert.equal(started.requestId, 1);
|
||||
assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false);
|
||||
});
|
||||
test("success clears password and retains only encrypted blob", () => {
|
||||
const state = reduce([
|
||||
{ type: "set-passphrase", value: "one-two-three-four" },
|
||||
{ type: "encrypt-started", requestId: 1 },
|
||||
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
|
||||
]);
|
||||
assert.equal(state.passphrase, "");
|
||||
assert.equal(state.encrypted, "ncryptsec1abc");
|
||||
assert.equal(state.savedPassword, true);
|
||||
assert.equal(state.requestId, null);
|
||||
});
|
||||
test("stale async completions cannot replace current request", () => {
|
||||
const state = reduce([
|
||||
{ type: "set-passphrase", value: "one-two-three-four" },
|
||||
{ type: "encrypt-started", requestId: 1 },
|
||||
{ type: "set-passphrase", value: "five-six-seven-eight" },
|
||||
{ type: "encrypt-started", requestId: 2 },
|
||||
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" },
|
||||
]);
|
||||
assert.equal(state.requestId, 2);
|
||||
assert.equal(state.encrypted, null);
|
||||
assert.equal(state.passphrase, "five-six-seven-eight");
|
||||
});
|
||||
test("failure clears submitted password", () => {
|
||||
const state = reduce([
|
||||
{ type: "set-passphrase", value: "one-two-three-four" },
|
||||
{ type: "encrypt-started", requestId: 1 },
|
||||
{ type: "download-clicked" },
|
||||
{ type: "encrypt-failed", requestId: 1, message: "keychain unavailable" },
|
||||
]);
|
||||
assert.equal(state.passphrase, "");
|
||||
assert.equal(state.createError, "keychain unavailable");
|
||||
assert.equal(state.downloadPending, false);
|
||||
assert.equal(downloadDisabled(state), true);
|
||||
});
|
||||
test("queued download commits and clears password", () => {
|
||||
const state = reduce([
|
||||
{ type: "set-passphrase", value: "one-two-three-four" },
|
||||
{ type: "encrypt-started", requestId: 1 },
|
||||
{ type: "download-clicked" },
|
||||
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
|
||||
]);
|
||||
assert.equal(state.ncryptsec, "ncryptsec1abc");
|
||||
assert.equal(state.passphrase, "");
|
||||
assert.equal(state.savedPassword, true);
|
||||
});
|
||||
test("Back preserves blob for immediate re-download without password", () => {
|
||||
const made = reduce([
|
||||
{ type: "set-passphrase", value: "one-two-three-four" },
|
||||
{ type: "encrypt-started", requestId: 1 },
|
||||
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
|
||||
{ type: "download-clicked" },
|
||||
{ type: "back-to-password" },
|
||||
]);
|
||||
assert.equal(made.ncryptsec, "ncryptsec1abc");
|
||||
assert.equal(made.passphrase, "");
|
||||
assert.equal(downloadDisabled(made), false);
|
||||
});
|
||||
test("starting over discards blob and invalidates late requests", () => {
|
||||
const made = {
|
||||
...initialEncryptedBackupState,
|
||||
ncryptsec: "ncryptsec1abc",
|
||||
encrypted: "ncryptsec1abc",
|
||||
savedPassword: true,
|
||||
nextRequestId: 3,
|
||||
};
|
||||
const fresh = reduce([{ type: "start-new-backup" }], made);
|
||||
assert.equal(fresh.ncryptsec, null);
|
||||
assert.equal(fresh.nextRequestId, 4);
|
||||
assert.equal(
|
||||
reduce(
|
||||
[
|
||||
{
|
||||
type: "encrypt-succeeded",
|
||||
requestId: 2,
|
||||
ncryptsec: "ncryptsec1stale",
|
||||
},
|
||||
],
|
||||
fresh,
|
||||
).ncryptsec,
|
||||
null,
|
||||
);
|
||||
|
||||
const failed = reduce(
|
||||
[{ type: "create-failed", message: "keychain unavailable" }],
|
||||
creating,
|
||||
);
|
||||
assert.equal(failed.isCreating, false);
|
||||
assert.equal(failed.createError, "keychain unavailable");
|
||||
assert.equal(createDisabled(failed), false, "retry allowed after failure");
|
||||
|
||||
const done = reduce(
|
||||
[
|
||||
{ type: "create-started" },
|
||||
{ type: "create-succeeded", ncryptsec: "ncryptsec1abc" },
|
||||
],
|
||||
failed,
|
||||
);
|
||||
assert.equal(done.isCreating, false);
|
||||
assert.equal(done.ncryptsec, "ncryptsec1abc");
|
||||
assert.equal(done.createError, null);
|
||||
});
|
||||
|
||||
@@ -1,117 +1,128 @@
|
||||
/**
|
||||
* Pure state model for the encrypted-key-backup (NIP-49) creation flow,
|
||||
* shared by the onboarding BackupStep and the settings Password Backup row.
|
||||
*
|
||||
* All validation and phase logic lives here so it can be unit-tested without
|
||||
* React. Hosts wire the reducer to the Tauri commands
|
||||
* (`generate_backup_passphrase`, `create_ncryptsec_backup`) and dispatch
|
||||
* events; the model never touches the raw private key — by construction the
|
||||
* default backup path cannot invoke `get_nsec`.
|
||||
*/
|
||||
|
||||
export type PassphraseMode = "generated" | "custom";
|
||||
|
||||
/** Mirrors `MIN_PASSPHRASE_LEN` in `src-tauri/src/key_backup.rs`. */
|
||||
export const MIN_CUSTOM_PASSPHRASE_LEN = 12;
|
||||
/** Pure state model for NIP-49 backup creation. */
|
||||
export const MIN_PASSPHRASE_LEN = 12;
|
||||
|
||||
export type EncryptedBackupState = {
|
||||
/** Six-word passphrase generated in Rust; null until loaded. */
|
||||
generatedPassphrase: string | null;
|
||||
generateError: string | null;
|
||||
mode: PassphraseMode;
|
||||
customPassphrase: string;
|
||||
customConfirm: string;
|
||||
isCreating: boolean;
|
||||
passphrase: string;
|
||||
requestId: number | null;
|
||||
nextRequestId: number;
|
||||
encrypted: string | null;
|
||||
createError: string | null;
|
||||
/** The persisted `ncryptsec1…` blob once the backup exists. */
|
||||
downloadPending: boolean;
|
||||
ncryptsec: string | null;
|
||||
savedPassword: boolean;
|
||||
};
|
||||
|
||||
export const initialEncryptedBackupState: EncryptedBackupState = {
|
||||
generatedPassphrase: null,
|
||||
generateError: null,
|
||||
mode: "generated",
|
||||
customPassphrase: "",
|
||||
customConfirm: "",
|
||||
isCreating: false,
|
||||
passphrase: "",
|
||||
requestId: null,
|
||||
nextRequestId: 1,
|
||||
encrypted: null,
|
||||
createError: null,
|
||||
downloadPending: false,
|
||||
ncryptsec: null,
|
||||
savedPassword: false,
|
||||
};
|
||||
|
||||
export type EncryptedBackupEvent =
|
||||
| { type: "passphrase-generated"; passphrase: string }
|
||||
| { type: "passphrase-generate-failed"; message: string }
|
||||
| { type: "set-mode"; mode: PassphraseMode }
|
||||
| { type: "set-custom-passphrase"; value: string }
|
||||
| { type: "set-custom-confirm"; value: string }
|
||||
| { type: "create-started" }
|
||||
| { type: "create-succeeded"; ncryptsec: string }
|
||||
| { type: "create-failed"; message: string };
|
||||
| { type: "set-passphrase"; value: string }
|
||||
| { type: "encrypt-started"; requestId: number }
|
||||
| { type: "encrypt-succeeded"; requestId: number; ncryptsec: string }
|
||||
| { type: "encrypt-failed"; requestId: number; message: string }
|
||||
| { type: "download-clicked" }
|
||||
| { type: "back-to-password" }
|
||||
| { type: "start-new-backup" };
|
||||
|
||||
export function encryptedBackupReducer(
|
||||
state: EncryptedBackupState,
|
||||
event: EncryptedBackupEvent,
|
||||
): EncryptedBackupState {
|
||||
switch (event.type) {
|
||||
case "passphrase-generated":
|
||||
case "set-passphrase":
|
||||
return {
|
||||
...state,
|
||||
generatedPassphrase: event.passphrase,
|
||||
generateError: null,
|
||||
passphrase: event.value,
|
||||
encrypted: null,
|
||||
createError: null,
|
||||
};
|
||||
case "encrypt-started":
|
||||
return {
|
||||
...state,
|
||||
requestId: event.requestId,
|
||||
nextRequestId: Math.max(state.nextRequestId, event.requestId + 1),
|
||||
createError: null,
|
||||
};
|
||||
case "encrypt-succeeded":
|
||||
if (event.requestId !== state.requestId) return state;
|
||||
return {
|
||||
...state,
|
||||
passphrase: "",
|
||||
requestId: null,
|
||||
encrypted: event.ncryptsec,
|
||||
ncryptsec: state.downloadPending ? event.ncryptsec : state.ncryptsec,
|
||||
downloadPending: false,
|
||||
savedPassword: true,
|
||||
};
|
||||
case "encrypt-failed":
|
||||
if (event.requestId !== state.requestId) return state;
|
||||
return {
|
||||
...state,
|
||||
passphrase: "",
|
||||
requestId: null,
|
||||
createError: event.message,
|
||||
downloadPending: false,
|
||||
};
|
||||
case "download-clicked":
|
||||
if (
|
||||
state.ncryptsec ||
|
||||
state.downloadPending ||
|
||||
(!state.encrypted && !effectivePassphrase(state))
|
||||
)
|
||||
return state;
|
||||
return state.encrypted
|
||||
? {
|
||||
...state,
|
||||
ncryptsec: state.encrypted,
|
||||
passphrase: "",
|
||||
savedPassword: true,
|
||||
}
|
||||
: { ...state, downloadPending: true };
|
||||
case "back-to-password":
|
||||
return { ...state, createError: null };
|
||||
case "start-new-backup":
|
||||
return {
|
||||
...initialEncryptedBackupState,
|
||||
nextRequestId: state.nextRequestId + 1,
|
||||
};
|
||||
case "passphrase-generate-failed":
|
||||
return { ...state, generateError: event.message };
|
||||
case "set-mode":
|
||||
// Editing state carries across toggles; validation re-derives.
|
||||
return { ...state, mode: event.mode, createError: null };
|
||||
case "set-custom-passphrase":
|
||||
return { ...state, customPassphrase: event.value, createError: null };
|
||||
case "set-custom-confirm":
|
||||
return { ...state, customConfirm: event.value, createError: null };
|
||||
case "create-started":
|
||||
return { ...state, isCreating: true, createError: null };
|
||||
case "create-succeeded":
|
||||
return { ...state, isCreating: false, ncryptsec: event.ncryptsec };
|
||||
case "create-failed":
|
||||
return { ...state, isCreating: false, createError: event.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation issue for a custom passphrase, or null when acceptable.
|
||||
* Confirm mismatch is only reported once the confirm field has content, so
|
||||
* the user isn't scolded mid-typing.
|
||||
*/
|
||||
export function customPassphraseIssue(
|
||||
passphrase: string,
|
||||
confirm: string,
|
||||
): string | null {
|
||||
export function passphraseIssue(passphrase: string): string | null {
|
||||
if (passphrase.length === 0) return null;
|
||||
if ([...passphrase].length < MIN_CUSTOM_PASSPHRASE_LEN) {
|
||||
return `Use at least ${MIN_CUSTOM_PASSPHRASE_LEN} characters.`;
|
||||
}
|
||||
if (confirm.length > 0 && passphrase !== confirm) {
|
||||
return "Passphrases don't match.";
|
||||
}
|
||||
return null;
|
||||
return [...passphrase].length < MIN_PASSPHRASE_LEN
|
||||
? `Use at least ${MIN_PASSPHRASE_LEN} characters.`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The passphrase the Create action would submit, or null when not ready. */
|
||||
export function effectivePassphrase(
|
||||
state: EncryptedBackupState,
|
||||
): string | null {
|
||||
if (state.mode === "generated") return state.generatedPassphrase;
|
||||
const { customPassphrase, customConfirm } = state;
|
||||
if (
|
||||
[...customPassphrase].length < MIN_CUSTOM_PASSPHRASE_LEN ||
|
||||
customPassphrase !== customConfirm
|
||||
) {
|
||||
return [...state.passphrase].length < MIN_PASSPHRASE_LEN
|
||||
? null
|
||||
: state.passphrase;
|
||||
}
|
||||
export function pendingEncryptPassphrase(
|
||||
state: EncryptedBackupState,
|
||||
): string | null {
|
||||
if (state.savedPassword || state.encrypted || state.requestId !== null)
|
||||
return null;
|
||||
}
|
||||
return customPassphrase;
|
||||
return effectivePassphrase(state);
|
||||
}
|
||||
|
||||
/** Whether the "Create backup" action is currently actionable. */
|
||||
export function createDisabled(state: EncryptedBackupState): boolean {
|
||||
return state.isCreating || effectivePassphrase(state) === null;
|
||||
export function isEncrypting(state: EncryptedBackupState): boolean {
|
||||
return state.requestId !== null;
|
||||
}
|
||||
export function downloadDisabled(state: EncryptedBackupState): boolean {
|
||||
if (state.savedPassword && state.ncryptsec) return false;
|
||||
return (
|
||||
state.downloadPending ||
|
||||
(!state.encrypted && effectivePassphrase(state) === null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,76 @@
|
||||
import { AlertTriangle, Info, RefreshCw } from "lucide-react";
|
||||
import { Check, Copy, Eye, EyeOff, Info } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { getNsec } from "@/shared/api/tauriIdentity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
import {
|
||||
type OnboardingTransitionDirection,
|
||||
OnboardingSlideTransition,
|
||||
} from "./OnboardingSlideTransition";
|
||||
import { EncryptedBackupCreator } from "./EncryptedBackupCreator";
|
||||
import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
|
||||
|
||||
export type BackupStepMode = "encrypted" | "raw";
|
||||
import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
|
||||
|
||||
/**
|
||||
* Pure helper so the disabled logic can be unit-tested without a DOM.
|
||||
*
|
||||
* Encrypted mode (default): Next unlocks once the backup blob exists — the
|
||||
* user must either create a backup or explicitly switch to the raw key.
|
||||
* Raw mode: disabled while loading or after a failed load (only the explicit
|
||||
* "Skip for now" ghost advances past an error), matching the previous flow.
|
||||
* How long the "Creating your identity key" loader holds the stage before the
|
||||
* finished state fades in. Purely perceptual — the key already exists; the
|
||||
* pause sells the creation moment.
|
||||
*/
|
||||
export function backupNextDisabled({
|
||||
mode,
|
||||
hasBackup,
|
||||
isLoading,
|
||||
loadError,
|
||||
}: {
|
||||
mode: BackupStepMode;
|
||||
hasBackup: boolean;
|
||||
isLoading: boolean;
|
||||
loadError: string | null;
|
||||
}): boolean {
|
||||
if (mode === "encrypted") {
|
||||
return !hasBackup;
|
||||
}
|
||||
return isLoading || loadError !== null;
|
||||
const INTRO_HOLD_MS = 1400;
|
||||
|
||||
/**
|
||||
* The creation moment should only be sold once per app session. Module-level
|
||||
* so remounts (e.g. navigating Back and returning to this step) skip the fake
|
||||
* hold and show the finished state instantly.
|
||||
*/
|
||||
let introPlayed = false;
|
||||
|
||||
const REVEAL_ANIMATION_CLASS =
|
||||
"animate-in fade-in duration-700 motion-reduce:animate-none";
|
||||
|
||||
/** Viewing the key never blocks onboarding — Next is always actionable. */
|
||||
export function backupNextDisabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
type BackupStepProps = {
|
||||
direction: OnboardingTransitionDirection;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
/** Advances to the dedicated "Download your key" onboarding step. */
|
||||
onDownload: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding backup step — encrypted by default. The user protects their
|
||||
* freshly created key with a passphrase and gets a NIP-49 `ncryptsec1…`
|
||||
* backup; the raw key is only fetched (and shown) after an explicit
|
||||
* "Show raw key instead" click. The default path never invokes `get_nsec`.
|
||||
* Onboarding backup step — shows the freshly created key and offers a direct
|
||||
* clipboard copy destined for a password manager. Next leads into the
|
||||
* encrypted download step (its own onboarding page, via `onDownload`), which
|
||||
* is skippable there. The raw key is fetched only when the user explicitly
|
||||
* clicks Copy or Reveal, and is never held in state before that.
|
||||
*/
|
||||
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
const [mode, setMode] = React.useState<BackupStepMode>("encrypted");
|
||||
const [hasBackup, setHasBackup] = React.useState(false);
|
||||
export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
|
||||
const [created, setCreated] = React.useState(introPlayed);
|
||||
const [copyState, setCopyState] = React.useState<
|
||||
"idle" | "copying" | "copied"
|
||||
>("idle");
|
||||
const [copyError, setCopyError] = React.useState<string | null>(null);
|
||||
const [nsec, setNsec] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
const [isRevealed, setIsRevealed] = React.useState(false);
|
||||
const cancelledRef = React.useRef(false);
|
||||
const copiedTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
const loadNsec = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const value = await getNsec();
|
||||
if (!cancelledRef.current) setNsec(value);
|
||||
} catch (err) {
|
||||
if (!cancelledRef.current)
|
||||
setLoadError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to retrieve private key.",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelledRef.current) setIsLoading(false);
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (introPlayed) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
introPlayed = true;
|
||||
setCreated(true);
|
||||
}, INTRO_HOLD_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -86,13 +80,61 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
// nsec from memory on unmount (backup step is only on the fresh-key path).
|
||||
cancelledRef.current = true;
|
||||
setNsec(null);
|
||||
if (copiedTimerRef.current !== null)
|
||||
window.clearTimeout(copiedTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showRawKey = React.useCallback(() => {
|
||||
setMode("raw");
|
||||
void loadNsec();
|
||||
}, [loadNsec]);
|
||||
const copyKeyToClipboard = React.useCallback(async () => {
|
||||
setCopyState("copying");
|
||||
setCopyError(null);
|
||||
try {
|
||||
const value = nsec ?? (await getNsec());
|
||||
await writeTextToClipboard(value);
|
||||
if (cancelledRef.current) return;
|
||||
setCopyState("copied");
|
||||
if (copiedTimerRef.current !== null)
|
||||
window.clearTimeout(copiedTimerRef.current);
|
||||
copiedTimerRef.current = window.setTimeout(() => {
|
||||
if (!cancelledRef.current) setCopyState("idle");
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
setCopyState("idle");
|
||||
setCopyError(
|
||||
err instanceof Error ? err.message : "Failed to retrieve private key.",
|
||||
);
|
||||
}
|
||||
}, [nsec]);
|
||||
|
||||
const toggleReveal = React.useCallback(async () => {
|
||||
if (isRevealed) {
|
||||
setIsRevealed(false);
|
||||
return;
|
||||
}
|
||||
setCopyError(null);
|
||||
try {
|
||||
// The raw key enters the DOM only after this explicit reveal action.
|
||||
const value = nsec ?? (await getNsec());
|
||||
if (cancelledRef.current) return;
|
||||
setNsec(value);
|
||||
setIsRevealed(true);
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
setCopyError(
|
||||
err instanceof Error ? err.message : "Failed to retrieve private key.",
|
||||
);
|
||||
}
|
||||
}, [isRevealed, nsec]);
|
||||
|
||||
// Fixed-length decorative mask (nsec keys are 63 chars) so no key material
|
||||
// is fetched just to render the blurred row. Bullets are joined with a
|
||||
// zero-width space: WebKit won't line-break a run of U+2022 without an
|
||||
// explicit break opportunity, so the masked row would overflow otherwise.
|
||||
const maskedKey = React.useMemo(
|
||||
() => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"),
|
||||
[nsec],
|
||||
);
|
||||
|
||||
return (
|
||||
<OnboardingSlideTransition
|
||||
@@ -102,132 +144,161 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
transitionKey={`backup-${direction}`}
|
||||
>
|
||||
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Your unique identity key has been created
|
||||
</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-foreground/80">
|
||||
{mode === "encrypted"
|
||||
? "Protect it with a passphrase and keep an encrypted backup in case you ever need to restore your account."
|
||||
: "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10">
|
||||
{mode === "encrypted" ? (
|
||||
<Card className="w-full px-8 py-6" variant="textured">
|
||||
<div className="mx-auto w-full max-w-[832px]">
|
||||
<EncryptedBackupCreator
|
||||
onCreated={() => setHasBackup(true)}
|
||||
variant="spotlight"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/70">
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Loading your private key…
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="mx-auto max-w-[500px] space-y-3 text-left">
|
||||
<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-load-error"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Could not retrieve your private key: {loadError}. You can
|
||||
continue and find it later in Settings > Profile >
|
||||
Identity.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 gap-1.5 text-sm"
|
||||
data-testid="backup-retry"
|
||||
onClick={() => void loadNsec()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : nsec ? (
|
||||
<Card className="w-full px-8 py-6" variant="textured">
|
||||
<div className="mx-auto w-full max-w-[832px]">
|
||||
<NsecMaskedDisplay nsec={nsec} variant="bare" />
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<p className="text-center text-sm text-foreground/70">
|
||||
No key available to back up.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mode === "encrypted" && !hasBackup ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
|
||||
data-testid="backup-show-raw-key"
|
||||
onClick={showRawKey}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Show raw key instead
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "raw" && nsec ? (
|
||||
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Never share your private key. Anyone with this key can impersonate
|
||||
you and access everything in your account.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
disabled={backupNextDisabled({
|
||||
mode,
|
||||
hasBackup,
|
||||
isLoading,
|
||||
loadError,
|
||||
})}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
|
||||
text-title size token as conflicting with text-foreground. */}
|
||||
<h1
|
||||
className={`text-title font-normal text-foreground ${REVEAL_ANIMATION_CLASS}`}
|
||||
key={created ? "created" : "creating"}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
{created
|
||||
? "Your unique identity key has been created"
|
||||
: "Creating your identity key"}
|
||||
</h1>
|
||||
{created ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-5 text-sm leading-6 text-foreground/80",
|
||||
REVEAL_ANIMATION_CLASS,
|
||||
)}
|
||||
>
|
||||
Your identity key will be saved to your keychain. Back it up
|
||||
somewhere safe so you can restore your account. Never share your
|
||||
key.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mode === "raw" && loadError ? (
|
||||
{!created ? (
|
||||
<div
|
||||
className="flex w-full flex-1 items-center justify-center py-10"
|
||||
data-testid="backup-intro-logo"
|
||||
>
|
||||
<FuzzyLogo
|
||||
ariaLabel="Creating your identity key"
|
||||
className="w-20! text-foreground"
|
||||
fuzz
|
||||
loop
|
||||
loopRestSeconds={0}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10",
|
||||
REVEAL_ANIMATION_CLASS,
|
||||
)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<Card className="px-8 py-6" variant="textured">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
ONBOARDING_KEY_TEXT_CLASS,
|
||||
isRevealed && nsec
|
||||
? "select-text"
|
||||
: "select-none blur-[4px]",
|
||||
)}
|
||||
data-testid="backup-key-value"
|
||||
>
|
||||
{isRevealed && nsec ? nsec : maskedKey}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
aria-label={
|
||||
isRevealed ? "Hide private key" : "Reveal private key"
|
||||
}
|
||||
className="h-10 w-10 text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-key-reveal-toggle"
|
||||
onClick={() => void toggleReveal()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOff className="h-6 w-6" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Copy private key"
|
||||
className="h-10 w-10 text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-copy-key"
|
||||
disabled={copyState === "copying"}
|
||||
onClick={() => void copyKeyToClipboard()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copyState === "copying" ? (
|
||||
<Spinner className="h-5 w-5 border-2" />
|
||||
) : copyState === "copied" ? (
|
||||
<Check
|
||||
className="h-6 w-6 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Copy className="h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[240px] text-center">
|
||||
Copy your key and save it somewhere safe — a password
|
||||
manager is a great place for it.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{copyError ? (
|
||||
<p
|
||||
className="mt-3 text-center text-sm text-destructive"
|
||||
data-testid="backup-copy-error"
|
||||
>
|
||||
Could not retrieve your private key: {copyError}. You can
|
||||
continue and find it later in Settings > Profile >
|
||||
Identity.
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Never share your private key. Anyone with this key can
|
||||
impersonate you and access everything in your account.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{created ? (
|
||||
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
|
||||
<Button
|
||||
className="h-9 rounded-full px-5 text-muted-foreground hover:text-accent-foreground"
|
||||
data-testid="backup-skip"
|
||||
onClick={onNext}
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
disabled={backupNextDisabled()}
|
||||
onClick={onDownload}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
Back
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</OnboardingFooter>
|
||||
) : null}
|
||||
</OnboardingSlideTransition>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
verifyNcryptsecBackup,
|
||||
type BackupVerification,
|
||||
} from "@/shared/api/tauriIdentity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
|
||||
type BackupTestStage = "drop" | "password" | "success";
|
||||
|
||||
/**
|
||||
* Durable progress through the test flow. Owned by the host so navigating
|
||||
* away (e.g. onboarding Back) and returning doesn't force the user to
|
||||
* re-drop the file. The password attempt is deliberately NOT part of this
|
||||
* state — it lives only in short-lived component state and is cleared the
|
||||
* moment it's submitted or the component unmounts.
|
||||
*/
|
||||
export type BackupTestProgress = {
|
||||
stage: BackupTestStage;
|
||||
/** Name of the accepted file once the drop check passed. */
|
||||
fileName: string | null;
|
||||
/** Contents of the accepted file, pending or past verification. */
|
||||
ncryptsec: string | null;
|
||||
/** The Rust-verified public identity once decryption succeeded. */
|
||||
result: BackupVerification | null;
|
||||
};
|
||||
|
||||
export const initialBackupTestProgress: BackupTestProgress = {
|
||||
stage: "drop",
|
||||
fileName: null,
|
||||
ncryptsec: null,
|
||||
result: null,
|
||||
};
|
||||
|
||||
type BackupTestFlowProps = {
|
||||
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
|
||||
variant?: "spotlight" | "boxed";
|
||||
/**
|
||||
* When supplied, only this exact just-created file is accepted — the
|
||||
* onboarding ceremony proves the user saved *that* backup. Without it the
|
||||
* flow is a general-purpose tester for any key backup file.
|
||||
*/
|
||||
expectedNcryptsec?: string;
|
||||
/** Re-open the native save dialog for another copy of the backup file. */
|
||||
onSaveCopy?: () => void;
|
||||
isSaving?: boolean;
|
||||
savedPath?: string | null;
|
||||
saveError?: string | null;
|
||||
/** Host-owned progress so it survives this component unmounting. */
|
||||
progress: BackupTestProgress;
|
||||
onProgressChange: React.Dispatch<React.SetStateAction<BackupTestProgress>>;
|
||||
/** Fired once when the user completes the test successfully. */
|
||||
onVerified?: () => void;
|
||||
};
|
||||
|
||||
const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const;
|
||||
const BURST_PARTICLE_COUNT = 18;
|
||||
|
||||
type BurstParticle = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
emoji: string;
|
||||
delay: number;
|
||||
scale: number;
|
||||
rotate: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One-shot radial emoji burst behind the success badge. Purely decorative —
|
||||
* skipped entirely under reduced motion.
|
||||
*/
|
||||
function SuccessBurst() {
|
||||
const particles = React.useMemo<BurstParticle[]>(
|
||||
() =>
|
||||
Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => {
|
||||
const angle =
|
||||
(i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5;
|
||||
const distance = 70 + Math.random() * 80;
|
||||
return {
|
||||
id: i,
|
||||
x: Math.cos(angle) * distance,
|
||||
y: Math.sin(angle) * distance,
|
||||
emoji: BURST_EMOJIS[i % BURST_EMOJIS.length],
|
||||
delay: Math.random() * 0.18,
|
||||
scale: 0.8 + Math.random() * 0.7,
|
||||
rotate: -120 + Math.random() * 240,
|
||||
};
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||
>
|
||||
{particles.map((particle) => (
|
||||
<motion.span
|
||||
animate={{
|
||||
x: particle.x,
|
||||
y: particle.y,
|
||||
opacity: 0,
|
||||
scale: particle.scale,
|
||||
rotate: particle.rotate,
|
||||
}}
|
||||
className="absolute text-xl"
|
||||
initial={{ x: 0, y: 0, opacity: 1, scale: 0.3, rotate: 0 }}
|
||||
key={particle.id}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
delay: particle.delay,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
{particle.emoji}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Test your backup" flow: the user drops a backup file onto a large
|
||||
* dropzone, then enters its password. Verification is a real NIP-49 decrypt
|
||||
* in Rust — the submitted password is cleared immediately after the result
|
||||
* and only the derived public identity ever comes back.
|
||||
*/
|
||||
export function BackupTestFlow({
|
||||
variant = "spotlight",
|
||||
expectedNcryptsec,
|
||||
onSaveCopy,
|
||||
isSaving = false,
|
||||
savedPath,
|
||||
saveError,
|
||||
progress,
|
||||
onProgressChange,
|
||||
onVerified,
|
||||
}: BackupTestFlowProps) {
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
const { stage, fileName, ncryptsec, result } = progress;
|
||||
// True while a file drag is anywhere over the window — the drop overlay
|
||||
// takes over the host surface only for the duration of the drag.
|
||||
const [isWindowDragging, setIsWindowDragging] = React.useState(false);
|
||||
const dragDepthRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
// dragenter/dragleave fire per nested element, so track depth to know
|
||||
// when the drag has actually left the window.
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.types.includes("Files")) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsWindowDragging(true);
|
||||
};
|
||||
const handleDragLeave = () => {
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsWindowDragging(false);
|
||||
};
|
||||
const handleDragEnd = () => {
|
||||
dragDepthRef.current = 0;
|
||||
setIsWindowDragging(false);
|
||||
};
|
||||
window.addEventListener("dragenter", handleDragEnter);
|
||||
window.addEventListener("dragleave", handleDragLeave);
|
||||
window.addEventListener("drop", handleDragEnd);
|
||||
window.addEventListener("dragend", handleDragEnd);
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", handleDragEnter);
|
||||
window.removeEventListener("dragleave", handleDragLeave);
|
||||
window.removeEventListener("drop", handleDragEnd);
|
||||
window.removeEventListener("dragend", handleDragEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The password attempt is component-local, never host state: it is cleared
|
||||
// when verification is submitted and when this component unmounts.
|
||||
const [attempt, setAttempt] = React.useState("");
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isVerifying, setIsVerifying] = React.useState(false);
|
||||
const [isRevealed, setIsRevealed] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const mountedRef = React.useRef(true);
|
||||
// Opaque correlation id so a stale in-flight verification can't commit
|
||||
// after "Use a different file" or unmount.
|
||||
const requestRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
requestRef.current += 1;
|
||||
setAttempt("");
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (stage === "password") passwordInputRef.current?.focus();
|
||||
}, [stage]);
|
||||
|
||||
const handleFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
let text: string;
|
||||
try {
|
||||
text = (await file.text()).trim();
|
||||
} catch {
|
||||
if (mountedRef.current) setError("Could not read that file.");
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
if (!text.toLowerCase().startsWith("ncryptsec1")) {
|
||||
setError(
|
||||
expectedNcryptsec
|
||||
? "That doesn't look like your key backup. Choose the file you just downloaded."
|
||||
: "That doesn't look like a key backup file.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (expectedNcryptsec && text !== expectedNcryptsec.trim()) {
|
||||
setError("That's a key backup, but not the one you just downloaded.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setAttempt("");
|
||||
onProgressChange({
|
||||
stage: "password",
|
||||
fileName: file.name,
|
||||
ncryptsec: text,
|
||||
result: null,
|
||||
});
|
||||
},
|
||||
[expectedNcryptsec, onProgressChange],
|
||||
);
|
||||
|
||||
const handleVerify = React.useCallback(async () => {
|
||||
if (!ncryptsec || !attempt || isVerifying) return;
|
||||
const password = attempt;
|
||||
const requestId = ++requestRef.current;
|
||||
setIsVerifying(true);
|
||||
setError(null);
|
||||
setIsRevealed(false);
|
||||
// Clear the attempt the moment it's handed to Rust — success or failure,
|
||||
// the typed password never lingers in the field.
|
||||
setAttempt("");
|
||||
try {
|
||||
const verified = await verifyNcryptsecBackup(ncryptsec, password);
|
||||
if (!mountedRef.current || requestId !== requestRef.current) return;
|
||||
onProgressChange((prev) => ({
|
||||
...prev,
|
||||
stage: "success",
|
||||
result: verified,
|
||||
}));
|
||||
onVerified?.();
|
||||
} catch (err) {
|
||||
if (mountedRef.current && requestId === requestRef.current)
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not verify this backup.",
|
||||
);
|
||||
} finally {
|
||||
if (mountedRef.current && requestId === requestRef.current)
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}, [attempt, isVerifying, ncryptsec, onProgressChange, onVerified]);
|
||||
|
||||
const isSpotlight = variant === "spotlight";
|
||||
|
||||
if (stage === "success" && result) {
|
||||
// The onboarding ceremony pins the exact file, so a success there is by
|
||||
// construction the current identity — celebrate and move on. The general
|
||||
// tester reports which identity the backup unlocks.
|
||||
const isCeremony = Boolean(expectedNcryptsec);
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col items-center gap-4 py-4 text-center"
|
||||
data-testid="backup-test-success"
|
||||
>
|
||||
{reduceMotion ? null : <SuccessBurst />}
|
||||
<motion.div
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||
initial={reduceMotion ? false : { scale: 0, opacity: 0 }}
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: { type: "spring", stiffness: 380, damping: 18 }
|
||||
}
|
||||
>
|
||||
<Check aria-hidden="true" className="h-8 w-8" strokeWidth={3} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : { delay: 0.15, duration: 0.35 }
|
||||
}
|
||||
>
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{isCeremony ? "Your backup works!" : "This backup works"}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
|
||||
{isCeremony
|
||||
? "File and password verified. Keep them both somewhere safe — that's all you need to restore your identity."
|
||||
: result.matchesCurrentIdentity
|
||||
? "It restores your current Buzz identity."
|
||||
: "It restores a different identity than the one signed in here."}
|
||||
</p>
|
||||
{isCeremony ? null : (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<PubKey
|
||||
pubkey={result.pubkey}
|
||||
testId="backup-test-npub"
|
||||
variant="full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
{isCeremony ? null : (
|
||||
<Button
|
||||
className="h-8 rounded-full px-4 text-xs text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-test-another"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
onProgressChange(initialBackupTestProgress);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Test another backup
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full space-y-4",
|
||||
isSpotlight ? "max-w-140" : "max-w-125",
|
||||
)}
|
||||
data-testid="backup-test-flow"
|
||||
>
|
||||
{stage === "drop" ? (
|
||||
<>
|
||||
<input
|
||||
accept=".ncryptsec,text/plain"
|
||||
className="sr-only"
|
||||
data-testid="backup-test-file-input"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
// Allow re-selecting the same file after an error.
|
||||
event.target.value = "";
|
||||
if (file) void handleFile(file);
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"mx-auto flex items-center justify-center rounded-full bg-primary text-center shadow transition-colors hover:bg-primary/90",
|
||||
// The CTA-label variable only exists inside the onboarding
|
||||
// theme; elsewhere fall back to the standard primary pair.
|
||||
isSpotlight
|
||||
? "h-14 px-12 text-(--buzz-onboarding-cta-label)"
|
||||
: "h-9 px-6 text-primary-foreground",
|
||||
)}
|
||||
data-testid="backup-test-dropzone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
isSpotlight ? "text-base" : "text-sm",
|
||||
)}
|
||||
>
|
||||
Select your backup file
|
||||
</span>
|
||||
</button>
|
||||
{isWindowDragging ? (
|
||||
/*
|
||||
* Composer-style takeover: fills the nearest positioned host
|
||||
* surface (the onboarding card / the settings backup row) and is
|
||||
* itself the drop target, so anywhere on that surface accepts
|
||||
* the file.
|
||||
*/
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path
|
||||
<div
|
||||
className="absolute inset-2 z-10 mt-0! flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/10 backdrop-blur-sm"
|
||||
data-testid="backup-test-drop-overlay"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2 rounded-full bg-foreground px-4 py-2 text-sm font-semibold text-background shadow-sm ring-1 ring-background/15">
|
||||
<FileUp aria-hidden="true" className="size-4" />
|
||||
<span>Drop your backup file here</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p
|
||||
className="text-center text-sm text-destructive"
|
||||
data-testid="backup-test-error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{onSaveCopy ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"gap-1.5 rounded-full bg-foreground/10 hover:bg-foreground/15",
|
||||
isSpotlight ? "h-12 px-10 text-base" : "h-9 px-6 text-sm",
|
||||
)}
|
||||
data-testid="encrypted-backup-save-copy"
|
||||
disabled={isSaving}
|
||||
onClick={onSaveCopy}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isSaving ? <Spinner className="h-4 w-4 border-2" /> : null}
|
||||
Re-download backup
|
||||
</Button>
|
||||
{savedPath ? (
|
||||
<p
|
||||
className="text-center text-xs text-muted-foreground"
|
||||
data-testid="encrypted-backup-saved-path"
|
||||
>
|
||||
Saved to {savedPath}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<p className="text-center text-sm text-destructive">{saveError}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 text-sm text-foreground animate-in fade-in slide-in-from-bottom-1 duration-300 motion-reduce:animate-none"
|
||||
data-testid="backup-test-file-accepted"
|
||||
>
|
||||
<FileKey2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="max-w-70 truncate font-mono text-xs">
|
||||
{fileName}
|
||||
</span>
|
||||
<Check aria-hidden="true" className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="text-center text-sm leading-6 text-muted-foreground">
|
||||
That's the one. Now enter your password to prove you can unlock it.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
aria-label="Backup password"
|
||||
autoComplete="off"
|
||||
className="h-10 bg-background pr-10 font-mono"
|
||||
data-testid="backup-test-password"
|
||||
disabled={isVerifying}
|
||||
onChange={(event) => setAttempt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void handleVerify();
|
||||
}
|
||||
}}
|
||||
placeholder="Your backup password"
|
||||
ref={passwordInputRef}
|
||||
type={isRevealed ? "text" : "password"}
|
||||
value={attempt}
|
||||
/>
|
||||
<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-test-password-reveal-toggle"
|
||||
disabled={isVerifying}
|
||||
onClick={() => setIsRevealed((revealed) => !revealed)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOff aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{error ? (
|
||||
<p
|
||||
className="absolute left-1 top-full mt-1 text-xs text-destructive animate-in fade-in duration-200 motion-reduce:animate-none"
|
||||
data-testid="backup-test-error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full bg-primary font-medium shadow transition-colors hover:bg-primary/90",
|
||||
isSpotlight
|
||||
? "h-12 px-10 text-base text-(--buzz-onboarding-cta-label)"
|
||||
: "h-9 px-6 text-sm text-primary-foreground",
|
||||
)}
|
||||
data-testid="backup-test-verify"
|
||||
disabled={!attempt || isVerifying}
|
||||
onClick={() => void handleVerify()}
|
||||
type="button"
|
||||
>
|
||||
{isVerifying ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
"Verify backup"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
data-testid="backup-test-use-different-file"
|
||||
disabled={isVerifying}
|
||||
onClick={() => {
|
||||
requestRef.current += 1;
|
||||
setAttempt("");
|
||||
setError(null);
|
||||
setIsRevealed(false);
|
||||
onProgressChange(initialBackupTestProgress);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Use a different file
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
{isProfileStage || isTeamStage ? (
|
||||
<OnboardingChrome current={isTeamStage ? 7 : 6} />
|
||||
<OnboardingChrome current={isTeamStage ? 8 : 7} />
|
||||
) : null}
|
||||
<OnboardingFooterProvider>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import { OnboardingFooter } from "./OnboardingFooter";
|
||||
import {
|
||||
type OnboardingTransitionDirection,
|
||||
OnboardingSlideTransition,
|
||||
} from "./OnboardingSlideTransition";
|
||||
import {
|
||||
type EncryptedBackupSession,
|
||||
backupSessionToPasswordEntry,
|
||||
EncryptedBackupCreator,
|
||||
} from "./EncryptedBackupCreator";
|
||||
|
||||
type DownloadKeyStepProps = {
|
||||
direction: OnboardingTransitionDirection;
|
||||
/**
|
||||
* Backup state owned by the parent flow so Back navigation (which unmounts
|
||||
* this step) doesn't discard the created backup, the entered password, or
|
||||
* the backup-test progress.
|
||||
*/
|
||||
session: EncryptedBackupSession;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding download step — the password-first encrypted key download
|
||||
* flow, promoted to its own page in the machine onboarding flow.
|
||||
* The raw key never enters this component: Rust builds the NIP-49 payload
|
||||
* locally and the native save dialog produces the user-owned file.
|
||||
*/
|
||||
export function DownloadKeyStep({
|
||||
direction,
|
||||
session,
|
||||
onBack,
|
||||
onNext,
|
||||
}: DownloadKeyStepProps) {
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
// True once the encrypted payload exists — the create button (living in the
|
||||
// footer's primary slot) disappears with the form, so Next takes its place.
|
||||
const hasCreated = session.created;
|
||||
// True once the user has passed the backup test — until then Next stays
|
||||
// disabled and "Skip for now" remains the escape hatch.
|
||||
const hasVerified = session.verified;
|
||||
// Footer slot the creator portals its "Download" button into.
|
||||
const [createButtonSlot, setCreateButtonSlot] =
|
||||
React.useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-0 w-full flex-col items-center"
|
||||
data-testid="onboarding-page-download"
|
||||
direction={direction}
|
||||
transitionKey={`download-${direction}`}
|
||||
>
|
||||
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
|
||||
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
|
||||
text-title size token as conflicting with text-foreground. */}
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
{hasCreated
|
||||
? "Now, test your backup"
|
||||
: "Backup your key with a password"}
|
||||
</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-foreground/80">
|
||||
{hasCreated
|
||||
? "Make sure your backup works: drop the file you just saved and unlock it with your password."
|
||||
: "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10">
|
||||
<div className="w-full">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
|
||||
transition={{ delay: 0.12, duration: 0.4, ease: "easeOut" }}
|
||||
>
|
||||
<Card className="w-full px-8 py-6" variant="textured">
|
||||
<div className="mx-auto w-full max-w-[832px]">
|
||||
<EncryptedBackupCreator
|
||||
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
createButtonPortal={createButtonSlot}
|
||||
session={session}
|
||||
variant="spotlight"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingFooter>
|
||||
{hasCreated ? (
|
||||
hasVerified ? (
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
/* No disabled Next while the test is unfinished — skipping is
|
||||
the only way forward until verification succeeds. */
|
||||
<Button
|
||||
className="h-9 whitespace-nowrap rounded-full px-6 hover:bg-foreground/10"
|
||||
data-testid="onboarding-skip"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
/* Relative row keeps the Download CTA truly centered while Skip
|
||||
hangs off its right edge without shifting the center. */
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div
|
||||
className="flex justify-center"
|
||||
data-testid="onboarding-create-slot"
|
||||
ref={setCreateButtonSlot}
|
||||
/>
|
||||
<Button
|
||||
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
|
||||
data-testid="onboarding-skip"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
onClick={
|
||||
// From the test view, Back first returns to the password form;
|
||||
// only from the form does it leave the step entirely.
|
||||
hasCreated ? () => backupSessionToPasswordEntry(session) : onBack
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{hasCreated ? null : (
|
||||
<p className="text-xs text-foreground/50">
|
||||
You can back up your key anytime in Settings → Profile →
|
||||
Identity.
|
||||
</p>
|
||||
)}
|
||||
</OnboardingFooter>
|
||||
</OnboardingSlideTransition>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,11 @@ import { Button } from "@/shared/ui/button";
|
||||
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
||||
import { BackupStep } from "./BackupStep";
|
||||
import { DefaultConfigStep } from "./DefaultConfigStep";
|
||||
import { DownloadKeyStep } from "./DownloadKeyStep";
|
||||
import {
|
||||
backupSessionToPasswordEntry,
|
||||
useEncryptedBackupSession,
|
||||
} from "./EncryptedBackupCreator";
|
||||
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
|
||||
import { LandingBees } from "./LandingBees";
|
||||
import { NostrKeyImportForm } from "./NostrKeyImportForm";
|
||||
@@ -25,6 +30,7 @@ export type MachineOnboardingPage =
|
||||
| "identity"
|
||||
| "key-import"
|
||||
| "backup"
|
||||
| "download"
|
||||
| "setup"
|
||||
| "config";
|
||||
|
||||
@@ -65,6 +71,9 @@ export function MachineOnboardingFlow({
|
||||
null,
|
||||
);
|
||||
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState<string[]>([]);
|
||||
// Owned here (not by DownloadKeyStep) so Back navigation — which unmounts
|
||||
// the step — keeps the created backup, entered password, and test progress.
|
||||
const backupSession = useEncryptedBackupSession();
|
||||
const handleReadyRuntimeIdsChange = React.useCallback(
|
||||
(runtimeIds: readonly string[]) => {
|
||||
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
|
||||
@@ -136,7 +145,15 @@ export function MachineOnboardingFlow({
|
||||
{page === "identity" ? <LandingBees /> : null}
|
||||
{page !== "identity" ? (
|
||||
<OnboardingChrome
|
||||
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
|
||||
current={
|
||||
page === "config"
|
||||
? 5
|
||||
: page === "setup"
|
||||
? 4
|
||||
: page === "download"
|
||||
? 3
|
||||
: 2
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<OnboardingFooterProvider>
|
||||
@@ -227,13 +244,29 @@ export function MachineOnboardingFlow({
|
||||
<BackupStep
|
||||
direction="forward"
|
||||
onBack={() => setPage("identity")}
|
||||
onDownload={() => setPage("download")}
|
||||
/>
|
||||
) : page === "download" ? (
|
||||
<DownloadKeyStep
|
||||
direction="forward"
|
||||
onBack={() => setPage("backup")}
|
||||
onNext={() => setPage("setup")}
|
||||
session={backupSession}
|
||||
/>
|
||||
) : page === "setup" ? (
|
||||
<SetupStep
|
||||
actions={{
|
||||
back: () =>
|
||||
setPage(identityWasImported ? "key-import" : "backup"),
|
||||
// Fresh-key users return to the "Backup your key with a
|
||||
// password" form (not the test flow they may have finished);
|
||||
// imported keys skip that step entirely.
|
||||
back: () => {
|
||||
if (identityWasImported) {
|
||||
setPage("key-import");
|
||||
return;
|
||||
}
|
||||
backupSessionToPasswordEntry(backupSession);
|
||||
setPage("download");
|
||||
},
|
||||
next: (runtimeIds) => {
|
||||
const ids = Array.from(runtimeIds);
|
||||
setReadyRuntimeIds(ids);
|
||||
|
||||
@@ -96,7 +96,7 @@ export function NostrKeyImportForm({
|
||||
|
||||
if (file.size > NOSTR_KEY_FILE_MAX_BYTES) {
|
||||
setImportError(
|
||||
"That file is too large to be a key. Choose a .key or .ncryptsec backup file, or paste your key.",
|
||||
"That file is too large to be a key backup or private key. Choose another file.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function NostrKeyImportForm({
|
||||
if (!isValid) {
|
||||
setImportError(
|
||||
isEncryptedInput
|
||||
? "Enter the passphrase for this encrypted backup."
|
||||
? "Enter the password for this key backup."
|
||||
: "That doesn't look like a valid nsec. Paste an nsec1 key.",
|
||||
);
|
||||
return;
|
||||
@@ -244,8 +244,8 @@ export function NostrKeyImportForm({
|
||||
</div>
|
||||
|
||||
{/* Hidden file input shared by both variants: the default drop zone and
|
||||
the spotlight "Import from a file" button both open it. Accepts the
|
||||
.ncryptsec archives our own save flow emits alongside raw .key files. */}
|
||||
the spotlight "Choose a backup file" button both open it. Accepts the
|
||||
.ncryptsec backups our own save flow emits alongside raw .key files. */}
|
||||
<input
|
||||
accept=".key,.ncryptsec,text/plain"
|
||||
className="sr-only"
|
||||
@@ -273,7 +273,7 @@ export function NostrKeyImportForm({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Import from a file
|
||||
Choose a backup file
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -358,7 +358,7 @@ export function NostrKeyImportForm({
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="nostr-import-passphrase"
|
||||
>
|
||||
Backup passphrase
|
||||
Backup password
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -370,11 +370,14 @@ export function NostrKeyImportForm({
|
||||
setPassphrase(event.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="Passphrase"
|
||||
placeholder="Password"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value={passphrase}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Your backup file and password stay on this device.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -393,7 +396,8 @@ export function NostrKeyImportForm({
|
||||
data-testid="nostr-import-encrypted-badge"
|
||||
>
|
||||
<KeyRound aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
Encrypted key backup — enter its passphrase to import
|
||||
Password-protected key backup · Private — enter its password to
|
||||
restore
|
||||
</p>
|
||||
) : previewNpub ? (
|
||||
variant === "spotlight" ? (
|
||||
|
||||
@@ -10,14 +10,9 @@ type NsecMaskedDisplayProps = {
|
||||
/**
|
||||
* What kind of secret is displayed. Drives labels, aria and testids:
|
||||
* a raw private key ("nsec", default) can impersonate its holder; an
|
||||
* encrypted backup ("ncryptsec") is only as sensitive as its passphrase.
|
||||
* encrypted key ("ncryptsec") is only as sensitive as its passphrase.
|
||||
*/
|
||||
kind?: "nsec" | "ncryptsec";
|
||||
/**
|
||||
* Called when the user reveals or copies the key. Lets flows that require
|
||||
* a backup (e.g. sign-out) gate on actual interaction with the key.
|
||||
*/
|
||||
onKeyInteraction?: () => void;
|
||||
};
|
||||
|
||||
const KIND_LABELS = {
|
||||
@@ -26,7 +21,7 @@ const KIND_LABELS = {
|
||||
testIdPrefix: "nsec",
|
||||
},
|
||||
ncryptsec: {
|
||||
noun: "encrypted backup",
|
||||
noun: "encrypted key",
|
||||
testIdPrefix: "ncryptsec",
|
||||
},
|
||||
} as const;
|
||||
@@ -48,7 +43,6 @@ export function NsecMaskedDisplay({
|
||||
nsec,
|
||||
variant = "boxed",
|
||||
kind = "nsec",
|
||||
onKeyInteraction,
|
||||
}: NsecMaskedDisplayProps) {
|
||||
const labels = KIND_LABELS[kind];
|
||||
const [isRevealed, setIsRevealed] = React.useState(false);
|
||||
@@ -65,13 +59,11 @@ export function NsecMaskedDisplay({
|
||||
}, []);
|
||||
|
||||
function handleRevealToggle() {
|
||||
if (!isRevealed) onKeyInteraction?.();
|
||||
setIsRevealed((prev) => !prev);
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await writeTextToClipboard(nsec);
|
||||
onKeyInteraction?.();
|
||||
setIsCopied(true);
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
|
||||
@@ -113,7 +105,9 @@ export function NsecMaskedDisplay({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`${
|
||||
isBare ? ONBOARDING_KEY_TEXT_CLASS : "text-xs leading-5"
|
||||
isBare
|
||||
? ONBOARDING_KEY_TEXT_CLASS
|
||||
: "break-all font-mono text-xs leading-5 wrap-anywhere"
|
||||
} ${
|
||||
isRevealed
|
||||
? `select-text ${isBare ? "" : "text-foreground"}`
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
|
||||
|
||||
/**
|
||||
* Positions in the first-launch flow: landing, identity/key, harness setup,
|
||||
* default config, community choice, community profile, meet the team. Used as
|
||||
* the default pagination length when a flow doesn't pass an explicit total.
|
||||
* Positions in the first-launch flow: landing, identity/key, key download,
|
||||
* harness setup, default config, community choice, community profile, meet
|
||||
* the team. Used as the default pagination length when a flow doesn't pass an
|
||||
* explicit total.
|
||||
*/
|
||||
export const TOTAL_ONBOARDING_PAGES = 7;
|
||||
export const TOTAL_ONBOARDING_PAGES = 8;
|
||||
|
||||
/** Shared pill shape (38px tall) for every onboarding primary CTA. */
|
||||
const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
|
||||
|
||||
@@ -685,25 +685,28 @@ function SetupStepContent({
|
||||
/>
|
||||
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
|
||||
data-testid="onboarding-setup-next"
|
||||
disabled={readyRuntimeIds.length === 0}
|
||||
onClick={() => actions.next(readyRuntimeIds)}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
data-testid="onboarding-setup-skip"
|
||||
onClick={() => actions.next([])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
{/* Relative row keeps the primary CTA truly centered while Skip
|
||||
hangs off its right edge without shifting the center. */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Button
|
||||
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
|
||||
data-testid="onboarding-setup-next"
|
||||
disabled={readyRuntimeIds.length === 0}
|
||||
onClick={() => actions.next(readyRuntimeIds)}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 text-sm fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
|
||||
data-testid="onboarding-setup-skip"
|
||||
onClick={() => actions.next([])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
|
||||
@@ -53,74 +53,11 @@ test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackupStep gating: backupNextDisabled() pure helper
|
||||
// BackupStep gating: saving a password-protected backup is recommended, not required
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("backup_next_disabled_in_encrypted_mode_until_backup_exists", () => {
|
||||
// Encrypted (default) mode: the user must create a backup — or explicitly
|
||||
// switch to the raw key — before Next unlocks. Loading/error state belongs
|
||||
// to the raw path and must not leak into the encrypted gate.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
mode: "encrypted",
|
||||
hasBackup: false,
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("backup_next_enabled_in_encrypted_mode_once_backup_created", () => {
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
mode: "encrypted",
|
||||
hasBackup: true,
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("backup_next_disabled_while_loading_raw_key", () => {
|
||||
// During a slow keychain read, Next must be blocked — user cannot race past
|
||||
// the key display before it is shown.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
mode: "raw",
|
||||
hasBackup: false,
|
||||
isLoading: true,
|
||||
loadError: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("backup_next_disabled_on_raw_load_error", () => {
|
||||
// Error state: only the explicit "Skip for now" ghost advances; Next blocked.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
mode: "raw",
|
||||
hasBackup: false,
|
||||
isLoading: false,
|
||||
loadError: "IPC error",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("backup_next_enabled_after_clean_raw_load", () => {
|
||||
// Key shown (or backend cleanly returned none) — user may proceed.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
mode: "raw",
|
||||
hasBackup: false,
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
test("backup_next_is_always_enabled", () => {
|
||||
assert.equal(backupNextDisabled(), false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BackupTestFlow,
|
||||
initialBackupTestProgress,
|
||||
} from "@/features/onboarding/ui/BackupTestFlow";
|
||||
import { EncryptedBackupCreator } from "@/features/onboarding/ui/EncryptedBackupCreator";
|
||||
|
||||
/**
|
||||
* Collapsible settings row shared by the two backup tools. `relative`
|
||||
* anchors the backup-test drop overlay (BackupTestFlow) so a file drag takes
|
||||
* over the whole row, mirroring the composer treatment.
|
||||
*/
|
||||
function ToolRow({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative px-4 py-3" data-testid={testId}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-label={open ? `Close ${title.toLowerCase()}` : title}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-testid={`${testId}-toggle`}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{open ? "Close" : action}
|
||||
</button>
|
||||
</div>
|
||||
{open ? <div className="mt-3">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sibling settings tools for the password-protected key backup: create a new
|
||||
* backup, or test any existing backup file. The raw private key never reaches
|
||||
* either flow — the password goes to Rust, which returns only the encrypted
|
||||
* NIP-49 blob (create) or the derived public identity (test).
|
||||
*/
|
||||
export function EncryptedBackupRow() {
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
const [testOpen, setTestOpen] = React.useState(false);
|
||||
const [progress, setProgress] = React.useState(initialBackupTestProgress);
|
||||
return (
|
||||
<>
|
||||
<ToolRow
|
||||
action="Create backup"
|
||||
description="Download a password-protected copy of your identity key."
|
||||
onToggle={() => setCreateOpen((open) => !open)}
|
||||
open={createOpen}
|
||||
testId="profile-encrypted-backup-row"
|
||||
title="Create a key backup"
|
||||
>
|
||||
<EncryptedBackupCreator guidedTest={false} variant="boxed" />
|
||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
Keep the file private and save its password somewhere safe — Buzz
|
||||
cannot reset it. Creating another backup does not invalidate copies
|
||||
you saved before.
|
||||
</p>
|
||||
</ToolRow>
|
||||
<ToolRow
|
||||
action="Test backup"
|
||||
description="Check a backup file and its password, and see which identity it unlocks."
|
||||
onToggle={() => setTestOpen((open) => !open)}
|
||||
open={testOpen}
|
||||
testId="profile-backup-test-row"
|
||||
title="Test a key backup"
|
||||
>
|
||||
<BackupTestFlow
|
||||
onProgressChange={setProgress}
|
||||
progress={progress}
|
||||
variant="boxed"
|
||||
/>
|
||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
Backups use the standard NIP-49 format, so this works for backups from
|
||||
compatible Nostr apps too.
|
||||
</p>
|
||||
</ToolRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
useUpdateProfileMutation,
|
||||
} from "@/features/profile/hooks";
|
||||
import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay";
|
||||
import { EncryptedBackupCreator } from "@/features/onboarding/ui/EncryptedBackupCreator";
|
||||
import { getNsec } from "@/shared/api/tauriIdentity";
|
||||
import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
@@ -25,6 +24,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { EncryptedBackupRow } from "./EncryptedBackupRow";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
import { SignOutSection } from "./SignOutSection";
|
||||
import { writeTextToClipboard } from "@/shared/lib/clipboard";
|
||||
@@ -177,42 +177,6 @@ function NsecRevealRow() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible row for creating an encrypted NIP-49 backup on demand. The raw
|
||||
* private key never reaches this flow — the passphrase goes to Rust, which
|
||||
* returns the persisted `ncryptsec1…` blob.
|
||||
*/
|
||||
function EncryptedBackupRow() {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3" data-testid="profile-encrypted-backup-row">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">Password Backup</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Protect your key with a password and save a recoverable backup.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-testid="profile-encrypted-backup-toggle"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? "Close" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<div className="mt-3">
|
||||
<EncryptedBackupCreator variant="boxed" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditProfileMetadataButton({
|
||||
label,
|
||||
testId,
|
||||
|
||||
@@ -31,9 +31,9 @@ export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data";
|
||||
* Signing out wipes the identity key and all local data, so the confirm
|
||||
* dialog gates the delete button behind two explicit steps:
|
||||
*
|
||||
* 1. Back up the key — the nsec is shown inline (masked, with reveal/copy);
|
||||
* the "I have saved my private key" checkbox unlocks only after the user
|
||||
* actually reveals or copies the key.
|
||||
* 1. Confirm recovery — Settings offers a tested password-protected backup;
|
||||
* the dialog also shows the raw nsec as a last-chance fallback, and the
|
||||
* user checks a box confirming they can restore their identity.
|
||||
* 2. Typed confirmation — the user must type the exact phrase
|
||||
* "wipe all my data".
|
||||
*
|
||||
@@ -47,7 +47,6 @@ export function SignOutSection() {
|
||||
const [nsec, setNsec] = React.useState<string | null>(null);
|
||||
const [nsecError, setNsecError] = React.useState<string | null>(null);
|
||||
const [isNsecLoading, setIsNsecLoading] = React.useState(false);
|
||||
const [hasInteractedWithKey, setHasInteractedWithKey] = React.useState(false);
|
||||
const [hasConfirmedBackup, setHasConfirmedBackup] = React.useState(false);
|
||||
// Guards against a late-resolving getNsec() repopulating state after the
|
||||
// dialog closes.
|
||||
@@ -58,20 +57,13 @@ export function SignOutSection() {
|
||||
const isPhraseConfirmed =
|
||||
confirmText.trim().toLowerCase() === SIGNOUT_CONFIRM_PHRASE;
|
||||
|
||||
// The backup checkbox unlocks after real interaction with the key
|
||||
// (reveal or copy). If the key cannot be loaded at all there is nothing to
|
||||
// interact with — let the user proceed past the backup step rather than
|
||||
// locking them out of sign-out entirely.
|
||||
const isBackupGateSatisfied = hasConfirmedBackup;
|
||||
const canConfirmBackup = hasInteractedWithKey || nsecError !== null;
|
||||
const canDelete = isBackupGateSatisfied && isPhraseConfirmed && !isPending;
|
||||
const canDelete = hasConfirmedBackup && isPhraseConfirmed && !isPending;
|
||||
|
||||
function resetDialogState() {
|
||||
fetchCancelledRef.current = true;
|
||||
setNsec(null);
|
||||
setNsecError(null);
|
||||
setIsNsecLoading(false);
|
||||
setHasInteractedWithKey(false);
|
||||
setHasConfirmedBackup(false);
|
||||
setConfirmText("");
|
||||
}
|
||||
@@ -137,7 +129,8 @@ export function SignOutSection() {
|
||||
<h2 className="text-lg font-semibold tracking-tight">Sign out</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Removes your identity key and all local app data from this device.
|
||||
Back up your private key (nsec) first — this cannot be undone.
|
||||
Before signing out, create and test a password-protected key backup
|
||||
above — this cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -175,7 +168,7 @@ export function SignOutSection() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
1. Back up your private key (nsec)
|
||||
1. Confirm you can restore your identity
|
||||
</p>
|
||||
{isNsecLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
@@ -187,10 +180,7 @@ export function SignOutSection() {
|
||||
{nsecError}
|
||||
</p>
|
||||
) : nsec ? (
|
||||
<NsecMaskedDisplay
|
||||
nsec={nsec}
|
||||
onKeyInteraction={() => setHasInteractedWithKey(true)}
|
||||
/>
|
||||
<NsecMaskedDisplay nsec={nsec} />
|
||||
) : null}
|
||||
<label
|
||||
className="flex cursor-pointer items-start gap-2.5 text-sm has-[button:disabled]:cursor-not-allowed has-[button:disabled]:opacity-60"
|
||||
@@ -201,19 +191,15 @@ export function SignOutSection() {
|
||||
checked={hasConfirmedBackup}
|
||||
className="mt-0.5"
|
||||
data-testid="signout-backup-confirm"
|
||||
disabled={!canConfirmBackup || isPending}
|
||||
disabled={isPending}
|
||||
id="signout-backup-confirm"
|
||||
onCheckedChange={(checked) =>
|
||||
setHasConfirmedBackup(checked === true)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
I have saved my private key somewhere safe.
|
||||
{!canConfirmBackup ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Reveal or copy the key above first.
|
||||
</span>
|
||||
) : null}
|
||||
I have tested a key backup or saved this private key somewhere
|
||||
safe.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,3 +95,20 @@ export async function persistCurrentIdentity(): Promise<Identity> {
|
||||
export async function signOut(): Promise<void> {
|
||||
await invokeTauri("sign_out");
|
||||
}
|
||||
|
||||
export type BackupVerification = {
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
matchesCurrentIdentity: boolean;
|
||||
};
|
||||
|
||||
/** Decrypt a NIP-49 backup in Rust and return only its public identity. */
|
||||
export async function verifyNcryptsecBackup(
|
||||
ncryptsec: string,
|
||||
password: string,
|
||||
): Promise<BackupVerification> {
|
||||
return invokeTauri<BackupVerification>("verify_ncryptsec_backup", {
|
||||
ncryptsec,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const ALLOWLIST = [
|
||||
"features/onboarding/lib/keyImportInput.ts",
|
||||
"features/onboarding/lib/keyImportInput.test.mjs",
|
||||
"features/onboarding/ui/BackupStep.tsx",
|
||||
"features/onboarding/ui/BackupTestFlow.tsx",
|
||||
"features/onboarding/ui/EncryptedBackupCreator.tsx",
|
||||
"features/onboarding/ui/NostrKeyImportForm.tsx",
|
||||
"features/onboarding/ui/NsecMaskedDisplay.tsx",
|
||||
|
||||
@@ -7167,6 +7167,21 @@ const MOCK_NCRYPTSEC =
|
||||
// The single passphrase the mocked backup commands accept/emit.
|
||||
const MOCK_BACKUP_PASSPHRASE = "mock horse battery staple lake orbit";
|
||||
|
||||
// Fixed word pool for the mocked passphrase generator (deterministic —
|
||||
// specs assert word count and separator, never entropy).
|
||||
const MOCK_PASSPHRASE_WORDS = [
|
||||
"mock",
|
||||
"horse",
|
||||
"battery",
|
||||
"staple",
|
||||
"lake",
|
||||
"orbit",
|
||||
"cedar",
|
||||
"plume",
|
||||
"raven",
|
||||
"tundra",
|
||||
];
|
||||
|
||||
// Per-page confirm_team_snapshot_import call counter for sequenced error testing.
|
||||
let teamSnapshotConfirmCallCount = 0;
|
||||
|
||||
@@ -9589,11 +9604,19 @@ export function maybeInstallE2eTauriMocks() {
|
||||
}
|
||||
return "nsec1mock000000000000000000000000000000000000000000000000000000";
|
||||
}
|
||||
case "generate_backup_passphrase":
|
||||
// Deterministic mock: production generates 6 EFF short-wordlist words
|
||||
// from OS entropy in Rust. Specs only assert display/flow, never
|
||||
// entropy quality.
|
||||
return MOCK_BACKUP_PASSPHRASE;
|
||||
case "generate_backup_passphrase": {
|
||||
// Deterministic mock: production draws EFF short-wordlist words from
|
||||
// OS entropy in Rust. The mock honors the generator popover's word
|
||||
// count and separator (clamped like Rust) so specs can assert the
|
||||
// controls, but never entropy quality.
|
||||
const request = payload as {
|
||||
words?: number;
|
||||
separator?: string;
|
||||
} | null;
|
||||
const wordCount = Math.min(Math.max(request?.words ?? 3, 3), 10);
|
||||
const separator = request?.separator ?? " ";
|
||||
return MOCK_PASSPHRASE_WORDS.slice(0, wordCount).join(separator);
|
||||
}
|
||||
case "create_ncryptsec_backup": {
|
||||
// Production encrypts the live key under the passphrase, persists
|
||||
// `identity.ncryptsec`, and returns the exact persisted blob. The
|
||||
@@ -9605,10 +9628,29 @@ export function maybeInstallE2eTauriMocks() {
|
||||
}
|
||||
return MOCK_NCRYPTSEC;
|
||||
}
|
||||
case "verify_ncryptsec_backup": {
|
||||
const request = payload as {
|
||||
ncryptsec?: string;
|
||||
password?: string;
|
||||
} | null;
|
||||
if (
|
||||
request?.password !== MOCK_BACKUP_PASSPHRASE ||
|
||||
!request.ncryptsec?.startsWith("ncryptsec1")
|
||||
)
|
||||
throw new Error("Wrong backup password or damaged key backup.");
|
||||
const different = request.ncryptsec !== MOCK_NCRYPTSEC;
|
||||
return {
|
||||
pubkey: different ? "f".repeat(64) : DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
npub: different
|
||||
? "npub1differentmockidentity000000000000000000000000000000000"
|
||||
: "npub1mockcurrentidentity0000000000000000000000000000000000",
|
||||
matchesCurrentIdentity: !different,
|
||||
};
|
||||
}
|
||||
case "save_ncryptsec_copy": {
|
||||
const blob = (payload as { ncryptsec?: string } | null)?.ncryptsec;
|
||||
if (!blob?.startsWith("ncryptsec1")) {
|
||||
throw new Error("Not a valid encrypted key backup.");
|
||||
throw new Error("Not a valid key backup.");
|
||||
}
|
||||
// Production opens a native save dialog; the harness pretends the
|
||||
// user picked a path.
|
||||
@@ -9644,7 +9686,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
input.trim() !== MOCK_NCRYPTSEC ||
|
||||
request?.password !== MOCK_BACKUP_PASSPHRASE
|
||||
) {
|
||||
throw new Error("Wrong passphrase or corrupted backup.");
|
||||
throw new Error("Wrong backup password or damaged key backup.");
|
||||
}
|
||||
mockIdentityLostCleared = true;
|
||||
mockIdentityLockedCleared = true;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import {
|
||||
dropFileOnTestId,
|
||||
endWindowFileDrag,
|
||||
startWindowFileDrag,
|
||||
} from "../helpers/fileDrag";
|
||||
|
||||
async function enterMachineBackup(page: import("@playwright/test").Page) {
|
||||
await installMockBridge(page, undefined, {
|
||||
@@ -11,125 +16,298 @@ async function enterMachineBackup(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
}
|
||||
|
||||
async function invokedCommands(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
|
||||
.__BUZZ_E2E_COMMANDS__ ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
const SHOTS = "test-results/screenshots-onboarding";
|
||||
|
||||
// Mirrors the mock bridge's MOCK_NCRYPTSEC (e2eBridge.ts): the blob the
|
||||
// mocked `create_ncryptsec_backup` returns, i.e. the "downloaded file"
|
||||
// contents the test-your-backup dropzone expects.
|
||||
const MOCK_NCRYPTSEC =
|
||||
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
||||
|
||||
test("backup step appears on fresh-key path after profile submit", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
|
||||
// Perceived-loading intro: the animated logo and "Creating" title show
|
||||
// first, then the finished state replaces them after the hold.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Creating your identity key" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("backup-intro-logo")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Your unique identity key has been created",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted-by-default path (plan D3): passphrase → create → ncryptsec shown.
|
||||
// The raw key must never be fetched on this path.
|
||||
// Chooser: masked key with reveal toggle and inline copy. The raw key is
|
||||
// fetched only on explicit reveal/copy, and Next is never blocked.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("encrypted backup happy path: generated passphrase, create, save copy, Next", async ({
|
||||
test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await enterMachineBackup(page);
|
||||
|
||||
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
|
||||
|
||||
// Masked by default: decorative mask only, no key material in the DOM.
|
||||
const key = page.getByTestId("backup-key-value");
|
||||
await expect(key).toBeVisible();
|
||||
await expect(key).toHaveClass(/blur/);
|
||||
await expect(key).not.toContainText("nsec1");
|
||||
expect(await invokedCommands(page)).not.toContain("get_nsec");
|
||||
|
||||
// Reveal fetches the key; box must not reflow (same-length monospace mask).
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
await expect(key).toContainText("nsec1mock");
|
||||
await expect(key).toHaveClass(/select-text/);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` });
|
||||
|
||||
// Hide again.
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
await expect(key).not.toContainText("nsec1");
|
||||
|
||||
// Inline copy goes straight to the clipboard.
|
||||
await page.getByTestId("backup-copy-key").click();
|
||||
await expect
|
||||
.poll(async () => invokedCommands(page))
|
||||
.toContain("copy_text_to_clipboard");
|
||||
expect(await invokedCommands(page)).toContain("get_nsec");
|
||||
|
||||
// Next leads into the download step, where backup stays skippable.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted download path ("Backup your key" step): password → encrypt
|
||||
// locally → native save → saved confirmation. The raw key must never be
|
||||
// fetched on this path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("download happy path: generated password, encrypt, native save, Next", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
// Default mode: generated passphrase shown, Next locked until backup exists.
|
||||
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
|
||||
// The download flow is its own onboarding step behind the footer's Next.
|
||||
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
|
||||
// The password field starts empty; the create button sits in the footer's
|
||||
// primary slot and stays disabled until a valid password exists.
|
||||
const input = page.getByTestId("backup-passphrase-input");
|
||||
await expect(input).toHaveValue("");
|
||||
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
await expect(page.getByTestId("onboarding-skip")).toBeVisible();
|
||||
|
||||
// The inset refresh icon opens the generator popover and immediately
|
||||
// fills the field (mock default: 3 words, spaces).
|
||||
await page.getByTestId("backup-passphrase-generate").click();
|
||||
await expect(input).toHaveValue("mock horse battery");
|
||||
|
||||
// Popover controls regenerate in place: word count (slider) and separator.
|
||||
await page.getByTestId("backup-passphrase-words").focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(input).toHaveValue("mock horse battery staple");
|
||||
await page
|
||||
.getByTestId("backup-passphrase-separator")
|
||||
.selectOption({ label: "Hyphens" });
|
||||
await expect(input).toHaveValue("mock-horse-battery-staple");
|
||||
|
||||
// Clicking the inset icon again re-rolls without closing the popover.
|
||||
await page.getByTestId("backup-passphrase-generate").click();
|
||||
await expect(page.getByTestId("backup-passphrase-separator")).toBeVisible();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` });
|
||||
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
|
||||
|
||||
// Encryption may finish while the generator popover is still open. Once it
|
||||
// does, the real password is cleared and only the fixed saved-password mask
|
||||
// remains over an empty read-only input.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("backup-passphrase-separator")).toHaveCount(0);
|
||||
await expect(input).toHaveValue("");
|
||||
await expect(input).toHaveAttribute("readonly", "");
|
||||
await expect(page.getByTestId("backup-saved-password-mask")).toBeVisible();
|
||||
|
||||
// Saving uses the retained encrypted blob; it does not re-encrypt or need
|
||||
// the cleared password.
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
|
||||
// The persisted blob is displayed masked; copy + save-a-copy available.
|
||||
const blob = page.getByTestId("ncryptsec-value");
|
||||
await expect(blob).toBeVisible();
|
||||
await expect(blob).toHaveCSS("filter", /blur/);
|
||||
await page.getByTestId("ncryptsec-reveal-toggle").click();
|
||||
await expect(blob).toContainText("ncryptsec1");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/03-backup-step-encrypted.png` });
|
||||
|
||||
await page.getByTestId("encrypted-backup-save-copy").click();
|
||||
// Only a successful save (the mock "picks" a path) advances to the
|
||||
// "Now, test your backup" flow: a select-file button for the saved file
|
||||
// (a composer-style drop overlay takes over the card while a file drag is
|
||||
// over the window), then the password to unlock it.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Now, test your backup" }),
|
||||
).toBeVisible();
|
||||
const dropzone = page.getByTestId("backup-test-dropzone");
|
||||
await expect(dropzone).toBeVisible();
|
||||
await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText(
|
||||
"identity.ncryptsec",
|
||||
);
|
||||
|
||||
// The default path must never have fetched the raw key.
|
||||
const commands = await page.evaluate(
|
||||
() =>
|
||||
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
|
||||
.__BUZZ_E2E_COMMANDS__ ?? [],
|
||||
);
|
||||
// Until the test passes there is no Next at all — Skip is the only way
|
||||
// forward.
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
await expect(page.getByTestId("onboarding-skip")).toBeVisible();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` });
|
||||
|
||||
// A wrong file is rejected with an inline error; the dropzone stays.
|
||||
await page.getByTestId("backup-test-file-input").setInputFiles({
|
||||
name: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("not a key backup"),
|
||||
});
|
||||
await expect(page.getByTestId("backup-test-error")).toBeVisible();
|
||||
|
||||
// A file drag over the window swaps in the drop overlay; leaving without
|
||||
// dropping restores the select button.
|
||||
const dropOverlay = page.getByTestId("backup-test-drop-overlay");
|
||||
await startWindowFileDrag(page);
|
||||
await expect(dropOverlay).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04b-backup-test-drop-overlay.png` });
|
||||
await endWindowFileDrag(page);
|
||||
await expect(dropOverlay).toHaveCount(0);
|
||||
|
||||
// Dropping the freshly downloaded file on the overlay advances to the
|
||||
// password check.
|
||||
await startWindowFileDrag(page);
|
||||
await expect(dropOverlay).toBeVisible();
|
||||
await dropFileOnTestId(page, "backup-test-drop-overlay", MOCK_NCRYPTSEC);
|
||||
const password = page.getByTestId("backup-test-password");
|
||||
await expect(password).toBeVisible();
|
||||
await expect(dropOverlay).toHaveCount(0);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/05-backup-test-password.png` });
|
||||
|
||||
// Verification is explicit and clears every submitted attempt.
|
||||
await password.fill("mock-horse-battery-staplX");
|
||||
await page.getByTestId("backup-test-verify").click();
|
||||
await expect(page.getByTestId("backup-test-error")).toBeVisible();
|
||||
await expect(password).toHaveValue("");
|
||||
|
||||
await password.fill("mock horse battery staple lake orbit");
|
||||
await page.getByTestId("backup-test-verify").click();
|
||||
await expect(page.getByTestId("backup-test-success")).toBeVisible();
|
||||
|
||||
// The celebration is driven by motion's rAF loop, which
|
||||
// `waitForAnimations` (WAAPI-only) cannot observe — hold until the badge
|
||||
// and copy have faded in before capturing.
|
||||
await page.waitForTimeout(1200);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
|
||||
|
||||
// The download path must never have fetched the raw key.
|
||||
const commands = await invokedCommands(page);
|
||||
expect(commands).not.toContain("get_nsec");
|
||||
expect(commands).toContain("create_ncryptsec_backup");
|
||||
|
||||
// A passed test unlocks Next and retires the Skip escape hatch.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await expect(page.getByTestId("onboarding-skip")).toHaveCount(0);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
test("custom passphrase requires 12 characters and confirmation", async ({
|
||||
test("download step Back returns to the backup chooser", async ({ page }) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
|
||||
// The chooser's footer Next belongs to the previous step; the download
|
||||
// step only mounts its own Next once the backup exists.
|
||||
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-key-value")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toBeVisible();
|
||||
});
|
||||
|
||||
test("test-view Back returns to a secure saved-password placeholder", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
|
||||
const input = page.getByTestId("backup-passphrase-input");
|
||||
await input.fill("mock-horse-battery-staple");
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Now, test your backup" }),
|
||||
).toBeVisible();
|
||||
|
||||
// Back retains only the encrypted blob and renders a fixed visual mask over
|
||||
// an empty readonly input.
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Backup your key with a password" }),
|
||||
).toBeVisible();
|
||||
await expect(input).toHaveValue("");
|
||||
await expect(input).toHaveAttribute("readonly", "");
|
||||
await expect(page.getByTestId("backup-saved-password-mask")).toBeVisible();
|
||||
|
||||
// Re-downloading needs no password or re-encryption.
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Now, test your backup" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await page.getByTestId("onboarding-back").click();
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
});
|
||||
|
||||
test("typed password requires 12 characters", async ({ page }) => {
|
||||
await enterMachineBackup(page);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
|
||||
await page.getByTestId("backup-passphrase-choose-own").click();
|
||||
const create = page.getByTestId("encrypted-backup-create");
|
||||
await expect(create).toBeDisabled(); // empty field
|
||||
|
||||
await page.getByTestId("backup-passphrase-custom").fill("short");
|
||||
await page.getByTestId("backup-passphrase-input").fill("short");
|
||||
await expect(page.getByTestId("backup-passphrase-issue")).toBeVisible();
|
||||
await expect(create).toBeDisabled();
|
||||
|
||||
await page
|
||||
.getByTestId("backup-passphrase-custom")
|
||||
.fill("a much longer passphrase");
|
||||
await expect(create).toBeDisabled(); // confirm still empty
|
||||
|
||||
await page
|
||||
.getByTestId("backup-passphrase-confirm")
|
||||
.getByTestId("backup-passphrase-input")
|
||||
.fill("a much longer passphrase");
|
||||
await expect(page.getByTestId("backup-passphrase-issue")).toHaveCount(0);
|
||||
await expect(create).toBeEnabled();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw-key path: preserved behind an explicit "Show raw key instead" click.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("raw key path is one explicit click away and shows the masked nsec", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
await page.getByTestId("backup-show-raw-key").click();
|
||||
|
||||
const nsecDisplay = page.getByTestId("nsec-value");
|
||||
await expect(nsecDisplay).toBeVisible();
|
||||
|
||||
// Should start masked (blurred) — reveal button exists and eye icon visible.
|
||||
const revealBtn = page.getByTestId("nsec-reveal-toggle");
|
||||
await expect(revealBtn).toBeVisible();
|
||||
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
|
||||
|
||||
// Reveal and verify the mock nsec appears.
|
||||
await revealBtn.click();
|
||||
await expect(nsecDisplay).not.toHaveCSS("filter", /blur/);
|
||||
await expect(nsecDisplay).toContainText("nsec1mock");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` });
|
||||
|
||||
// Raw mode keeps the previous gating: key shown → Next enabled.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
test("backup step back button returns to machine identity choice", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -149,10 +327,10 @@ test("backup step back button returns to machine identity choice", async ({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B4: Error path coverage (raw path)
|
||||
// B4: Error path coverage (reveal/copy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("raw path shows error banner and retry button when get_nsec fails", async ({
|
||||
test("reveal shows inline error when get_nsec fails and Next still advances", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(
|
||||
@@ -164,22 +342,19 @@ test("raw path shows error banner and retry button when get_nsec fails", async (
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await page.getByTestId("backup-show-raw-key").click();
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
|
||||
await expect(page.getByTestId("backup-load-error")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-retry")).toBeVisible();
|
||||
// Next is blocked on error; Skip for now ghost is shown instead.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
|
||||
await expect(page.getByTestId("backup-skip")).toBeVisible();
|
||||
|
||||
// Skip for now still advances to machine setup.
|
||||
await page.getByTestId("backup-skip").click();
|
||||
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
|
||||
// Keychain failure does not trap the user: Next still advances into the
|
||||
// download step, and Skip there continues to setup.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
test("raw path retry succeeds and shows key after initial failure", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("reveal retry succeeds after initial failure", async ({ page }) => {
|
||||
// First call fails, second succeeds (sequenced via nsecErrors).
|
||||
await installMockBridge(
|
||||
page,
|
||||
@@ -188,12 +363,12 @@ test("raw path retry succeeds and shows key after initial failure", async ({
|
||||
);
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a new identity key" }).click();
|
||||
await page.getByTestId("backup-show-raw-key").click();
|
||||
|
||||
await expect(page.getByTestId("backup-load-error")).toBeVisible();
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
|
||||
|
||||
// Retry — second call succeeds.
|
||||
await page.getByTestId("backup-retry").click();
|
||||
await expect(page.getByTestId("nsec-value")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-load-error")).not.toBeVisible();
|
||||
// Retry — second call succeeds and clears the error.
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock");
|
||||
await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` });
|
||||
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create a new identity key" }),
|
||||
).toBeVisible();
|
||||
@@ -59,17 +59,19 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
|
||||
|
||||
// Encrypted-by-default backup: the raw key sits behind an explicit click.
|
||||
await page.getByTestId("backup-show-raw-key").click();
|
||||
await expect(page.getByTestId("nsec-value")).toBeVisible();
|
||||
// The key stays masked behind an explicit reveal toggle.
|
||||
await expect(page.getByTestId("backup-key-value")).toBeVisible();
|
||||
|
||||
// Reveal the key: box must not reflow (same-length monospace mask).
|
||||
await page.getByTestId("nsec-reveal-toggle").click();
|
||||
await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/);
|
||||
await page.getByTestId("backup-key-reveal-toggle").click();
|
||||
await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
|
||||
|
||||
// Next leads into the download step; Skip there continues to setup.
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Set up your agent harnesses" }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -645,7 +645,7 @@ test("first-launch encrypted backup import asks for a passphrase and continues",
|
||||
await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase");
|
||||
await page.getByTestId("nostr-import-submit").click();
|
||||
await expect(page.getByTestId("nostr-import-feedback")).toContainText(
|
||||
/wrong passphrase/i,
|
||||
/wrong backup password/i,
|
||||
);
|
||||
|
||||
await page
|
||||
@@ -679,6 +679,15 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
|
||||
".key,.ncryptsec,text/plain",
|
||||
);
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
buffer: Buffer.alloc(1_025, "x"),
|
||||
mimeType: "text/plain",
|
||||
name: "not-a-backup.txt",
|
||||
});
|
||||
await expect(page.getByTestId("nostr-import-feedback")).toContainText(
|
||||
/too large to be a key backup/i,
|
||||
);
|
||||
|
||||
// Spec-vector blob the mock bridge accepts with the mock passphrase.
|
||||
const mockNcryptsec =
|
||||
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
||||
|
||||
@@ -63,3 +63,35 @@ test("reveal shows error when get_nsec fails", async ({ page }) => {
|
||||
"Keychain locked",
|
||||
);
|
||||
});
|
||||
|
||||
test("settings separates backup creation from general NIP-49 testing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await openSettings(page, "profile");
|
||||
await expandIdentity(page);
|
||||
const createRow = page.getByTestId("profile-encrypted-backup-row");
|
||||
const testRow = page.getByTestId("profile-backup-test-row");
|
||||
await expect(createRow).toContainText("Create a key backup");
|
||||
await expect(testRow).toContainText("Test a key backup");
|
||||
await page.getByTestId("profile-backup-test-row-toggle").click();
|
||||
await page.getByTestId("backup-test-file-input").setInputFiles({
|
||||
name: "identity.ncryptsec",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(
|
||||
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p",
|
||||
),
|
||||
});
|
||||
await page
|
||||
.getByTestId("backup-test-password")
|
||||
.fill("mock horse battery staple lake orbit");
|
||||
await page.getByTestId("backup-test-verify").click();
|
||||
await expect(page.getByTestId("backup-test-success")).toContainText(
|
||||
"This backup works",
|
||||
);
|
||||
await expect(page.getByTestId("backup-test-success")).toContainText(
|
||||
"It restores your current Buzz identity",
|
||||
);
|
||||
await expect(page.getByTestId("backup-test-npub")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Signing out wipes the identity key and all local data, so the dialog gates
|
||||
* "Delete My Data" behind two explicit steps:
|
||||
* 1. backup — reveal/copy the nsec, then check "I have saved my private key"
|
||||
* 1. backup — check "I have saved my private key"
|
||||
* 2. typed confirmation — type the exact phrase "wipe all my data"
|
||||
*/
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
@@ -13,10 +13,6 @@ import { openSettings } from "../helpers/settings";
|
||||
|
||||
const CONFIRM_PHRASE = "wipe all my data";
|
||||
|
||||
// The mock bridge routes copy_text_to_clipboard through navigator.clipboard,
|
||||
// which requires explicit permissions in headless Chromium.
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] });
|
||||
|
||||
async function openSignOutDialog(page: Page) {
|
||||
await openSettings(page, "profile");
|
||||
const section = page.getByTestId("settings-signout");
|
||||
@@ -36,12 +32,8 @@ test("delete button unlocks only after backup + typed phrase", async ({
|
||||
const backupCheckbox = page.getByTestId("signout-backup-confirm");
|
||||
const phraseInput = page.getByTestId("signout-confirm-phrase");
|
||||
|
||||
// Everything locked initially: no key interaction yet.
|
||||
// Delete is locked initially; the backup checkbox is immediately usable.
|
||||
await expect(deleteButton).toBeDisabled();
|
||||
await expect(backupCheckbox).toBeDisabled();
|
||||
|
||||
// Copying the key unlocks the backup checkbox.
|
||||
await page.getByTestId("nsec-copy").click();
|
||||
await expect(backupCheckbox).toBeEnabled();
|
||||
await backupCheckbox.click();
|
||||
|
||||
@@ -61,24 +53,11 @@ test("delete button unlocks only after backup + typed phrase", async ({
|
||||
await expect(deleteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("reveal also unlocks the backup checkbox", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await openSignOutDialog(page);
|
||||
|
||||
const backupCheckbox = page.getByTestId("signout-backup-confirm");
|
||||
await expect(backupCheckbox).toBeDisabled();
|
||||
|
||||
await page.getByTestId("nsec-reveal-toggle").click();
|
||||
await expect(backupCheckbox).toBeEnabled();
|
||||
});
|
||||
|
||||
test("completing both gates invokes sign_out", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await openSignOutDialog(page);
|
||||
|
||||
await page.getByTestId("nsec-copy").click();
|
||||
await page.getByTestId("signout-backup-confirm").click();
|
||||
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
|
||||
|
||||
@@ -104,16 +83,15 @@ test("cancel resets the gates for the next open", async ({ page }) => {
|
||||
await openSignOutDialog(page);
|
||||
|
||||
// Satisfy both gates, then cancel.
|
||||
await page.getByTestId("nsec-copy").click();
|
||||
await page.getByTestId("signout-backup-confirm").click();
|
||||
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("alertdialog")).not.toBeVisible();
|
||||
|
||||
// Reopen — everything must be locked again.
|
||||
// Reopen — everything must be reset again.
|
||||
await page.getByTestId("signout-open-dialog").click();
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
await expect(page.getByTestId("signout-backup-confirm")).toBeDisabled();
|
||||
await expect(page.getByTestId("signout-backup-confirm")).not.toBeChecked();
|
||||
await expect(page.getByTestId("signout-confirm-phrase")).toHaveValue("");
|
||||
await expect(page.getByTestId("signout-confirm")).toBeDisabled();
|
||||
});
|
||||
@@ -125,8 +103,8 @@ test("nsec load failure still allows sign-out (backup step degrades)", async ({
|
||||
await page.goto("/");
|
||||
await openSignOutDialog(page);
|
||||
|
||||
// Error shown in place of the key; checkbox is usable so the user is not
|
||||
// permanently locked out of signing out.
|
||||
// Error shown in place of the key; checkbox is still usable so the user is
|
||||
// not locked out of signing out.
|
||||
await expect(page.getByTestId("signout-nsec-error")).toContainText(
|
||||
"Keychain locked",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Simulate a native file drag entering the window. The backup test flow
|
||||
* listens for window-level dragenter with a "Files" payload and swaps in a
|
||||
* composer-style drop overlay over its host surface.
|
||||
*/
|
||||
export async function startWindowFileDrag(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(
|
||||
new File(["x"], "identity.ncryptsec", { type: "text/plain" }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new DragEvent("dragenter", { dataTransfer, bubbles: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Simulate the file drag leaving the window without dropping. */
|
||||
export async function endWindowFileDrag(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new DragEvent("dragend", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a text file with the given contents onto the element with `testId`. */
|
||||
export async function dropFileOnTestId(
|
||||
page: Page,
|
||||
testId: string,
|
||||
contents: string,
|
||||
name = "identity.ncryptsec",
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ testId, contents, name }) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(
|
||||
new File([contents], name, { type: "text/plain" }),
|
||||
);
|
||||
const target = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!target) throw new Error(`drop target ${testId} not found`);
|
||||
target.dispatchEvent(
|
||||
new DragEvent("drop", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ testId, contents, name },
|
||||
);
|
||||
}
|
||||
@@ -16,13 +16,12 @@ export async function seedActiveIdentity(
|
||||
);
|
||||
}
|
||||
|
||||
/** Navigate through the backup step (fresh-key path, encrypted default). */
|
||||
/** Navigate through the backup steps (fresh-key path). */
|
||||
export async function passThroughBackupStep(page: Page) {
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
// Encrypted-by-default: create the backup with the generated passphrase,
|
||||
// then advance. (The raw key path is behind "Show raw key instead".)
|
||||
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
await expect(page.getByTestId("ncryptsec-value")).toBeVisible();
|
||||
// Next always leads into the "Backup your key" step; backing up is
|
||||
// recommended, never required — "Skip for now" there advances to setup.
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
|
||||
await page.getByTestId("onboarding-skip").click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user