diff --git a/desktop/scripts/texture-card/generate-card-texture.mjs b/desktop/scripts/texture-card/generate-card-texture.mjs index 75cc24e744..57ebc61a9b 100644 --- a/desktop/scripts/texture-card/generate-card-texture.mjs +++ b/desktop/scripts/texture-card/generate-card-texture.mjs @@ -12,83 +12,116 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const OUTPUT = path.resolve( - HERE, - "../../src/shared/ui/assets/card-texture.png", -); - -// CSS-pixel source geometry. Screenshotting at DPR 2 produces a crisp asset. -const CARD_SIZE = 640; -const OUTSET = 96; -const CAPTURE_SIZE = CARD_SIZE + OUTSET * 2; +const OUTPUT_DIRECTORY = path.resolve(HERE, "../../src/shared/ui/assets"); const DPR = 2; // Approved texture parameters, archived from the former runtime SVG filter. -const BLUR = 66; -const DILATE = Math.round(BLUR * 0.85); const THRESHOLD_BIAS = 0.302; const SLOPE = 8; const FREQUENCY = 0.999; const OCTAVES = 3; const SEED = 5315; -await mkdir(path.dirname(OUTPUT), { recursive: true }); +const TEXTURES = [ + { + filename: "card-texture.png", + color: "white", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-dark.png", + color: "#171b21", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-compact.png", + color: "white", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, + { + filename: "card-texture-dark-compact.png", + color: "#171b21", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, +]; + +await mkdir(OUTPUT_DIRECTORY, { recursive: true }); const browser = await chromium.launch(); try { - const page = await browser.newPage({ - deviceScaleFactor: DPR, - viewport: { height: CAPTURE_SIZE, width: CAPTURE_SIZE }, - }); + for (const texture of TEXTURES) { + const captureSize = texture.cardSize + texture.outset * 2; + const dilate = Math.round(texture.blur * 0.85); + const output = path.join(OUTPUT_DIRECTORY, texture.filename); + const page = await browser.newPage({ + deviceScaleFactor: DPR, + viewport: { height: captureSize, width: captureSize }, + }); - await page.setContent(` - -
- - -
`); + await page.setContent(` + +
+ + +
`); - await page.locator("#stage").screenshot({ - omitBackground: true, - path: OUTPUT, - }); + await page.locator("#stage").screenshot({ + omitBackground: true, + path: output, + }); + await page.close(); + + console.log(`Generated ${output}`); + console.log(`Asset: ${captureSize * DPR}×${captureSize * DPR}px @${DPR}x`); + console.log( + `Runtime slice: ${(texture.outset + texture.innerBand) * DPR}px; outset: ${texture.outset}px`, + ); + } } finally { await browser.close(); } - -console.log(`Generated ${OUTPUT}`); -console.log(`Asset: ${CAPTURE_SIZE * DPR}×${CAPTURE_SIZE * DPR}px @${DPR}x`); -console.log(`Runtime slice: ${(OUTSET + 112) * DPR}px; outset: ${OUTSET}px`); diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94d162e620..abce86202a 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16}, + atomic::{AtomicBool, AtomicU16, AtomicU8}, Arc, Mutex, }, }; @@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity}; use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; + pub struct AppState { pub keys: Mutex, + /// Durable backend holding `keys`. Updated after the key write and before + /// recovery flags are cleared so `get_identity` reports a consistent state. + pub(crate) identity_storage: AtomicU8, pub http_client: reqwest::Client, /// A no-redirect client for authenticated relay media fetches (download, /// clipboard copy, snapshot, editor). Every caller pre-validates the URL @@ -178,19 +183,20 @@ pub fn build_media_fetch_client() -> reqwest::Result { pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. - let keys = match identity_from_env() { + let (keys, identity_storage) = match identity_from_env() { Some(keys) => { eprintln!( "buzz-desktop: configured identity pubkey {}", keys.public_key().to_hex() ); - keys + (keys, IdentityStorage::Environment) } - None => Keys::generate(), + None => (Keys::generate(), IdentityStorage::Ephemeral), }; AppState { keys: Mutex::new(keys), + identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) .pool_idle_timeout(std::time::Duration::from_secs(10)) @@ -366,9 +372,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let resolved = load_or_create_identity(&data_dir)?; - // Write keys before setting the recovery flags (Release) so any thread - // that reads a flag as false with Acquire is guaranteed to see the keys. - *state.keys.lock().map_err(|e| e.to_string())? = resolved.keys; + // Write keys and storage before setting the recovery flags (Release) so + // any thread that reads a flag as false with Acquire sees consistent data. + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = resolved.keys; + state.set_identity_storage(resolved.storage); + } state.identity_lost.store( resolved.recovery == RecoveryState::Lost, std::sync::atomic::Ordering::Release, @@ -394,26 +404,6 @@ const IDENTITY_KEY_NAME: &str = "identity"; /// keyring is merely unreachable (the key IS in the keyring, must NOT generate). const MIGRATION_MARKER_NAME: &str = "identity.migrated"; -/// Recovery state produced by identity resolution. `None` means the app has -/// a real, usable identity. `Lost` means the keyring was reachable-but-empty -/// despite a prior successful migration — the key vanished externally. `KeyringLocked` -/// means the keyring is unreachable this boot but was used in the past -/// (marker present, no file) — the key still exists but is temporarily -/// inaccessible. Both non-`None` variants boot with an ephemeral key; the -/// frontend shows a different recovery screen for each. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RecoveryState { - None, - Lost, - KeyringLocked, -} - -/// The output of identity resolution. -struct ResolvedIdentity { - keys: Keys, - recovery: RecoveryState, -} - /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. @@ -465,6 +455,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result Result<(), String> { +) -> Result { match persist_identity_to_keyring(store, keys, legacy_path, data_dir) { - Ok(()) => Ok(()), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(e) => { eprintln!( "buzz-desktop: keyring write failed during import ({e}), \ falling back to identity.key" ); - save_key_file(legacy_path, keys) + save_key_file(legacy_path, keys)?; + Ok(IdentityStorage::LocalFile) } } } @@ -892,7 +897,7 @@ pub(crate) fn persist_imported_identity( keys: &Keys, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result<(), String> { +) -> Result { persist_imported_identity_impl(store, keys, legacy_path, data_dir) } @@ -920,15 +925,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("commit migration marker: {e}")) } -/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes -/// the migration marker only after a keyring success — on the file-fallback arm -/// the key is on disk and a marker would wrongly trip the next Unreachable boot -/// into failing closed. -enum PersistBackend { - Keyring, - File, -} - /// Generate a fresh identity, persist it through the store, return it. /// /// On a keyring-backed persist no file is written, so a later @@ -940,9 +936,10 @@ fn generate_and_persist( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result { +) -> Result<(Keys, IdentityStorage), String> { let keys = Keys::generate(); - if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? { + let storage = store_key_preferring_keyring(store, &keys, legacy_path)?; + if storage == IdentityStorage::SystemKeyring { let marker_path = migration_marker_path(data_dir); if let Err(e) = write_migration_marker(&marker_path) { eprintln!( @@ -956,7 +953,7 @@ fn generate_and_persist( "buzz-desktop: generated and saved identity pubkey {}", keys.public_key().to_hex() ); - Ok(keys) + Ok((keys, storage)) } /// Persist `keys` through the store, silently falling back to the `0o600` file @@ -968,17 +965,17 @@ fn store_key_preferring_keyring( store: &impl IdentityKeyStore, keys: &Keys, legacy_path: &std::path::Path, -) -> Result { +) -> Result { let nsec = keys .secret_key() .to_bech32() .map_err(|e| format!("encode nsec: {e}"))?; match store.store(IDENTITY_KEY_NAME, &nsec) { - Ok(()) => Ok(PersistBackend::Keyring), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(keyring_err) => { eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback"); save_key_file(legacy_path, keys)?; - Ok(PersistBackend::File) + Ok(IdentityStorage::LocalFile) } } } diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 485dfaea15..751bcf22e5 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() { let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); // The key was stored in the keyring (not the file), and the marker marks it. - assert!(!legacy_path.exists()); + assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring); assert!(migration_marker_path(dir.path()).exists()); assert_eq!( store @@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() { let from_file = load_key_file(&legacy_path).unwrap(); assert_key_eq(&resolved.keys, &from_file); // No marker: the file is the authoritative store, not the keyring. - assert!(!migration_marker_path(dir.path()).exists()); + assert!( + !migration_marker_path(dir.path()).exists() + && resolved.storage == IdentityStorage::LocalFile + ); } // ── New tests for the three defects fixed in this PR ───────────────────── @@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() { let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path()); // The policy core handles the keyring failure — Ok, not Err. - assert!( - result.is_ok(), - "must not propagate keyring failure when file fallback succeeds" - ); + assert_eq!(result.unwrap(), IdentityStorage::LocalFile); // Key is recoverable from the file on next boot. let from_file = load_key_file(&legacy_path).unwrap(); diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 142e3bac88..33ecf3cfca 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: state.identity_storage().as_str().to_string(), lost, locked, reset_failed, @@ -334,11 +335,17 @@ pub async fn save_ncryptsec_copy( #[tauri::command] pub async fn import_identity( nsec: String, + password: Option, app_handle: tauri::AppHandle, ) -> Result { tokio::task::spawn_blocking(move || { - let trimmed = nsec.trim(); - let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + // NIP-49 backups require a passphrase and decrypt entirely in Rust. + // Raw nsec/hex input follows the existing parser path unchanged. + let password = password.map(zeroize::Zeroizing::new); + let keys = crate::key_backup::recover_keys_from_input( + &nsec, + password.as_ref().map(|value| value.as_str()), + )?; // Serialize against persist_current_identity: hold this guard for the // full function body so a concurrent stale persist can't overwrite @@ -353,30 +360,14 @@ pub async fn import_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); - // Persist into the OS keyring first (store → read-back verify → marker → - // delete file). Falls back to the 0o600 file when the keyring is - // unavailable; returns Err only when both backends fail. - let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - - // Update in-memory keys BEFORE clearing recovery flags. The Release - // stores below pair with Acquire loads in get_identity: a reader - // observing false is guaranteed to see the updated keys. - let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; - - // Clear both recovery flags — an import is valid in either lost or - // keyring-locked state and resolves both. In the locked case the - // keyring is unreachable, so persist_imported_identity already fell - // back to identity.key; on the next Unreachable boot the file is - // loaded directly and when the keyring returns the adoption path - // picks it up. - state - .identity_lost - .store(false, std::sync::atomic::Ordering::Release); - state - .keyring_locked - .store(false, std::sync::atomic::Ordering::Release); + let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| { + // Persist into the OS keyring first (store → read-back verify → + // marker → delete file). Falls back to the 0o600 file when the + // keyring is unavailable; returns Err only when both backends fail. + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; @@ -386,6 +377,7 @@ pub async fn import_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, @@ -395,6 +387,69 @@ pub async fn import_identity( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Commit an imported identity: durably persist, swap in-memory keys, clear +/// recovery flags, then remove the previous identity's stale app-managed +/// backup. Caller must hold `state.identity_mutation`. +/// +/// Ordering is the contract: +/// +/// 1. `persist` runs FIRST. If it fails (`Err` from both keyring and file +/// fallback), nothing has changed — the previous identity stays live in +/// memory AND its valid canonical `identity.ncryptsec` stays on disk. +/// 2. Only after durable persistence do we swap `state.keys` and clear the +/// recovery flags. +/// 3. Stale-backup cleanup runs LAST and is deliberately best-effort: at that +/// point the import is durably committed, so reporting a cleanup failure +/// as a command `Err` would claim a half-applied import that actually +/// succeeded. The leftover blob is still passphrase-encrypted and is +/// replaced by the next backup creation; we log and move on. +fn commit_imported_identity( + state: &AppState, + data_dir: &std::path::Path, + keys: nostr::Keys, + persist: impl FnOnce(&nostr::Keys) -> Result, +) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> { + // Capture the previous pubkey up front for post-commit cleanup. + let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); + + let storage = persist(&keys)?; + + // Update in-memory keys BEFORE clearing recovery flags. The Release + // stores below pair with Acquire loads in get_identity: a reader + // observing false is guaranteed to see the updated keys. + let pubkey = keys.public_key(); + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = keys; + state.set_identity_storage(storage); + } + + // Clear both recovery flags — an import is valid in either lost or + // keyring-locked state and resolves both. In the locked case the + // keyring is unreachable, so the persist step already fell back to + // identity.key; on the next Unreachable boot the file is loaded + // directly and when the keyring returns the adoption path picks it up. + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + state + .keyring_locked + .store(false, std::sync::atomic::Ordering::Release); + + // Importing a different identity invalidates the app-managed backup: it + // encrypts the previous key and must not linger mislabeled. Best-effort + // per the ordering contract above. + if let Err(e) = crate::key_backup::cleanup_stale_backup(&previous_pubkey, &pubkey, data_dir) { + eprintln!( + "buzz-desktop: import committed, but stale key backup cleanup failed: {e}; \ + the leftover identity.ncryptsec encrypts the PREVIOUS key and will be \ + replaced by the next backup creation" + ); + } + + Ok((pubkey, storage)) +} + /// Make the current ephemeral identity durable by persisting it to the OS /// keyring (or falling back to identity.key). This is called when the user /// chooses to start a new identity instead of re-importing their previous one @@ -438,11 +493,12 @@ pub async fn persist_current_identity( let key_path = data_dir.join("identity.key"); let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + let storage = + crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - // Keys are already the live identity — only clear identity_lost. - // Release pairs with Acquire in get_identity so readers see - // consistent state. + // Keys are already the live identity. Record where the durable write + // landed before clearing identity_lost. + state.set_identity_storage(storage); state .identity_lost .store(false, std::sync::atomic::Ordering::Release); @@ -454,6 +510,7 @@ pub async fn persist_current_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, diff --git a/desktop/src-tauri/src/identity_storage.rs b/desktop/src-tauri/src/identity_storage.rs new file mode 100644 index 0000000000..b39c1a0331 --- /dev/null +++ b/desktop/src-tauri/src/identity_storage.rs @@ -0,0 +1,62 @@ +use nostr::Keys; + +use crate::app_state::AppState; + +/// Durable location of the active human identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum IdentityStorage { + Ephemeral = 0, + SystemKeyring = 1, + LocalFile = 2, + Environment = 3, +} + +impl IdentityStorage { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Ephemeral => "ephemeral", + Self::SystemKeyring => "system-keyring", + Self::LocalFile => "local-file", + Self::Environment => "environment", + } + } + + fn from_u8(value: u8) -> Self { + match value { + 1 => Self::SystemKeyring, + 2 => Self::LocalFile, + 3 => Self::Environment, + _ => Self::Ephemeral, + } + } +} + +impl AppState { + pub(crate) fn identity_storage(&self) -> IdentityStorage { + IdentityStorage::from_u8( + self.identity_storage + .load(std::sync::atomic::Ordering::Acquire), + ) + } + + pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) { + self.identity_storage + .store(storage as u8, std::sync::atomic::Ordering::Release); + } +} + +/// Recovery state produced by identity resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecoveryState { + None, + Lost, + KeyringLocked, +} + +/// Identity and persistence metadata produced by startup resolution. +pub(crate) struct ResolvedIdentity { + pub(crate) keys: Keys, + pub(crate) recovery: RecoveryState, + pub(crate) storage: IdentityStorage, +} diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index 6396911aef..f97bf95a67 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -13,6 +13,10 @@ use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; use nostr::{FromBech32, Keys, ToBech32}; +/// Bech32 prefix of NIP-49 encrypted secret keys. Import routing is +/// case-insensitive because bech32 permits all-uppercase encodings. +pub const NCRYPTSEC_HRP: &str = "ncryptsec1"; + /// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB). /// The blob self-describes its cost, so this can be raised later without /// breaking existing backups. @@ -108,6 +112,27 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { Ok(Keys::new(secret_key)) } +/// Recover identity keys from either an encrypted NIP-49 backup or the raw +/// nsec/hex formats accepted before encrypted imports were added. +pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result { + let trimmed = input.trim(); + let is_ncryptsec = trimmed + .get(..NCRYPTSEC_HRP.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP)); + + if is_ncryptsec { + 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}")) + } +} + +/// Path of the canonical app-managed backup file. +pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf { + data_dir.join(BACKUP_FILE_NAME) +} + /// Atomically write `ncryptsec` to `path` with owner-only permissions, then /// reread and byte-compare. Same crash-safety pattern as /// `app_state::save_key_file`. @@ -140,6 +165,28 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), Ok(()) } +/// Delete the app-managed backup if present. Missing files are already clean. +pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> { + let path = backup_file_path(data_dir); + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("delete stale backup file: {e}")), + } +} + +/// Remove the app-managed backup only when an import changes identities. +pub fn cleanup_stale_backup( + previous: &nostr::PublicKey, + new: &nostr::PublicKey, + data_dir: &std::path::Path, +) -> Result<(), String> { + if previous != new { + delete_backup_file(data_dir)?; + } + Ok(()) +} + /// Generate a passphrase of `word_count` EFF short-wordlist words joined by /// `separator`, using OS entropy. /// diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index e5892ad99e..b9713201e1 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -79,12 +79,59 @@ fn verify_backup_blob_catches_pubkey_mismatch() { assert!(err.contains("does not match identity"), "{err}"); } +// ── Import key recovery ─────────────────────────────────────────────────────── + +#[test] +fn recover_keys_ncryptsec_happy_path() { + let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn recover_keys_ncryptsec_requires_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err(); + 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 backup password or damaged key backup"); +} + +#[test] +fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() { + let upper = SPEC_NCRYPTSEC.to_ascii_uppercase(); + assert_eq!( + recover_keys_from_input(&upper, None).unwrap_err(), + "key backup requires a password" + ); + let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); + + let mut mixed = SPEC_NCRYPTSEC.to_string(); + mixed.replace_range(0..1, "N"); + let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err(); + assert!(err.contains("invalid ncryptsec"), "{err}"); +} + +#[test] +fn recover_keys_raw_nsec_path_unchanged() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + let recovered = recover_keys_from_input(&nsec, None).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + assert!(recover_keys_from_input("garbage", None).is_err()); +} + // ── File lifecycle ──────────────────────────────────────────────────────────── #[test] fn write_backup_file_persists_0600_and_verifies() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); let on_disk = std::fs::read_to_string(&path).unwrap(); @@ -101,7 +148,7 @@ fn write_backup_file_persists_0600_and_verifies() { #[test] fn write_backup_file_overwrites_atomically() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, "ncryptsec1old").unwrap(); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); @@ -113,6 +160,34 @@ fn write_backup_file_overwrites_atomically() { assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); } +#[test] +fn delete_backup_file_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + delete_backup_file(dir.path()).unwrap(); + let path = backup_file_path(dir.path()); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + delete_backup_file(dir.path()).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn cleanup_stale_backup_removes_only_on_identity_change() { + let dir = tempfile::tempdir().unwrap(); + let path = backup_file_path(dir.path()); + let a = Keys::generate().public_key(); + let b = Keys::generate().public_key(); + + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + cleanup_stale_backup(&a, &a, dir.path()).unwrap(); + assert!(path.exists(), "same identity must keep the backup"); + + cleanup_stale_backup(&a, &b, dir.path()).unwrap(); + assert!( + !path.exists(), + "identity change must remove the stale backup" + ); +} + #[test] fn generated_passphrase_respects_word_count_and_separator() { let words: std::collections::HashSet<&str> = diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7dcc5994ae..ee2a98f5c1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_storage; mod key_backup; mod linux_media; mod managed_agents; diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..3f04d3d7a1 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct IdentityInfo { pub pubkey: String, pub display_name: String, + /// Durable location of the active identity key. + pub storage: String, /// True when the app booted with an ephemeral key because the OS keyring /// was empty despite a prior successful migration (key was externally /// deleted). The frontend routes to the nsec re-import step when true. diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index d2e35e6839..18ddd80eb8 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -463,6 +463,26 @@ mod tests { assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once"); } + // ── NIP-49: the boot wipe destroys the app-managed key backup ───────────── + + #[test] + fn test_wipe_removes_app_managed_key_backup() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let backup = crate::key_backup::backup_file_path(&app_data); + std::fs::write(&backup, b"encrypted-backup-bytes").unwrap(); + + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false)); + + assert!(outcome.completed); + assert!( + !backup.exists(), + "sign-out wipe must destroy the app-managed key backup" + ); + } + // ── Test 3: keychain failure keeps sentinel ──────────────────────────────── #[test] diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs new file mode 100644 index 0000000000..b570153185 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +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( + 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("background encryption remains silent until download is clicked", () => { + 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, "one-two-three-four"); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); + 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, + ); +}); diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts new file mode 100644 index 0000000000..2f7d4a0bb0 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -0,0 +1,135 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { 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 "set-passphrase": + return { + ...state, + 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; + if (state.downloadPending) { + return { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + }; + } + return { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; + 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, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + 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 effectivePassphrase(state); +} +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) + ); +} diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs new file mode 100644 index 0000000000..bc0bb4b4d7 --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs @@ -0,0 +1,73 @@ +/** + * Pure-logic tests for key-import input classification (nsec vs NIP-49 + * ncryptsec) and submit gating. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey } from "nostr-tools/pure"; +import { + classifyKeyImportInput, + isPlausibleNcryptsec, + keyImportSubmitEnabled, + NCRYPTSEC_ENCODED_LENGTH, +} from "./keyImportInput.ts"; + +// NIP-49 spec vector — structurally valid encrypted backup. +const NCRYPTSEC = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +const VALID_NSEC = nsecEncode(generateSecretKey()); + +test("classify_by_hrp_with_whitespace_tolerance", () => { + assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec"); + assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec"); + assert.equal(classifyKeyImportInput("npub1whatever"), "unknown"); + assert.equal(classifyKeyImportInput(""), "unknown"); + // nsec must not be shadowed by the longer HRP check. + assert.equal(classifyKeyImportInput("nsec1"), "nsec"); +}); + +test("uppercase_bech32_encoding_classifies_and_gates_like_lowercase", () => { + // Bech32 permits an all-uppercase encoding; it must route to the + // encrypted path (matching Rust) and be submit-plausible. + const upper = NCRYPTSEC.toUpperCase(); + assert.equal(classifyKeyImportInput(upper), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(upper), true); + assert.equal(keyImportSubmitEnabled(upper, ""), false); + assert.equal(keyImportSubmitEnabled(upper, "hunter2hunter2"), true); + // Mixed case: routed encrypted (Rust reports the accurate error) but + // never plausible/submittable — mixed-case bech32 cannot decode. + const mixed = `N${NCRYPTSEC.slice(1)}`; + assert.equal(classifyKeyImportInput(mixed), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(mixed), false); + assert.equal(keyImportSubmitEnabled(mixed, "hunter2hunter2"), false); +}); + +test("plausible_ncryptsec_requires_complete_checksummed_nip49_payload", () => { + assert.equal(NCRYPTSEC.length, NCRYPTSEC_ENCODED_LENGTH); + assert.equal(isPlausibleNcryptsec(NCRYPTSEC), true); + assert.equal(isPlausibleNcryptsec(` ${NCRYPTSEC}\n`), true); + assert.equal(isPlausibleNcryptsec(NCRYPTSEC.slice(0, -1)), false); + assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC}q`), false); + // Same length and charset, but a changed checksum must not advance the UI. + assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC.slice(0, -1)}q`), false); + // '1' and 'b' / 'i' / 'o' are not in the Bech32 data charset. + assert.equal(isPlausibleNcryptsec("ncryptsec1bio"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1 with spaces"), false); +}); + +test("submit_gating_nsec_path_unchanged", () => { + assert.equal(keyImportSubmitEnabled(VALID_NSEC, ""), true); + assert.equal(keyImportSubmitEnabled("nsec1garbage", ""), false); + assert.equal(keyImportSubmitEnabled("", ""), false); +}); + +test("submit_gating_ncryptsec_requires_passphrase", () => { + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, ""), false); + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, "hunter2hunter2"), true); + // Structurally implausible blob never submits, passphrase or not. + assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false); +}); diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts new file mode 100644 index 0000000000..0f6fc609ed --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -0,0 +1,127 @@ +/** + * Pure classification + submit gating for the key-import form, unit-testable + * without a DOM. + * + * `ncryptsec1…` is a NIP-49 encrypted backup: no npub preview is possible + * (the pubkey is inside the encrypted payload) and a passphrase is required. + * Password validation happens in Rust at decrypt time; this module performs + * the password-independent Bech32 and NIP-49 structure checks needed to decide + * when the form can safely switch modes. + */ + +import { nsecToNpub } from "@/shared/lib/nostrUtils"; + +export type KeyImportKind = "nsec" | "ncryptsec" | "unknown"; + +const NCRYPTSEC_HRP = "ncryptsec"; +const NIP49_VERSION = 2; +const NIP49_PAYLOAD_BYTES = 91; +/** Current NIP-49 payloads encode to 162 characters including the checksum. */ +export const NCRYPTSEC_ENCODED_LENGTH = 162; +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_GENERATORS = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3, +] as const; + +function bech32Polymod(values: readonly number[]): number { + let checksum = 1; + for (const value of values) { + const high = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ value; + for (let index = 0; index < BECH32_GENERATORS.length; index += 1) { + if ((high >>> index) & 1) checksum ^= BECH32_GENERATORS[index]; + } + } + return checksum >>> 0; +} + +function expandBech32Hrp(hrp: string): number[] { + return [ + ...Array.from(hrp, (character) => character.charCodeAt(0) >>> 5), + 0, + ...Array.from(hrp, (character) => character.charCodeAt(0) & 31), + ]; +} + +function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null { + let accumulator = 0; + let bitCount = 0; + const bytes: number[] = []; + + for (const word of words) { + accumulator = (accumulator << 5) | word; + bitCount += 5; + while (bitCount >= 8) { + bitCount -= 8; + bytes.push((accumulator >>> bitCount) & 0xff); + } + } + + // Bech32 conversion without padding permits fewer than five zero remainder + // bits. Any larger or non-zero remainder is not a canonical byte encoding. + if (bitCount >= 5 || ((accumulator << (8 - bitCount)) & 0xff) !== 0) { + return null; + } + return bytes; +} + +export function classifyKeyImportInput(input: string): KeyImportKind { + const trimmed = input.trim(); + // Case-insensitive on the HRP to match the Rust classifier: an uppercase + // valid backup routes to the encrypted path (and decodes there); mixed + // case routes there too and fails in Rust with the accurate error. + if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec"; + if (trimmed.startsWith("nsec1")) return "nsec"; + return "unknown"; +} + +/** + * Password-independent NIP-49 validation used for the automatic UI transition. + * A candidate must have canonical casing and length, a valid Bech32 checksum, + * and the current 91-byte/version-2 NIP-49 payload shape. + */ +export function isPlausibleNcryptsec(input: string): boolean { + const trimmed = input.trim(); + if (trimmed.length !== NCRYPTSEC_ENCODED_LENGTH) return false; + if (trimmed !== trimmed.toLowerCase() && trimmed !== trimmed.toUpperCase()) { + return false; + } + + const normalized = trimmed.toLowerCase(); + const separatorIndex = normalized.lastIndexOf("1"); + if ( + separatorIndex !== NCRYPTSEC_HRP.length || + normalized.slice(0, separatorIndex) !== NCRYPTSEC_HRP + ) { + return false; + } + + const encoded = normalized.slice(separatorIndex + 1); + const words = Array.from(encoded, (character) => + BECH32_CHARSET.indexOf(character), + ); + if (words.some((word) => word < 0) || words.length <= 6) return false; + if (bech32Polymod([...expandBech32Hrp(NCRYPTSEC_HRP), ...words]) !== 1) { + return false; + } + + const payload = convertFiveBitWordsToBytes(words.slice(0, -6)); + return ( + payload?.length === NIP49_PAYLOAD_BYTES && payload[0] === NIP49_VERSION + ); +} + +/** + * Whether the import form's submit should be enabled. + * nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase. + */ +export function keyImportSubmitEnabled( + input: string, + passphrase: string, +): boolean { + const kind = classifyKeyImportInput(input); + if (kind === "ncryptsec") { + return isPlausibleNcryptsec(input) && passphrase.length > 0; + } + return nsecToNpub(input) !== null; +} diff --git a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx new file mode 100644 index 0000000000..610c104d95 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx @@ -0,0 +1,130 @@ +import { FileKey2, LockKeyhole, LockOpen } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { cn } from "@/shared/lib/cn"; + +const BACKUP_KEY_DOTS = [ + "key-dot-1", + "key-dot-2", + "key-dot-3", + "key-dot-4", + "key-dot-5", + "key-dot-6", + "key-dot-7", + "key-dot-8", + "key-dot-9", +] as const; + +const TIMELINE_CONNECTOR_DOTS = [ + "connector-dot-1", + "connector-dot-2", + "connector-dot-3", + "connector-dot-4", +] as const; + +const TIMELINE_DOT_INITIAL = { opacity: 0.35, scale: 0.85 }; +const TIMELINE_DOT_PULSE = { + opacity: [0.35, 1, 0.35], + scale: [0.85, 1.25, 0.85], +}; +const TIMELINE_DOT_TRANSITION = { + duration: 0.7, + ease: "easeInOut" as const, + repeat: Number.POSITIVE_INFINITY, + repeatDelay: 1.2, +}; +const TIMELINE_TOP_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( + (_, index) => ({ + ...TIMELINE_DOT_TRANSITION, + delay: index * 0.16, + }), +); +const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( + (_, index) => ({ + ...TIMELINE_DOT_TRANSITION, + delay: (index + TIMELINE_CONNECTOR_DOTS.length) * 0.16 + 0.24, + }), +); + +/** + * Decorative timeline shared by backup creation and encrypted-backup restore. + * Backup creation reads key → password → lock; restore reads encrypted file → + * password → unlocked account. The password field is layered over the center. + */ +export function BackupPasswordTimeline({ + className, + mode = "backup", +}: { + className?: string; + mode?: "backup" | "restore"; +}) { + const reduceMotion = useReducedMotion() ?? false; + + return ( +
+ {mode === "restore" ? ( +
+ +
+ ) : ( +
+ {BACKUP_KEY_DOTS.map((dot) => ( + + ))} +
+ )} +
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ {mode === "restore" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index ed2184baaa..99d9c6324d 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,183 +1,438 @@ -import { AlertTriangle, Info, RefreshCw } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react"; +import { useReducedMotion } from "motion/react"; import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; +import type { IdentityStorage } from "@/shared/api/types"; +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 { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { + ONBOARDING_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; /** - * Pure helper so the disabled logic can be unit-tested without a DOM. - * - * Disabled while loading (key not fetched yet) or after a failed load (only - * the explicit "Skip for now" ghost advances past an error). + * 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({ - isLoading, - loadError, -}: { - isLoading: boolean; - loadError: string | null; -}): boolean { - 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"; + +const BACKUP_OPTION_CLASS = + "flex min-h-48 w-full flex-col items-start justify-start px-6 py-5 text-left text-foreground"; + +/** Viewing the key never blocks onboarding — Next is always actionable. */ +export function backupNextDisabled(): boolean { + return false; } type BackupStepProps = { direction: OnboardingTransitionDirection; + identityStorage?: IdentityStorage; onBack: () => void; onNext: () => void; + onOpenPasswordBackup: () => void; + onShowOptions: () => void; + optionsExpanded: boolean; + returningFromSecurity: boolean; }; /** - * Onboarding backup step — shows the user their freshly created key so they - * can save it somewhere safe. Only shown on the fresh-key path. + * Onboarding identity-key step — shows the freshly created key, then opens a + * dark backup-options state. Copy fetches the raw key only after an explicit + * click; password backup opens the separate security flow. Neither method + * blocks Next. */ -export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { +export function BackupStep({ + direction, + identityStorage, + onBack, + onNext, + onOpenPasswordBackup, + onShowOptions, + optionsExpanded, + returningFromSecurity, +}: BackupStepProps) { + const reduceMotion = useReducedMotion() ?? false; + const [created, setCreated] = React.useState(introPlayed || reduceMotion); + const [copyState, setCopyState] = React.useState< + "idle" | "copying" | "copied" + >("idle"); + const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(true); - const [loadError, setLoadError] = React.useState(null); + const [isRevealed, setIsRevealed] = React.useState(false); const cancelledRef = React.useRef(false); + const copiedTimerRef = React.useRef(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; + if (reduceMotion) { + introPlayed = true; + setCreated(true); + return; } - }, []); + const timer = window.setTimeout(() => { + introPlayed = true; + setCreated(true); + }, INTRO_HOLD_MS); + return () => window.clearTimeout(timer); + }, [reduceMotion]); React.useEffect(() => { cancelledRef.current = false; - void loadNsec(); return () => { // Back-during-fetch: cancel any in-flight setState calls and clear the // 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); }; - }, [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], + ); + const storageDescription = + identityStorage === "system-keyring" + ? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key." + : identityStorage === "local-file" + ? "Your system keychain wasn’t available, so Buzz keeps your identity key in a private file on this device." + : "Buzz keeps your identity key protected on this device. Make a separate backup in case you lose access."; + const storageTitle = + identityStorage === "system-keyring" + ? "Protected by your system keychain" + : identityStorage === "local-file" + ? "Stored in private device storage" + : "Protected in private device storage"; + const introStorageDescription = + identityStorage === "system-keyring" + ? "Buzz keeps your identity key in your system keychain." + : identityStorage === "local-file" + ? "Buzz keeps your identity key in a private file on this device because the system keychain wasn’t available." + : "Your identity key is protected on this device."; + + if (optionsExpanded) { + return ( + +
+

+ Backup options +

+

+ Your identity key works like a password for your Buzz account. Keep + a copy somewhere safe. You can create a backup file and lock it with + a password you can remember. +

+
+ +
+
+
+ {storageTitle} + + {storageDescription} + +
+ +
+ + Saved in your password manager + + + Copy your identity key, then save it in a password manager like + 1Password. + + +
+ +
+ + Locked in a backup file + + + Create a backup file and choose a password you can remember. + You’ll need both to restore your account. + + +
+
+ + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can continue + and find it later in Settings > Profile > Identity. +

+ ) : null} +
+
+ ); + } return (
-

- Your unique identity key has been created + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {created + ? "Your unique identity key has been created" + : "Creating your identity key"}

-

- This key is stored in your system keychain, but save it some place - safe in case you ever need to restore your account. -

-
- -
- {isLoading ? ( -
- - Loading your private key… -
- ) : loadError ? ( -
-
- - - Could not retrieve your private key: {loadError}. You can - continue and find it later in Settings > Profile > - Identity. - -
- -
- ) : nsec ? ( - -
- -
-
- ) : ( -

- No key available to back up. -

- )} - - {nsec ? ( -

- - - Never share your private key. Anyone with this key can impersonate - you and access everything in your account. - + review backup options + {" "} + for ways to restore your account.

) : null}
- - + + + ) : ( +
+
+ +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+ +
+
+ + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} - {loadError ? ( +

+ + + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. + +

+
+
+ )} + + {created ? ( + - ) : null} - - + +
+ ) : null}
); } diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..9370d5c061 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -0,0 +1,745 @@ +import { Check, CircleHelp, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; +import { createPortal } from "react-dom"; + +import { + getNsec, + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; + +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; + saveError?: string | null; + /** Optional onboarding footer target for the verification CTA. */ + verifyButtonPortal?: HTMLElement | null; + /** Host-owned progress so it survives this component unmounting. */ + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; + /** Fired once when the user completes the test successfully. */ + onVerified?: () => void; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; +const VERIFICATION_CONNECTOR_DOTS = [ + "verification-dot-1", + "verification-dot-2", + "verification-dot-3", + "verification-dot-4", +] as const; +const VERIFICATION_DOT_ANIMATION = { + opacity: [0.35, 1, 0.35], + scale: [0.85, 1.25, 0.85], +}; +const VERIFICATION_DOT_TRANSITION = { + duration: 0.7, + ease: "easeInOut" as const, + repeat: Number.POSITIVE_INFINITY, + repeatDelay: 1.2, +}; +const PRIVATE_KEY_MASK = Array.from({ length: 63 }, () => "•").join("\u200b"); + +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( + () => + 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 ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +function VerificationConnector({ + delayOffset, + reduceMotion, +}: { + delayOffset: number; + reduceMotion: boolean; +}) { + return ( +
+ {VERIFICATION_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ ); +} + +/** + * "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, + saveError, + verifyButtonPortal, + 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(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const [successNsec, setSuccessNsec] = React.useState(null); + const [isSuccessNsecRevealed, setIsSuccessNsecRevealed] = + React.useState(false); + const [isLoadingSuccessNsec, setIsLoadingSuccessNsec] = React.useState(false); + const [successNsecError, setSuccessNsecError] = React.useState( + null, + ); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(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 toggleSuccessNsec = React.useCallback(async () => { + if (isSuccessNsecRevealed) { + setIsSuccessNsecRevealed(false); + return; + } + if (successNsec) { + setIsSuccessNsecRevealed(true); + return; + } + setIsLoadingSuccessNsec(true); + setSuccessNsecError(null); + try { + const value = await getNsec(); + if (!mountedRef.current) return; + setSuccessNsec(value); + setIsSuccessNsecRevealed(true); + } catch (err) { + if (!mountedRef.current) return; + setSuccessNsecError( + err instanceof Error ? err.message : "Could not retrieve your key.", + ); + } finally { + if (mountedRef.current) setIsLoadingSuccessNsec(false); + } + }, [isSuccessNsecRevealed, successNsec]); + + 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 ( +
+ {reduceMotion ? null : } + + + + {isCeremony ? ( +
+

+ Your backup works! +

+

+ File and password verified. Keep them both somewhere safe — + that's all you need to restore your identity. +

+
+

+ {isSuccessNsecRevealed && successNsec + ? successNsec + : PRIVATE_KEY_MASK} +

+ +
+ {successNsecError ? ( +

+ {successNsecError} +

+ ) : null} +
+ ) : ( + <> +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+ + )} +
+ {isCeremony ? null : ( + + )} +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + + { + 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" + /> + + {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 +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {onSaveCopy ? ( +
+ +
+ ) : null} + {saveError ? ( +

{saveError}

+ ) : null} +
+ ) : ( + + {(() => { + const fileRow = ( +
+ + + + {fileName} + +
+ ); + const passwordField = ( +
+ 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} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); + if (!isSpotlight) { + return ( + <> + {fileRow} +

+ Enter the password to prove you can unlock this backup. +

+ {passwordField} + + ); + } + return ( +
+
+ ); + })()} + {(() => { + const verifyButton = ( + + ); + if (verifyButtonPortal === undefined) { + return ( +
{verifyButton}
+ ); + } + return verifyButtonPortal + ? createPortal(verifyButton, verifyButtonPortal) + : null; + })()} +
+ )} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx new file mode 100644 index 0000000000..3d69150049 --- /dev/null +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -0,0 +1,139 @@ +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; +import { OnboardingFooter } from "./OnboardingFooter"; +import { + type OnboardingTransitionDirection, + OnboardingSlideTransition, +} from "./OnboardingSlideTransition"; +import { + type EncryptedBackupSession, + EncryptedBackupCreator, +} from "./EncryptedBackupCreator"; + +type DownloadKeyStepProps = { + direction: OnboardingTransitionDirection; + /** Backup state owned by the parent flow across the creation and test views. */ + session: EncryptedBackupSession; + onBack: () => void; +}; + +/** + * Password-backup security subview within the identity-key onboarding step. + * 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, +}: DownloadKeyStepProps) { + const reduceMotion = useReducedMotion() ?? false; + // Once the encrypted payload is saved, the creator advances to its guided + // backup test while this surface keeps its own navigation. + const hasCreated = session.created; + const hasVerifiedBackup = session.verified; + const hasSelectedBackup = session.test.stage === "password"; + const [primaryActionSlot, setPrimaryActionSlot] = + React.useState(null); + + return ( + + + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {hasVerifiedBackup + ? "Your backup is verified" + : hasSelectedBackup + ? "That’s your backup file" + : hasCreated + ? "Optionally, test your backup" + : "Backup your key with a password"} +

+

+ {hasVerifiedBackup + ? "Your file and password can restore your identity." + : hasSelectedBackup + ? "Now enter your password to prove you can unlock it." + : hasCreated + ? "Learn how 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."} +

+
+ +
+
+ +
+ +
+
+
+
+ + +
+ + + + ); +} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx new file mode 100644 index 0000000000..bb76166bd7 --- /dev/null +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,885 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; +import { createPortal } from "react-dom"; + +import { + createNcryptsecBackup, + generateBackupPassphrase, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + downloadDisabled, + passphraseIssue, + pendingEncryptPassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, + MIN_PASSPHRASE_LEN, + type EncryptedBackupEvent, + type EncryptedBackupState, +} from "../lib/encryptedBackup"; +import { + type BackupTestProgress, + BackupTestFlow, + initialBackupTestProgress, +} from "./BackupTestFlow"; +import { BackupPasswordTimeline } from "./BackupPasswordTimeline"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Pause after the last keystroke before the background KDF starts, so typing + * past the minimum length doesn't launch an encryption per character. + */ +const ENCRYPT_DEBOUNCE_MS = 400; + +const PENDING_TICKER_MESSAGES = [ + "Downloading once finished", + "Encrypting your password", + "Just a bit longer...", +] as const; + +/** How long each ticker message holds before sliding to the next. */ +const PENDING_TICKER_INTERVAL_MS = 2500; + +/** Matches the `duration-300` slide transition on the ticker column. */ +const PENDING_TICKER_SLIDE_MS = 300; + +/** + * Vertical ticker for the queued-download button label — cycles through the + * pending messages by sliding a stacked column inside a one-line viewport. + * The column ends with a clone of the first message, so the wrap-around + * slides up from the bottom like every other step; once the clone settles, + * the column snaps (transition disabled) back to the real first row. All + * lines render at all times, so the button keeps the width of the longest + * message instead of resizing on each swap. + */ +function PendingDownloadTicker() { + // Index into the rendered column (messages + trailing clone of the first). + const [position, setPosition] = React.useState(0); + const [snap, setSnap] = React.useState(false); + + React.useEffect(() => { + const timer = window.setInterval( + () => setPosition((current) => current + 1), + PENDING_TICKER_INTERVAL_MS, + ); + return () => window.clearInterval(timer); + }, []); + + // The clone is visually identical to the first message: once its slide-in + // finishes, jump back to the real first row without animating. + React.useEffect(() => { + if (position !== PENDING_TICKER_MESSAGES.length) return; + const timer = window.setTimeout(() => { + setSnap(true); + setPosition(0); + }, PENDING_TICKER_SLIDE_MS); + return () => window.clearTimeout(timer); + }, [position]); + + // Re-enable the transition one frame after the snap has painted. + React.useEffect(() => { + if (!snap) return; + const raf = window.requestAnimationFrame(() => setSnap(false)); + return () => window.cancelAnimationFrame(raf); + }, [snap]); + + // The clone row duplicates the first message's text, so it carries its own + // stable key. + const column = [ + ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })), + { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] }, + ]; + + return ( + + + {column.map((row) => ( + + {row.message} + + ))} + + + ); +} + +/** + * Everything about an in-progress backup that must survive this component + * unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the + * backup test passed, where the file was saved, the save-once guard, and the + * test-flow progress. Hosts that need the state to outlive the creator (the + * onboarding flow, where Back unmounts the step) call + * `useEncryptedBackupSession` at a longer-lived level and pass it down; + * otherwise the creator owns a private session internally. + */ +export type EncryptedBackupSession = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + /** + * True once the encrypted payload has been committed AND saved to disk. + * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching + * the blob itself — keeping them outside the ncryptsec confinement scan. + */ + created: boolean; + /** True once the user has passed the backup test. */ + verified: boolean; + setVerified: React.Dispatch>; + savedPath: string | null; + setSavedPath: React.Dispatch>; + /** The committed blob a save was already kicked off for (save-once guard). */ + savedForRef: React.MutableRefObject; + test: BackupTestProgress; + setTest: React.Dispatch>; +}; + +/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */ +export function useEncryptedBackupSession(): EncryptedBackupSession { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [verified, setVerified] = React.useState(false); + const [savedPath, setSavedPath] = React.useState(null); + const savedForRef = React.useRef(null); + const [test, setTest] = React.useState( + initialBackupTestProgress, + ); + return React.useMemo( + () => ({ + state, + dispatch, + created: state.ncryptsec !== null && savedPath !== null, + verified, + setVerified, + savedPath, + setSavedPath, + savedForRef, + test, + setTest, + }), + [state, verified, savedPath, test], + ); +} + +/** + * Return to a secure saved-password placeholder. The encrypted blob survives + * for instant re-download, while no password or test attempt is retained. + */ +export function backupSessionToPasswordEntry( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "back-to-password" }); + session.setVerified(false); + session.setSavedPath(null); + session.setTest(initialBackupTestProgress); +} + +/** Discard all backup-creation and verification progress. */ +export function resetEncryptedBackupSession( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "start-new-backup" }); + session.setVerified(false); + session.setSavedPath(null); + session.savedForRef.current = null; + session.setTest(initialBackupTestProgress); +} + +type EncryptedBackupCreatorProps = { + /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ + variant?: "spotlight" | "boxed"; + /** + * When set, the "Download" button is portaled into this element instead of + * rendering inline. + */ + createButtonPortal?: HTMLElement | null; + /** Optional onboarding footer target for the guided-test verification CTA. */ + verifyButtonPortal?: HTMLElement | null; + /** Extra classes for the "Download" button. */ + createButtonClassName?: string; + /** + * Host-owned session so the backup state survives this component + * unmounting (onboarding Back navigation). Omitted = private session. + */ + session?: EncryptedBackupSession; + /** Fired once the encrypted payload has been created (before saving). */ + onCreated?: () => void; + /** Fired only after the encrypted key file has been saved successfully. */ + onSaved?: (path: string) => void; + /** Whether creation continues into onboarding's guided test ceremony. */ + guidedTest?: boolean; + /** Fired once when the user completes the backup test successfully. */ + onVerified?: () => void; +}; + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, + securityTheme = false, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; + securityTheme?: boolean; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow shared by onboarding and + * Settings. The raw private key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly. Background encryption is silent; clicking + * mid-encryption reveals the queued-download ticker until the KDF finishes. + */ +export function EncryptedBackupCreator({ + variant = "spotlight", + createButtonPortal, + verifyButtonPortal, + createButtonClassName, + session: sessionProp, + onCreated, + onSaved, + guidedTest = true, + onVerified, +}: EncryptedBackupCreatorProps) { + // Hosts without a longer-lived session get a private one (settings card). + const fallbackSession = useEncryptedBackupSession(); + const session = sessionProp ?? fallbackSession; + const { state, dispatch, savedPath, setSavedPath, savedForRef } = session; + const [isRevealed, setIsRevealed] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const [isSaving, setIsSaving] = React.useState(false); + const [confirmNewPassword, setConfirmNewPassword] = React.useState(false); + const mountedRef = React.useRef(true); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // A queued download locks the form — mask the password too so it isn't + // left readable on screen while the user waits for the save dialog. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + // Correlate KDF completion by an opaque request id. The password exists only + // in this short-lived effect closure and is cleared from reducer state once + // Rust returns; stale completions cannot commit. + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let cancelled = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelled) return; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }), + ) + .catch((err: unknown) => + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }), + ); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [dispatch, pendingPassphrase, skipDebounce, state.nextRequestId]); + + // Download commit: fires once per committed blob, whether the commit was + // instant (encryption already done) or resolved a queued download. The flow + // only advances to the test view once the file is actually on disk — a + // canceled save dialog or a save failure rolls the commit back to the + // password form so "Download backup" can be clicked again. + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || savedForRef.current === ncryptsec) return; + savedForRef.current = ncryptsec; + onCreated?.(); + setIsSaving(true); + setSaveError(null); + const rollBack = () => { + savedForRef.current = null; + dispatch({ type: "back-to-password" }); + }; + void saveNcryptsecCopy(ncryptsec) + .then((path) => { + if (path) { + setSavedPath(path); + onSaved?.(path); + } else { + // User canceled the native save dialog — nothing was downloaded. + rollBack(); + } + }) + .catch((err: unknown) => { + rollBack(); + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + }) + .finally(() => { + if (mountedRef.current) setIsSaving(false); + }); + }, [ + dispatch, + onCreated, + onSaved, + savedForRef, + setSavedPath, + state.ncryptsec, + ]); + + const handleSaveCopy = React.useCallback(async () => { + if (!state.ncryptsec || isSaving) return; + setIsSaving(true); + setSaveError(null); + try { + const path = await saveNcryptsecCopy(state.ncryptsec); + if (mountedRef.current && path) { + setSavedPath(path); + onSaved?.(path); + } + } catch (err) { + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, [isSaving, onSaved, setSavedPath, state.ncryptsec]); + + const { setVerified, test, setTest } = session; + const handleVerified = React.useCallback(() => { + setVerified(true); + onVerified?.(); + }, [onVerified, setVerified]); + + const issue = passphraseIssue(state.passphrase); + const showBackupTimeline = + variant === "spotlight" && + !state.savedPassword && + !state.createError && + !saveError; + + // The test view requires a successful save, not just a committed blob — + // while the native save dialog is open the password form stays put. + if (state.ncryptsec && savedPath && guidedTest) { + return ( +
+ void handleSaveCopy()} + onVerified={handleVerified} + progress={test} + saveError={saveError} + variant={variant} + verifyButtonPortal={verifyButtonPortal} + /> +
+ ); + } + // Without the guided test (settings), a completed save keeps the form + // visible in its saved-password state: masked input, instant re-download, + // and the change-password confirmation guarding any edit. + + return ( +
+
+ {showBackupTimeline ? : null} +
+ { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onPaste={(event) => { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onChange={(event) => + dispatch({ type: "set-passphrase", value: event.target.value }) + } + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + if (downloadDisabled(state) || isSaving) return; + if (state.savedPassword && state.ncryptsec) { + void handleSaveCopy(); + return; + } + dispatch({ type: "download-clicked" }); + }} + placeholder={ + state.savedPassword + ? "" + : `Password (min ${MIN_PASSPHRASE_LEN} characters)` + } + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + {state.savedPassword ? ( +
+ •••••••••••••••••••••••••••••••• +
+ ) : null} + {state.savedPassword ? ( + + Backup password saved; hidden for security. + + ) : null} + + setConfirmNewPassword(true) + : undefined + } + onGenerated={(value) => { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + securityTheme={variant === "spotlight"} + /> + {issue ? ( +

+ {issue} +

+ ) : null} +
+
+ + {state.savedPassword && state.ncryptsec && savedPath ? ( +
+

+ Backup saved to {savedPath} +

+

+ Your password isn't kept — download another copy anytime, or start + over to choose a new password. +

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

+ {state.createError} +

+ ) : null} + + {saveError ? ( +

+ {saveError} +

+ ) : null} + + {(() => { + // A queued download gets an explicit progress treatment. Background + // encryption stays silent until the user asks to download. + const createButton = ( +
+ {state.downloadPending || isSaving ? ( + + ) : null} + +
+ ); + // `undefined` = inline (settings); `null` = slot not mounted yet + // (skip a frame rather than flashing the button inline). + if (createButtonPortal === undefined) + return
{createButton}
; + return createButtonPortal + ? createPortal(createButton, createButtonPortal) + : null; + })()} + + + + Create a new backup password? + + Starting over lets you pick a new password and download a fresh + backup file. Backups you saved earlier will still work — just use + the password you created them with. + + + + + Keep current backup + + { + dispatch({ type: "start-new-backup" }); + setSavedPath(null); + savedForRef.current = null; + setTest(initialBackupTestProgress); + setIsRevealed(false); + }} + > + Start with a new password + + + + +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx index 0376fc9709..a6a02f38c0 100644 --- a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx +++ b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx @@ -22,8 +22,8 @@ export function KeyringLockedScreen() { }, []); const handleImport = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); // Update the identity query cache so useIdentityQuery observers see // locked: false. The bootedLocked latch in hooks.ts will then route // to RelaunchRequiredScreen via bootedLocked && !identityLocked. diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index ca87c76636..cee17c68f8 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -1,20 +1,33 @@ import * as React from "react"; import type { QueryClient } from "@tanstack/react-query"; +import { ArrowUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import { getIdentity, importIdentity, persistCurrentIdentity, } from "@/shared/api/tauriIdentity"; +import type { IdentityStorage } from "@/shared/api/types"; 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, + resetEncryptedBackupSession, + useEncryptedBackupSession, +} from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; import { LandingBees } from "./LandingBees"; -import { NostrKeyImportForm } from "./NostrKeyImportForm"; +import { + NostrKeyImportForm, + type NostrKeyImportStage, +} from "./NostrKeyImportForm"; import { ONBOARDING_LANDING_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, OnboardingChrome, } from "./OnboardingChrome"; import { OnboardingFooterProvider } from "./OnboardingFooter"; @@ -28,6 +41,8 @@ export type MachineOnboardingPage = | "setup" | "config"; +type BackupSubview = "created" | "options" | "password"; + /** A pending navigation the parent should execute after RouterProvider mounts. */ export type PostOnboardingNavigation = { to: string; @@ -61,10 +76,27 @@ export function MachineOnboardingFlow({ const [error, setError] = React.useState(null); const [isPending, setIsPending] = React.useState(false); const [identityWasImported, setIdentityWasImported] = React.useState(false); + const [keyImportStage, setKeyImportStage] = + React.useState("key-entry"); const [selectedPubkey, setSelectedPubkey] = React.useState( null, ); + const [identityStorage, setIdentityStorage] = React.useState< + IdentityStorage | undefined + >(); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + const [backupSubview, setBackupSubview] = + React.useState("created"); + const [backupDirection, setBackupDirection] = React.useState< + "forward" | "backward" + >("forward"); + const [returningFromSecurity, setReturningFromSecurity] = + React.useState(false); + // Owned here so switching between the yellow onboarding view and the dark + // security subview keeps the created backup, password, and test progress. + const backupSession = useEncryptedBackupSession(); + const reduceMotion = useReducedMotion() ?? false; + const isSecuritySubview = page === "backup" && backupSubview !== "created"; const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -79,6 +111,10 @@ export function MachineOnboardingFlow({ const identity = await getIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -101,6 +137,10 @@ export function MachineOnboardingFlow({ const identity = await persistCurrentIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -112,8 +152,8 @@ export function MachineOnboardingFlow({ }, [queryClient]); const importExistingIdentity = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); continueWithIdentity(identity.pubkey); queryClient.setQueryData(["identity"], identity); setIdentityWasImported(true); @@ -126,6 +166,8 @@ export function MachineOnboardingFlow({ return (
{page === "identity" ? : null} - {page !== "identity" ? ( + {isSecuritySubview ? ( +
+ +
+ ) : page !== "identity" ? ( @@ -178,9 +237,12 @@ export function MachineOnboardingFlow({ : "Create a new identity key"} -
- - ) : ( - { - setNsecInput(event.target.value); - setImportError(null); - }} - placeholder="nsec1..." - ref={inputRef} - spellCheck={false} - type="password" - value={nsecInput} - /> - )} -
+ + + + ) : ( + { + setNsecInput(event.target.value); + setImportError(null); + }} + placeholder="nsec1..." + ref={inputRef} + spellCheck={false} + type="password" + value={nsecInput} + /> + )} + + ) : null} - {variant === "spotlight" ? null : ( - <> - { - void handleFiles(event.currentTarget.files); - event.currentTarget.value = ""; - }} - ref={fileInputRef} - tabIndex={-1} - type="file" - /> + {/* Hidden file input shared by both variants: the default drop zone and + the spotlight "Choose a backup file" button both open it. Accepts the + .ncryptsec backups our own save flow emits alongside raw .key files. */} + { + void handleFiles(event.currentTarget.files); + event.currentTarget.value = ""; + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> - + ) : null} + + {isPasswordStage ? ( +
+ + +
+ { + setPassphrase(event.target.value); + setImportError(null); + }} + placeholder="Backup password" + ref={passphraseInputRef} + spellCheck={false} + type={isRevealed ? "text" : "password"} + value={passphrase} /> - setIsRevealed((current) => !current)} + size="icon" + type="button" + variant="ghost" > - Drop a key here - - - - )} + {isRevealed ? ( +
+
+ ) : null} -
- {previewNpub ? ( - variant === "spotlight" ? ( - // Spotlight uses the backup step's quiet caption language: - // centered, unboxed, with the npub in the shared olive key ink. -
-

-

-

- {previewNpub} -

-
- ) : ( -
- -
-

- This will use this Nostr identity: + {!isPasswordStage || errorMessage ? ( +

+ {!isPasswordStage && previewNpub ? ( + variant === "spotlight" ? ( + // Spotlight uses the backup step's quiet caption language: + // centered, unboxed, with the npub in the shared olive key ink. +
+

+

-

+

{previewNpub}

-
- ) - ) : null} + ) : ( +
+ +
+

+ This will use this Nostr identity: +

+

+ {previewNpub} +

+
+
+ ) + ) : null} - {showInvalidHint && !errorMessage ? ( -

- Waiting for a valid nsec1 key -

- ) : null} + {showInvalidHint && !errorMessage ? ( +

+ {isEncryptedInput + ? "Waiting for a complete ncryptsec backup" + : "Waiting for a valid nsec1 key"} +

+ ) : null} - {errorMessage ? ( -

{errorMessage}

- ) : null} -
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) : null} diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx index 936313bce0..7a52ae4999 100644 --- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx @@ -2,8 +2,8 @@ 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. + * default config, community choice, community profile, meet the team. Password + * backup is an optional subview of identity/key, not another position. */ export const TOTAL_ONBOARDING_PAGES = 7; @@ -17,6 +17,9 @@ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6"; */ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`; +/** Inverted primary action used only on dark backup-security surfaces. */ +export const ONBOARDING_SECURITY_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} bg-white text-black/80 hover:bg-white/90 hover:text-black`; + /** * Primary-CTA styling for the landing screen only: the shared pill with the * chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved @@ -24,6 +27,10 @@ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(- */ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`; +/** Shared quiet pill for secondary actions throughout onboarding. */ +export const ONBOARDING_SECONDARY_CTA_CLASS = + "h-9 rounded-full bg-foreground/10 px-6 text-foreground hover:bg-foreground/15 hover:text-foreground"; + /** * Icon-control styling for onboarding surfaces that sit on the textured card: * olive backup ink (`--buzz-onboarding-backup-ink`) with a plain @@ -34,6 +41,10 @@ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(- export const ONBOARDING_INK_ICON_CLASS = "text-[color:var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground"; +/** Icon controls on the dark noisy backup surfaces stay visually unboxed. */ +export const ONBOARDING_SECURITY_ICON_CLASS = + "text-muted-foreground hover:bg-transparent hover:text-foreground"; + /** * Shared onboarding chrome shown on every page after the landing screen: a * static Buzz mark pinned to the top-left, and a centered pagination track that diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 01a226e3de..a3653f750f 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -388,8 +388,8 @@ export function OnboardingFlow({ // key's relay profile reseeds the steps, and a key that already finished // onboarding on this machine skips straight into the app. const importExistingKey = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); relayClient.disconnect(); queryClient.setQueryData(["identity"], identity); queryClient.removeQueries({ queryKey: profileQueryKey }); diff --git a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx index 82d9a0213c..ba5d8b2c87 100644 --- a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx @@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward"; export type OnboardingTransitionEffect = | "fade" | "line-slide" + | "mask-reveal-down" | "mask-reveal-up" | "none"; diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 431c9b2f51..911ddaf362 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -698,25 +698,28 @@ function SetupStepContent({ /> - - - + {/* Relative row keeps the primary CTA truly centered while Skip + hangs off its right edge without shifting the center. */} +
+ + +