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:
Taylor Ho
2026-07-29 17:42:55 -04:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 855d25ef3a
commit 88d08e426c
33 changed files with 2904 additions and 1192 deletions
+55 -211
View File
@@ -188,11 +188,18 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
.map_err(|error| format!("encode nsec: {error}"))
}
/// Generate a 6-word passphrase for a new encrypted backup (EFF short
/// wordlist, OS entropy, ≈62 bits before the scrypt work factor).
/// Generate a passphrase for a new encrypted backup (EFF short wordlist, OS
/// entropy). `words` is clamped to the range allowed by `key_backup`;
/// `separator` joins the words (defaults to a space).
#[tauri::command]
pub fn generate_backup_passphrase() -> Result<String, String> {
crate::key_backup::generate_passphrase()
pub fn generate_backup_passphrase(
words: Option<u32>,
separator: Option<String>,
) -> Result<String, String> {
crate::key_backup::generate_passphrase(
words.map_or(crate::key_backup::DEFAULT_PASSPHRASE_WORDS, |w| w as usize),
separator.as_deref().unwrap_or(" "),
)
}
/// Core of [`create_ncryptsec_backup`], factored so tests can drive it with a
@@ -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",
+40 -20
View File
@@ -37,9 +37,15 @@ pub const BACKUP_LOG_N: u8 = 18;
/// Filename of the app-managed canonical backup inside the app data dir.
pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec";
/// Number of words in a generated backup passphrase. Six words from a
/// 1296-word list ≈ 62 bits of entropy before the scrypt work factor.
const PASSPHRASE_WORDS: usize = 6;
/// Default number of words in a generated backup passphrase. Three words
/// from a 1296-word list ≈ 31 bits of entropy before the scrypt work factor.
pub const DEFAULT_PASSPHRASE_WORDS: usize = 3;
/// Bounds for the generator's word-count control. At the lower bound a draw
/// can fall below [`MIN_PASSPHRASE_LEN`] (three 3-char words), so
/// [`generate_passphrase`] re-draws until the phrase meets the minimum.
pub const MIN_PASSPHRASE_WORDS: usize = 3;
pub const MAX_PASSPHRASE_WORDS: usize = 10;
/// EFF short wordlist 2.0 (1296 words, one per line).
const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt");
@@ -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)]
+27 -13
View File
@@ -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()));
}
}
+1
View File
@@ -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)
);
}
+246 -175
View File
@@ -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 &gt; Profile &gt;
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 &gt; Profile &gt;
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 &rarr; Profile &rarr;
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>
+32 -3
View File
@@ -40,9 +40,21 @@ export async function importIdentity(
);
}
/** Generate a 6-word passphrase (EFF short wordlist, OS entropy) in Rust. */
export async function generateBackupPassphrase(): Promise<string> {
return invokeTauri<string>("generate_backup_passphrase");
export type GeneratePassphraseOptions = {
/** Word count; Rust clamps to its allowed range (currently 4–10). */
words?: number;
/** Separator joined between words. Defaults to a space in Rust. */
separator?: string;
};
/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */
export async function generateBackupPassphrase(
options?: GeneratePassphraseOptions,
): Promise<string> {
return invokeTauri<string>("generate_backup_passphrase", {
words: options?.words,
separator: options?.separator,
});
}
/**
@@ -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",
+49 -7
View File
@@ -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;
+261 -86
View File
@@ -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();
+10 -1
View File
@@ -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();
});
+6 -28
View File
@@ -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",
);
+52
View File
@@ -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 },
);
}
+5 -6
View File
@@ -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();
}