From 230bf6fa7c7d48e2ffbb75ceab52db8535722d51 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 19:13:41 -0700 Subject: [PATCH] Add two-secret identity recovery kits Signed-off-by: Jordan Mecom --- VISION_SOVEREIGN.md | 12 +- desktop/src-tauri/Cargo.lock | 5 + desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/src/commands/identity.rs | 86 +++- desktop/src-tauri/src/key_backup.rs | 16 + desktop/src-tauri/src/key_backup_tests.rs | 36 ++ desktop/src-tauri/src/lib.rs | 4 + desktop/src-tauri/src/two_skd.rs | 410 ++++++++++++++++++ .../onboarding/lib/keyImportInput.test.mjs | 21 + .../features/onboarding/lib/keyImportInput.ts | 30 +- .../onboarding/ui/KeyringLockedScreen.tsx | 4 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 4 +- .../onboarding/ui/NostrKeyImportForm.tsx | 67 ++- .../features/onboarding/ui/OnboardingFlow.tsx | 4 +- .../features/settings/ui/BackupTestFlow.tsx | 56 ++- .../settings/ui/PrivateKeyBackupRow.tsx | 22 +- .../settings/ui/TwoSkdRecoveryCreator.tsx | 279 ++++++++++++ desktop/src/shared/api/tauriIdentity.ts | 42 +- desktop/src/testing/e2eBridge.ts | 63 ++- desktop/tests/e2e/onboarding.spec.ts | 34 +- .../tests/e2e/profile-backup-settings.spec.ts | 72 ++- 21 files changed, 1223 insertions(+), 49 deletions(-) create mode 100644 desktop/src-tauri/src/two_skd.rs create mode 100644 desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx diff --git a/VISION_SOVEREIGN.md b/VISION_SOVEREIGN.md index 77362b3f7e..494030f715 100644 --- a/VISION_SOVEREIGN.md +++ b/VISION_SOVEREIGN.md @@ -213,11 +213,13 @@ project runs as a community on shared infrastructure, isolated from every other tenant, same sovereignty, someone else handles the ops — but it's a cost either way, in time or money. Worth knowing before you start. -Key management is harder than "sign in with Google." Losing your private key means -losing your identity. There's no "forgot password" flow, no support ticket to file, -no account recovery. Hardware keys help. Good practices help. But it's a real -tradeoff and you should go in knowing it. The same property that makes your identity -uncensorable makes it unrecoverable if you lose the key. +Key management is harder than "sign in with Google." Without a backup, losing your +private key means losing your identity. Buzz can create an optional 2SKD recovery +kit: an encrypted backup plus a separately held random recovery code, with the +user's password as the third required input. Buzz cannot reset the password or +recreate the recovery code, so this is still self-custody rather than a support +ticket recovery flow. Hardware keys help. Good practices help. But it remains a +real tradeoff, and you should go in knowing it. The ecosystem is young. The tooling is good and getting better, but it's not a decade of polish. Some things will feel rough. Some integrations won't exist yet. diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5bd19ef2b6..8fb776ad84 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1085,6 +1085,7 @@ version = "0.5.17" dependencies = [ "anyhow", "arboard", + "argon2", "atomic-write-file", "audioadapter-buffers", "axum", @@ -1100,6 +1101,7 @@ dependencies = [ "buzz-ws-client", "bytes", "bzip2 0.6.1", + "chacha20poly1305", "chrono", "ctrlc", "dirs", @@ -1109,6 +1111,7 @@ dependencies = [ "futures-util", "getrandom 0.2.17", "hex", + "hkdf", "image", "infer", "iroh", @@ -1142,6 +1145,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2 0.10.9", "sha2 0.11.0", "sherpa-onnx", "strip-ansi-escapes", @@ -1164,6 +1168,7 @@ dependencies = [ "tokio-util", "toml 0.8.2", "tracing", + "unicode-normalization", "url", "user-idle", "uuid", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index db089fc13f..c25ca4d0da 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -120,7 +120,12 @@ mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75 mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-system", optional = true } mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-events", optional = true } base64 = "0.22" +argon2 = "0.5" +chacha20poly1305 = "0.10" +hkdf = "0.12" sha2 = "0.11" +sha2-legacy = { package = "sha2", version = "0.10" } +unicode-normalization = "0.1" tar = "0.4" bzip2 = "0.6" chrono = { version = "0.4", features = ["serde"] } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e..0bdf29276c 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -253,6 +253,32 @@ pub async fn create_ncryptsec_backup( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Create a two-secret identity backup entirely in native code. +/// +/// The returned encrypted backup and recovery code must be stored separately. +/// The identity key itself never crosses the Tauri command boundary. +#[tauri::command] +pub async fn create_2skd_backup( + password: String, + app_handle: tauri::AppHandle, +) -> Result { + if password.chars().count() < crate::key_backup::MIN_PASSPHRASE_LEN { + return Err(format!( + "passphrase must be at least {} characters", + crate::key_backup::MIN_PASSPHRASE_LEN + )); + } + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; + crate::two_skd::create_backup(&keys, &password) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupVerification { @@ -294,6 +320,33 @@ pub async fn verify_ncryptsec_backup( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Decrypt and validate a 2SKD backup without exposing its secret key. +#[tauri::command] +pub async fn verify_2skd_backup( + backup: String, + password: String, + recovery_secret: String, + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let recovery_secret = zeroize::Zeroizing::new(recovery_secret); + let keys = crate::two_skd::decrypt_backup(&backup, &password, &recovery_secret)?; + let pubkey = keys.public_key(); + let state = app_handle.state::(); + let current = state.signing_keys()?.public_key(); + Ok(BackupVerification { + pubkey: pubkey.to_hex(), + npub: pubkey + .to_bech32() + .map_err(|e| format!("encode backup identity: {e}"))?, + matches_current_identity: pubkey == current, + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + /// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path. /// /// The input must parse as a structurally valid NIP-49 payload. The dialog is @@ -333,19 +386,50 @@ pub async fn save_ncryptsec_copy( Ok(Some(dest.display().to_string())) } +/// Save the encrypted half of a 2SKD recovery kit to a user-selected path. +#[tauri::command] +pub async fn save_2skd_backup_copy( + backup: String, + app_handle: tauri::AppHandle, +) -> Result, String> { + crate::two_skd::validate_backup(&backup)?; + let normalized = backup.trim().to_string(); + let dest = match crate::commands::export_util::pick_save_path( + &app_handle, + crate::two_skd::BACKUP_FILE_NAME, + "Encrypted Buzz recovery backup", + &["buzzbackup"], + ) + .await? + { + Some(path) => path, + None => return Ok(None), + }; + let dest_for_write = dest.clone(); + tokio::task::spawn_blocking(move || { + crate::key_backup::write_portable_backup_file(&dest_for_write, &normalized) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + Ok(Some(dest.display().to_string())) +} + #[tauri::command] pub async fn import_identity( nsec: String, password: Option, + recovery_secret: Option, app_handle: tauri::AppHandle, ) -> Result { tokio::task::spawn_blocking(move || { // 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( + let recovery_secret = recovery_secret.map(zeroize::Zeroizing::new); + let keys = crate::key_backup::recover_keys_from_input_with_recovery( &nsec, password.as_ref().map(|value| value.as_str()), + recovery_secret.as_ref().map(|value| value.as_str()), )?; // Serialize against persist_current_identity: hold this guard for the diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index e8fcc8abe4..b3d07a48bb 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -114,8 +114,24 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { /// Recover identity keys from either an encrypted NIP-49 backup or the raw /// nsec/hex formats accepted before encrypted imports were added. +#[allow(dead_code)] // Compatibility wrapper retained for NIP-49-focused tests and callers. pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result { + recover_keys_from_input_with_recovery(input, password, None) +} + +/// Recover identity keys from a 2SKD backup, NIP-49 backup, or raw key. +pub fn recover_keys_from_input_with_recovery( + input: &str, + password: Option<&str>, + recovery_secret: Option<&str>, +) -> Result { let trimmed = input.trim(); + if crate::two_skd::is_two_skd_backup(trimmed) { + let password = password.ok_or_else(|| "key backup requires a password".to_string())?; + let recovery_secret = recovery_secret + .ok_or_else(|| "2SKD key backup requires a recovery code".to_string())?; + return crate::two_skd::decrypt_backup(trimmed, password, recovery_secret); + } let is_ncryptsec = trimmed .get(..NCRYPTSEC_HRP.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP)); diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 7f46ff2a7d..5140d65e65 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -126,6 +126,42 @@ fn recover_keys_raw_nsec_path_unchanged() { assert!(recover_keys_from_input("garbage", None).is_err()); } +#[test] +fn recover_keys_two_skd_requires_password_and_recovery_code() { + let keys = Keys::generate(); + let created = crate::two_skd::create_backup_with_cost( + &keys, + "correct horse battery", + crate::two_skd::Argon2Cost { + memory_kib: 8 * 1024, + iterations: 1, + parallelism: 1, + }, + ) + .unwrap(); + + assert_eq!( + recover_keys_from_input_with_recovery(&created.backup, None, None).unwrap_err(), + "key backup requires a password" + ); + assert_eq!( + recover_keys_from_input_with_recovery( + &created.backup, + Some("correct horse battery"), + None, + ) + .unwrap_err(), + "2SKD key backup requires a recovery code" + ); + let recovered = recover_keys_from_input_with_recovery( + &created.backup, + Some("correct horse battery"), + Some(&created.recovery_secret), + ) + .unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + // ── File lifecycle ──────────────────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f8601b66d0..4c2619f306 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -46,6 +46,7 @@ mod terminal_runtime; mod terminal_transport; #[cfg(target_os = "macos")] mod tray_menu; +mod two_skd; mod unread_catch_up; mod util; #[cfg(target_os = "linux")] @@ -556,8 +557,11 @@ pub fn run() { get_nsec, generate_backup_passphrase, create_ncryptsec_backup, + create_2skd_backup, verify_ncryptsec_backup, + verify_2skd_backup, save_ncryptsec_copy, + save_2skd_backup_copy, import_identity, persist_current_identity, get_profile, diff --git a/desktop/src-tauri/src/two_skd.rs b/desktop/src-tauri/src/two_skd.rs new file mode 100644 index 0000000000..9504f0562b --- /dev/null +++ b/desktop/src-tauri/src/two_skd.rs @@ -0,0 +1,410 @@ +//! Two-secret key derivation for portable identity recovery. +//! +//! A Buzz identity is encrypted with a key assembled from two independent +//! halves: an Argon2id-derived passphrase half and an HKDF-derived random +//! recovery-secret half. The encrypted backup is safe to store separately +//! from the recovery secret; neither the backup nor the secret is useful on +//! its own. + +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use hkdf::Hkdf; +use nostr::{Keys, SecretKey}; +use serde::{Deserialize, Serialize}; +use sha2_legacy::Sha256; +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; + +pub const BACKUP_PREFIX: &str = "buzz2skd1:"; +pub const RECOVERY_SECRET_PREFIX: &str = "buzz-recovery-v1-"; +pub const BACKUP_FILE_NAME: &str = "identity.buzzbackup"; + +const FORMAT_VERSION: u8 = 1; +const RECOVERY_SECRET_BYTES: usize = 16; +const SALT_BYTES: usize = 16; +const NONCE_BYTES: usize = 12; +const KEY_BYTES: usize = 32; +const CIPHERTEXT_BYTES: usize = KEY_BYTES + 16; +const INFO: &[u8] = b"Buzz 2SKD v1"; +const MAX_BACKUP_BYTES: usize = 4096; + +/// RFC 9106 section 4's first recommended Argon2id profile: 2 GiB, one pass, +/// four lanes. The serialized artifact records these values so a future format +/// can change them without making existing backups unreadable. +pub const ARGON2_MEMORY_KIB: u32 = 1 << 21; +pub const ARGON2_ITERATIONS: u32 = 1; +pub const ARGON2_PARALLELISM: u32 = 4; + +#[derive(Clone, Copy, Debug)] +pub struct Argon2Cost { + pub memory_kib: u32, + pub iterations: u32, + pub parallelism: u32, +} + +impl Argon2Cost { + pub const PRODUCTION: Self = Self { + memory_kib: ARGON2_MEMORY_KIB, + iterations: ARGON2_ITERATIONS, + parallelism: ARGON2_PARALLELISM, + }; +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatedBackup { + pub backup: String, + pub recovery_secret: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct BackupPayload { + version: u8, + pubkey: String, + salt: String, + memory_kib: u32, + iterations: u32, + parallelism: u32, + nonce: String, + ciphertext: String, +} + +impl BackupPayload { + fn cost(&self) -> Argon2Cost { + Argon2Cost { + memory_kib: self.memory_kib, + iterations: self.iterations, + parallelism: self.parallelism, + } + } + + fn aad(&self) -> Vec { + format!( + "Buzz 2SKD v1|{}|{}|{}|{}|{}|{}", + self.pubkey, self.salt, self.memory_kib, self.iterations, self.parallelism, self.nonce + ) + .into_bytes() + } +} + +pub(crate) fn is_two_skd_backup(input: &str) -> bool { + input.trim().starts_with(BACKUP_PREFIX) +} + +/// Encrypt `keys` under a fresh recovery secret and the user passphrase. +pub(crate) fn create_backup(keys: &Keys, passphrase: &str) -> Result { + create_backup_with_cost(keys, passphrase, Argon2Cost::PRODUCTION) +} + +pub(crate) fn create_backup_with_cost( + keys: &Keys, + passphrase: &str, + cost: Argon2Cost, +) -> Result { + validate_cost(cost)?; + + let mut recovery_secret = Zeroizing::new([0u8; RECOVERY_SECRET_BYTES]); + let mut salt = [0u8; SALT_BYTES]; + let mut nonce = [0u8; NONCE_BYTES]; + getrandom::getrandom(recovery_secret.as_mut()) + .map_err(|error| format!("generate recovery secret: {error}"))?; + getrandom::getrandom(&mut salt).map_err(|error| format!("generate backup salt: {error}"))?; + getrandom::getrandom(&mut nonce).map_err(|error| format!("generate backup nonce: {error}"))?; + + // Buzz does not use a passkey in this flow, so the stable public Nostr + // identity fills the public-key binding role from the 2SKD sketch. + let pubkey_bytes = keys.public_key().to_bytes(); + let encryption_key = + derive_encryption_key(passphrase, &salt, &recovery_secret, &pubkey_bytes, cost)?; + + let mut payload = BackupPayload { + version: FORMAT_VERSION, + pubkey: keys.public_key().to_hex(), + salt: URL_SAFE_NO_PAD.encode(salt), + memory_kib: cost.memory_kib, + iterations: cost.iterations, + parallelism: cost.parallelism, + nonce: URL_SAFE_NO_PAD.encode(nonce), + ciphertext: String::new(), + }; + + let cipher = ChaCha20Poly1305::new_from_slice(encryption_key.as_ref()) + .map_err(|_| "initialize backup cipher".to_string())?; + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: keys.secret_key().as_secret_bytes(), + aad: &payload.aad(), + }, + ) + .map_err(|_| "encrypt identity backup".to_string())?; + payload.ciphertext = URL_SAFE_NO_PAD.encode(ciphertext); + + let encoded = + serde_json::to_vec(&payload).map_err(|error| format!("encode identity backup: {error}"))?; + let backup = format!("{BACKUP_PREFIX}{}", URL_SAFE_NO_PAD.encode(encoded)); + let recovery_secret = format!( + "{RECOVERY_SECRET_PREFIX}{}", + hex::encode(recovery_secret.as_ref()) + ); + + // Refuse to hand a fresh backup to the UI unless all three inputs recover + // this exact identity. + let recovered = decrypt_backup(&backup, passphrase, &recovery_secret)?; + if recovered.public_key() != keys.public_key() { + return Err("verify identity backup: recovered identity did not match".to_string()); + } + + Ok(CreatedBackup { + backup, + recovery_secret, + }) +} + +/// Recover identity keys from a 2SKD backup, passphrase, and recovery secret. +pub(crate) fn decrypt_backup( + input: &str, + passphrase: &str, + recovery_secret: &str, +) -> Result { + let payload = parse_backup(input)?; + let cost = payload.cost(); + validate_cost(cost)?; + + let salt = decode_array::(&payload.salt, "backup salt")?; + let nonce = decode_array::(&payload.nonce, "backup nonce")?; + let pubkey_bytes = hex::decode(&payload.pubkey) + .map_err(|_| "invalid backup public key".to_string())? + .try_into() + .map_err(|_| "invalid backup public key".to_string())?; + let recovery_secret = parse_recovery_secret(recovery_secret)?; + let encryption_key = + derive_encryption_key(passphrase, &salt, &recovery_secret, &pubkey_bytes, cost)?; + let ciphertext = URL_SAFE_NO_PAD + .decode(&payload.ciphertext) + .map_err(|_| "invalid backup ciphertext".to_string())?; + let cipher = ChaCha20Poly1305::new_from_slice(encryption_key.as_ref()) + .map_err(|_| "initialize backup cipher".to_string())?; + let plaintext = Zeroizing::new( + cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &ciphertext, + aad: &payload.aad(), + }, + ) + .map_err(|_| "wrong backup password, recovery code, or damaged backup".to_string())?, + ); + if plaintext.len() != KEY_BYTES { + return Err("damaged identity backup".to_string()); + } + let secret = + SecretKey::from_slice(&plaintext).map_err(|_| "damaged identity backup".to_string())?; + let keys = Keys::new(secret); + if keys.public_key().to_bytes() != pubkey_bytes { + return Err("damaged identity backup".to_string()); + } + Ok(keys) +} + +fn parse_backup(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.len() > MAX_BACKUP_BYTES { + return Err("identity backup is too large".to_string()); + } + let encoded = trimmed + .strip_prefix(BACKUP_PREFIX) + .ok_or_else(|| "invalid 2SKD identity backup".to_string())?; + let json = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| "invalid 2SKD identity backup".to_string())?; + let payload: BackupPayload = + serde_json::from_slice(&json).map_err(|_| "invalid 2SKD identity backup".to_string())?; + if payload.version != FORMAT_VERSION { + return Err(format!( + "unsupported 2SKD backup version: {}", + payload.version + )); + } + validate_payload_shape(&payload)?; + Ok(payload) +} + +pub(crate) fn validate_backup(input: &str) -> Result<(), String> { + parse_backup(input).map(|_| ()) +} + +fn validate_payload_shape(payload: &BackupPayload) -> Result<(), String> { + validate_cost(payload.cost())?; + decode_array::(&payload.salt, "backup salt")?; + decode_array::(&payload.nonce, "backup nonce")?; + let _: [u8; KEY_BYTES] = hex::decode(&payload.pubkey) + .map_err(|_| "invalid backup public key".to_string())? + .try_into() + .map_err(|_| "invalid backup public key".to_string())?; + decode_array::(&payload.ciphertext, "backup ciphertext")?; + Ok(()) +} + +fn parse_recovery_secret(input: &str) -> Result, String> { + let encoded = input + .trim() + .strip_prefix(RECOVERY_SECRET_PREFIX) + .ok_or_else(|| "invalid recovery code".to_string())?; + let bytes = hex::decode(encoded).map_err(|_| "invalid recovery code".to_string())?; + let secret = bytes + .try_into() + .map_err(|_| "invalid recovery code".to_string())?; + Ok(Zeroizing::new(secret)) +} + +fn decode_array(encoded: &str, label: &str) -> Result<[u8; N], String> { + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| format!("invalid {label}"))?; + bytes.try_into().map_err(|_| format!("invalid {label}")) +} + +fn derive_encryption_key( + passphrase: &str, + salt: &[u8; SALT_BYTES], + recovery_secret: &[u8; RECOVERY_SECRET_BYTES], + pubkey: &[u8; KEY_BYTES], + cost: Argon2Cost, +) -> Result, String> { + let params = Params::new( + cost.memory_kib, + cost.iterations, + cost.parallelism, + Some(KEY_BYTES), + ) + .map_err(|error| format!("invalid backup KDF parameters: {error}"))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let normalized = Zeroizing::new(passphrase.nfkc().collect::()); + let mut passphrase_half = Zeroizing::new([0u8; KEY_BYTES]); + argon2 + .hash_password_into(normalized.as_bytes(), salt, passphrase_half.as_mut()) + .map_err(|error| format!("derive backup key from password: {error}"))?; + + let mut secret_half = Zeroizing::new([0u8; KEY_BYTES]); + Hkdf::::new(Some(pubkey), recovery_secret) + .expand(INFO, secret_half.as_mut()) + .map_err(|_| "derive backup key from recovery secret".to_string())?; + + let mut combined = Zeroizing::new([0u8; KEY_BYTES]); + for index in 0..KEY_BYTES { + combined[index] = passphrase_half[index] ^ secret_half[index]; + } + Ok(combined) +} + +fn validate_cost(cost: Argon2Cost) -> Result<(), String> { + if cost.memory_kib == 0 + || cost.memory_kib > ARGON2_MEMORY_KIB + || cost.iterations == 0 + || cost.iterations > 4 + || cost.parallelism == 0 + || cost.parallelism > ARGON2_PARALLELISM + { + return Err("unsupported backup KDF parameters".to_string()); + } + Params::new( + cost.memory_kib, + cost.iterations, + cost.parallelism, + Some(KEY_BYTES), + ) + .map_err(|_| "unsupported backup KDF parameters".to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAST_COST: Argon2Cost = Argon2Cost { + memory_kib: 8 * 1024, + iterations: 1, + parallelism: 1, + }; + + #[test] + fn round_trip_requires_both_secrets() { + let keys = Keys::generate(); + let created = create_backup_with_cost(&keys, "correct horse battery", FAST_COST).unwrap(); + assert!(created.backup.starts_with(BACKUP_PREFIX)); + assert!(created.recovery_secret.starts_with(RECOVERY_SECRET_PREFIX)); + assert!(!created.backup.contains(&created.recovery_secret)); + + let recovered = decrypt_backup( + &created.backup, + "correct horse battery", + &created.recovery_secret, + ) + .unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + + assert_eq!( + decrypt_backup(&created.backup, "wrong password", &created.recovery_secret,) + .unwrap_err(), + "wrong backup password, recovery code, or damaged backup" + ); + let other_secret = format!("{RECOVERY_SECRET_PREFIX}{}", "00".repeat(16)); + assert_eq!( + decrypt_backup(&created.backup, "correct horse battery", &other_secret).unwrap_err(), + "wrong backup password, recovery code, or damaged backup" + ); + } + + #[test] + fn normalized_passphrases_round_trip() { + let keys = Keys::generate(); + let created = + create_backup_with_cost(&keys, "caf\u{00e9} recovery phrase", FAST_COST).unwrap(); + let recovered = decrypt_backup( + &created.backup, + "cafe\u{0301} recovery phrase", + &created.recovery_secret, + ) + .unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + } + + #[test] + fn tampered_public_metadata_fails_authentication() { + let keys = Keys::generate(); + let created = create_backup_with_cost(&keys, "correct horse battery", FAST_COST).unwrap(); + let mut payload = parse_backup(&created.backup).unwrap(); + payload.pubkey = Keys::generate().public_key().to_hex(); + let encoded = serde_json::to_vec(&payload).unwrap(); + let tampered = format!("{BACKUP_PREFIX}{}", URL_SAFE_NO_PAD.encode(encoded)); + assert!( + decrypt_backup(&tampered, "correct horse battery", &created.recovery_secret).is_err() + ); + } + + #[test] + fn untrusted_cost_is_bounded_before_derivation() { + let keys = Keys::generate(); + let created = create_backup_with_cost(&keys, "correct horse battery", FAST_COST).unwrap(); + let mut payload = parse_backup(&created.backup).unwrap(); + payload.memory_kib = ARGON2_MEMORY_KIB + 1; + let encoded = serde_json::to_vec(&payload).unwrap(); + let oversized = format!("{BACKUP_PREFIX}{}", URL_SAFE_NO_PAD.encode(encoded)); + assert_eq!( + decrypt_backup( + &oversized, + "correct horse battery", + &created.recovery_secret, + ) + .unwrap_err(), + "unsupported backup KDF parameters" + ); + } +} diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs index bc0bb4b4d7..99179fd860 100644 --- a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs +++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs @@ -10,6 +10,7 @@ import { generateSecretKey } from "nostr-tools/pure"; import { classifyKeyImportInput, isPlausibleNcryptsec, + isPlausibleTwoSkdBackup, keyImportSubmitEnabled, NCRYPTSEC_ENCODED_LENGTH, } from "./keyImportInput.ts"; @@ -19,10 +20,12 @@ const NCRYPTSEC = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; const VALID_NSEC = nsecEncode(generateSecretKey()); +const TWO_SKD_BACKUP = `buzz2skd1:${"A".repeat(80)}`; test("classify_by_hrp_with_whitespace_tolerance", () => { assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec"); assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec"); + assert.equal(classifyKeyImportInput(TWO_SKD_BACKUP), "two-skd"); assert.equal(classifyKeyImportInput("npub1whatever"), "unknown"); assert.equal(classifyKeyImportInput(""), "unknown"); // nsec must not be shadowed by the longer HRP check. @@ -71,3 +74,21 @@ test("submit_gating_ncryptsec_requires_passphrase", () => { // Structurally implausible blob never submits, passphrase or not. assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false); }); + +test("2SKD backup requires both password and recovery code", () => { + assert.equal(isPlausibleTwoSkdBackup(TWO_SKD_BACKUP), true); + assert.equal(keyImportSubmitEnabled(TWO_SKD_BACKUP, "", ""), false); + assert.equal( + keyImportSubmitEnabled(TWO_SKD_BACKUP, "hunter2hunter2", ""), + false, + ); + assert.equal( + keyImportSubmitEnabled( + TWO_SKD_BACKUP, + "hunter2hunter2", + "buzz-recovery-v1-00112233445566778899aabbccddeeff", + ), + true, + ); + assert.equal(isPlausibleTwoSkdBackup("buzz2skd1:not valid"), false); +}); diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts index 0f6fc609ed..9f775e2602 100644 --- a/desktop/src/features/onboarding/lib/keyImportInput.ts +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -2,16 +2,14 @@ * 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. + * Encrypted backups cannot be previewed before their recovery inputs are + * provided. Password and recovery-code validation happens in Rust; this module + * performs only the password-independent shape checks needed to switch modes. */ import { nsecToNpub } from "@/shared/lib/nostrUtils"; -export type KeyImportKind = "nsec" | "ncryptsec" | "unknown"; +export type KeyImportKind = "nsec" | "ncryptsec" | "two-skd" | "unknown"; const NCRYPTSEC_HRP = "ncryptsec"; const NIP49_VERSION = 2; @@ -67,6 +65,7 @@ function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null { export function classifyKeyImportInput(input: string): KeyImportKind { const trimmed = input.trim(); + if (trimmed.startsWith("buzz2skd1:")) return "two-skd"; // 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. @@ -75,6 +74,14 @@ export function classifyKeyImportInput(input: string): KeyImportKind { return "unknown"; } +/** Lightweight shape check for Buzz's versioned, base64url 2SKD artifact. */ +export function isPlausibleTwoSkdBackup(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed.startsWith("buzz2skd1:") || trimmed.length > 4096) return false; + const encoded = trimmed.slice("buzz2skd1:".length); + return encoded.length >= 40 && /^[A-Za-z0-9_-]+$/.test(encoded); +} + /** * Password-independent NIP-49 validation used for the automatic UI transition. * A candidate must have canonical casing and length, a valid Bech32 checksum, @@ -113,13 +120,22 @@ export function isPlausibleNcryptsec(input: string): boolean { /** * Whether the import form's submit should be enabled. - * nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase. + * Raw keys must derive an npub. Encrypted backups require their complete set + * of recovery inputs before submission. */ export function keyImportSubmitEnabled( input: string, passphrase: string, + recoverySecret = "", ): boolean { const kind = classifyKeyImportInput(input); + if (kind === "two-skd") { + return ( + isPlausibleTwoSkdBackup(input) && + passphrase.length > 0 && + recoverySecret.trim().length > 0 + ); + } if (kind === "ncryptsec") { return isPlausibleNcryptsec(input) && passphrase.length > 0; } diff --git a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx index a6a02f38c0..d8b8cc314c 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, password?: string) => { - const identity = await importIdentity(nsec, password); + async (nsec: string, password?: string, recoverySecret?: string) => { + const identity = await importIdentity(nsec, password, recoverySecret); // 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 27d6b8ce44..94632e2d0e 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -200,8 +200,8 @@ export function MachineOnboardingFlow({ }, [queryClient]); const importExistingIdentity = React.useCallback( - async (nsec: string, password?: string) => { - const identity = await importIdentity(nsec, password); + async (nsec: string, password?: string, recoverySecret?: string) => { + const identity = await importIdentity(nsec, password, recoverySecret); continueWithIdentity(identity.pubkey); queryClient.setQueryData(["identity"], identity); setIdentityWasImported(true); diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 814f228741..96a89fe946 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -6,6 +6,7 @@ import { nsecToNpub } from "@/shared/lib/nostrUtils"; import { classifyKeyImportInput, isPlausibleNcryptsec, + isPlausibleTwoSkdBackup, keyImportSubmitEnabled, } from "../lib/keyImportInput"; import { Button } from "@/shared/ui/button"; @@ -22,7 +23,7 @@ import { } from "./BackupPasswordTimeline"; import { OnboardingFooter } from "./OnboardingFooter"; -const NOSTR_KEY_FILE_MAX_BYTES = 1024; +const NOSTR_KEY_FILE_MAX_BYTES = 4096; export type NostrKeyImportStage = "key-entry" | "backup-password"; @@ -31,7 +32,11 @@ type NostrKeyImportFormProps = { disabled?: boolean; errorMessage?: string | null; onBack: () => void; - onImport: (nsec: string, password?: string) => Promise; + onImport: ( + nsec: string, + password?: string, + recoverySecret?: string, + ) => Promise; /** Reports whether an import is in flight so host-owned navigation can be disabled. */ onImportingChange?: (isImporting: boolean) => void; onStageChange?: (stage: NostrKeyImportStage) => void; @@ -70,6 +75,7 @@ export function NostrKeyImportForm({ }: NostrKeyImportFormProps) { const [nsecInput, setNsecInput] = React.useState(""); const [passphrase, setPassphrase] = React.useState(""); + const [recoverySecret, setRecoverySecret] = React.useState(""); const [isImporting, setIsImporting] = React.useState(false); const importInFlightRef = React.useRef(false); const [importError, setImportError] = React.useState(null); @@ -78,13 +84,16 @@ export function NostrKeyImportForm({ const [isRevealed, setIsRevealed] = React.useState(false); const inputRef = React.useRef(null); const passphraseInputRef = React.useRef(null); + const recoverySecretInputRef = React.useRef(null); const fileInputRef = React.useRef(null); const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]); const trimmedInput = nsecInput.trim(); const hasInput = trimmedInput.length > 0; const inputKind = classifyKeyImportInput(nsecInput); const isEncryptedInput = inputKind === "ncryptsec"; - const isPasswordStage = isPlausibleNcryptsec(nsecInput); + const isTwoSkdInput = inputKind === "two-skd"; + const isPasswordStage = + isPlausibleNcryptsec(nsecInput) || isPlausibleTwoSkdBackup(nsecInput); // Masked-by-default must re-assert whenever the field empties: a sticky // reveal from a previous key must never apply to newly pasted content the @@ -99,9 +108,10 @@ export function NostrKeyImportForm({ React.useEffect(() => { if (!isPasswordStage) { setPassphrase(""); + setRecoverySecret(""); } }, [isPasswordStage]); - const isValid = keyImportSubmitEnabled(nsecInput, passphrase); + const isValid = keyImportSubmitEnabled(nsecInput, passphrase, recoverySecret); const isInteractionDisabled = disabled || isImporting; const showInvalidHint = hasInput && @@ -113,11 +123,12 @@ export function NostrKeyImportForm({ React.useLayoutEffect(() => { if (isPasswordStage) { - passphraseInputRef.current?.focus(); + if (isTwoSkdInput) recoverySecretInputRef.current?.focus(); + else passphraseInputRef.current?.focus(); } else { inputRef.current?.focus(); } - }, [isPasswordStage]); + }, [isPasswordStage, isTwoSkdInput]); React.useEffect(() => { onStageChange?.(isPasswordStage ? "backup-password" : "key-entry"); @@ -217,7 +228,11 @@ export function NostrKeyImportForm({ setImportError(null); try { - await onImport(trimmedInput, isPasswordStage ? passphrase : undefined); + await onImport( + trimmedInput, + isPasswordStage ? passphrase : undefined, + isTwoSkdInput ? recoverySecret.trim() : undefined, + ); } catch (error) { setImportError( error instanceof Error ? error.message : "Couldn't import this key.", @@ -231,10 +246,12 @@ export function NostrKeyImportForm({ isEncryptedInput, isInteractionDisabled, isPasswordStage, + isTwoSkdInput, isValid, onImport, onImportingChange, passphrase, + recoverySecret, trimmedInput, ]); @@ -246,6 +263,7 @@ export function NostrKeyImportForm({ setNsecInput(""); setPassphrase(""); + setRecoverySecret(""); setImportError(null); setIsRevealed(false); onStageChange?.("key-entry"); @@ -359,11 +377,10 @@ export function NostrKeyImportForm({ ) : null} {/* 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. */} + the spotlight "Choose a backup file" button both open it. */} {mode === "backup" || variant !== "spotlight" ? (
+ {isTwoSkdInput ? ( +
+ + { + setRecoverySecret(event.target.value); + setImportError(null); + }} + placeholder="Recovery code" + ref={recoverySecretInputRef} + spellCheck={false} + value={recoverySecret} + /> +
+ ) : null} - {isEncryptedInput - ? "Waiting for a complete ncryptsec backup" - : "Waiting for a valid nsec1 key"} + {isTwoSkdInput + ? "Waiting for a complete Buzz recovery backup" + : isEncryptedInput + ? "Waiting for a complete ncryptsec backup" + : "Waiting for a valid nsec1 key"}

) : null} diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index f859414cf9..135677510e 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -395,8 +395,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, password?: string) => { - const identity = await importIdentity(nsec, password); + async (nsec: string, password?: string, recoverySecret?: string) => { + const identity = await importIdentity(nsec, password, recoverySecret); relayClient.disconnect(); queryClient.setQueryData(["identity"], identity); queryClient.removeQueries({ queryKey: profileQueryKey }); diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx index 65ef40a98a..c682664591 100644 --- a/desktop/src/features/settings/ui/BackupTestFlow.tsx +++ b/desktop/src/features/settings/ui/BackupTestFlow.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { verifyNcryptsecBackup, + verifyTwoSkdBackup, type BackupVerification, } from "@/shared/api/tauriIdentity"; import { Button } from "@/shared/ui/button"; @@ -119,6 +120,7 @@ export function BackupTestFlow({ }: BackupTestFlowProps) { const reduceMotion = useReducedMotion() ?? false; const { stage, fileName, ncryptsec, result } = progress; + const isTwoSkd = ncryptsec?.startsWith("buzz2skd1:") === true; // 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); @@ -155,6 +157,7 @@ export function BackupTestFlow({ // 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 [recoveryAttempt, setRecoveryAttempt] = React.useState(""); const [error, setError] = React.useState(null); const [isVerifying, setIsVerifying] = React.useState(false); const [isRevealed, setIsRevealed] = React.useState(false); @@ -171,6 +174,7 @@ export function BackupTestFlow({ mountedRef.current = false; requestRef.current += 1; setAttempt(""); + setRecoveryAttempt(""); }; }, []); @@ -188,12 +192,16 @@ export function BackupTestFlow({ return; } if (!mountedRef.current) return; - if (!text.toLowerCase().startsWith("ncryptsec1")) { + if ( + !text.toLowerCase().startsWith("ncryptsec1") && + !text.startsWith("buzz2skd1:") + ) { setError("That doesn't look like a key backup file."); return; } setError(null); setAttempt(""); + setRecoveryAttempt(""); onProgressChange({ stage: "password", fileName: file.name, @@ -205,8 +213,15 @@ export function BackupTestFlow({ ); const handleVerify = React.useCallback(async () => { - if (!ncryptsec || !attempt || isVerifying) return; + if ( + !ncryptsec || + !attempt || + (isTwoSkd && !recoveryAttempt.trim()) || + isVerifying + ) + return; const password = attempt; + const recoverySecret = recoveryAttempt.trim(); const requestId = ++requestRef.current; setIsVerifying(true); setError(null); @@ -214,8 +229,11 @@ export function BackupTestFlow({ // Clear the attempt the moment it's handed to Rust — success or failure, // the typed password never lingers in the field. setAttempt(""); + setRecoveryAttempt(""); try { - const verified = await verifyNcryptsecBackup(ncryptsec, password); + const verified = isTwoSkd + ? await verifyTwoSkdBackup(ncryptsec, password, recoverySecret) + : await verifyNcryptsecBackup(ncryptsec, password); if (!mountedRef.current || requestId !== requestRef.current) return; onProgressChange((prev) => ({ ...prev, @@ -231,7 +249,14 @@ export function BackupTestFlow({ if (mountedRef.current && requestId === requestRef.current) setIsVerifying(false); } - }, [attempt, isVerifying, ncryptsec, onProgressChange]); + }, [ + attempt, + isTwoSkd, + isVerifying, + ncryptsec, + onProgressChange, + recoveryAttempt, + ]); if (stage === "success" && result) { return ( @@ -299,7 +324,7 @@ export function BackupTestFlow({ {stage === "drop" ? ( <> { @@ -370,8 +395,23 @@ export function BackupTestFlow({

- That's the one. Now enter your password to prove you can unlock it. + {isTwoSkd + ? "That’s the one. Enter its separate recovery code and password." + : "That’s the one. Now enter your password to prove you can unlock it."}

+ {isTwoSkd ? ( + setRecoveryAttempt(event.target.value)} + placeholder="Recovery code" + spellCheck={false} + value={recoveryAttempt} + /> + ) : null}
void handleVerify()} type="button" > diff --git a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx index 8eb0256a55..079d9c9607 100644 --- a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx +++ b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx @@ -1,4 +1,4 @@ -import { Download, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import { Download, Eye, EyeOff, KeyRound, ShieldCheck } from "lucide-react"; import * as React from "react"; import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay"; @@ -11,6 +11,7 @@ import { initialBackupTestProgress, } from "@/features/settings/ui/BackupTestFlow"; import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; +import { TwoSkdRecoveryCreator } from "@/features/settings/ui/TwoSkdRecoveryCreator"; import { getNsec } from "@/shared/api/tauriIdentity"; import { Button } from "@/shared/ui/button"; import { @@ -64,6 +65,7 @@ export function PrivateKeyBackupRow() { const [loadError, setLoadError] = React.useState(null); const [createOpen, setCreateOpen] = React.useState(false); const [testOpen, setTestOpen] = React.useState(false); + const [recoveryKitOpen, setRecoveryKitOpen] = React.useState(false); const [testProgress, setTestProgress] = React.useState( initialBackupTestProgress, ); @@ -171,6 +173,12 @@ export function PrivateKeyBackupRow() { ) : nsec ? (
+ Test a key backup - Confirm that a backup file and its password can unlock an - identity. + Confirm that a backup file and its required recovery inputs can + unlock an identity.

- Backups use the standard NIP-49 format, so this works for backups - from compatible Nostr apps too. + Buzz recovery kits use 2SKD. Standard NIP-49 backups from compatible + Nostr apps remain supported.

diff --git a/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx b/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx new file mode 100644 index 0000000000..66cecba42c --- /dev/null +++ b/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx @@ -0,0 +1,279 @@ +import { Check, Copy, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import * as React from "react"; + +import { + createTwoSkdBackup, + type CreatedTwoSkdBackup, + saveTwoSkdBackupCopy, +} from "@/shared/api/tauriIdentity"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Spinner } from "@/shared/ui/spinner"; + +const MIN_PASSPHRASE_LEN = 12; + +/** Create the smallest useful 2SKD recovery kit: one encrypted file plus a + * separately-held recovery code. The raw identity key never reaches React. */ +export function TwoSkdRecoveryCreator({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const [passphrase, setPassphrase] = React.useState(""); + const [revealed, setRevealed] = React.useState(false); + const [created, setCreated] = React.useState( + null, + ); + const [savedPath, setSavedPath] = React.useState(null); + const [pending, setPending] = React.useState(false); + const [copied, setCopied] = React.useState(false); + const [error, setError] = React.useState(null); + const copyTimerRef = React.useRef(null); + const requestRef = React.useRef(0); + + const reset = React.useCallback(() => { + requestRef.current += 1; + setPassphrase(""); + setRevealed(false); + setCreated(null); + setSavedPath(null); + setPending(false); + setCopied(false); + setError(null); + if (copyTimerRef.current !== null) { + window.clearTimeout(copyTimerRef.current); + copyTimerRef.current = null; + } + }, []); + + React.useEffect( + () => () => { + if (copyTimerRef.current !== null) + window.clearTimeout(copyTimerRef.current); + }, + [], + ); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (!nextOpen) reset(); + onOpenChange(nextOpen); + }, + [onOpenChange, reset], + ); + + const saveBackup = React.useCallback( + (result: CreatedTwoSkdBackup) => saveTwoSkdBackupCopy(result.backup), + [], + ); + + const create = React.useCallback(async () => { + if ([...passphrase].length < MIN_PASSPHRASE_LEN || pending) return; + const requestId = ++requestRef.current; + setPending(true); + setError(null); + setRevealed(false); + try { + const result = await createTwoSkdBackup(passphrase); + if (requestId !== requestRef.current) return; + setPassphrase(""); + setCreated(result); + const path = await saveBackup(result); + if (requestId !== requestRef.current) return; + if (path) setSavedPath(path); + } catch (cause) { + if (requestId !== requestRef.current) return; + setError( + cause instanceof Error + ? cause.message + : "Could not create your recovery kit.", + ); + } finally { + if (requestId === requestRef.current) setPending(false); + } + }, [passphrase, pending, saveBackup]); + + const copyRecoveryCode = React.useCallback(async () => { + if (!created) return; + try { + await writeTextToClipboard(created.recoverySecret); + setCopied(true); + if (copyTimerRef.current !== null) + window.clearTimeout(copyTimerRef.current); + copyTimerRef.current = window.setTimeout(() => setCopied(false), 2000); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Could not copy the code.", + ); + } + }, [created]); + + const retrySave = React.useCallback(async () => { + if (!created || pending) return; + const requestId = ++requestRef.current; + setPending(true); + setError(null); + try { + const path = await saveBackup(created); + if (requestId !== requestRef.current) return; + if (path) setSavedPath(path); + } catch (cause) { + if (requestId !== requestRef.current) return; + setError( + cause instanceof Error + ? cause.message + : "Could not save the encrypted backup.", + ); + } finally { + if (requestId === requestRef.current) setPending(false); + } + }, [created, pending, saveBackup]); + + return ( + + + + Create a recovery kit + + Recovery requires the encrypted backup, your password, and a + separate recovery code. + + + + {!created ? ( +
+
+ { + setPassphrase(event.target.value); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void create(); + } + }} + placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`} + type={revealed ? "text" : "password"} + value={passphrase} + /> + +
+

+ Buzz combines this password with a random code that never enters + the encrypted backup. A stolen backup alone cannot be tested + against guessed passwords. +

+
+ +
+
+ ) : savedPath ? ( +
+
+
+

+ The encrypted backup was saved to {savedPath}. Put this code in a + different place, such as your password manager or a printed QR. + Buzz cannot recover it later. +

+
+ + +
+
+ ) : ( +
+

+ Save the encrypted backup before Buzz shows its separate recovery + code. +

+
+ +
+
+ )} + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index 161f25c211..4b4f360727 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -32,9 +32,14 @@ export async function getNsec(): Promise { export async function importIdentity( nsec: string, password?: string, + recoverySecret?: string, ): Promise { return fromRawIdentity( - await invokeTauri("import_identity", { nsec, password }), + await invokeTauri("import_identity", { + nsec, + password, + recoverySecret, + }), ); } @@ -77,6 +82,28 @@ export async function createNcryptsecBackup(password: string): Promise { return invokeTauri("create_ncryptsec_backup", { password }); } +export type CreatedTwoSkdBackup = { + backup: string; + recoverySecret: string; +}; + +/** Create a 2SKD backup and its separately-held recovery code in Rust. */ +export async function createTwoSkdBackup( + password: string, +): Promise { + return invokeTauri("create_2skd_backup", { password }); +} + +/** Save only the encrypted half of a 2SKD recovery kit. */ +export async function saveTwoSkdBackupCopy( + backup: string, +): Promise { + return ( + (await invokeTauri("save_2skd_backup_copy", { backup })) ?? + null + ); +} + /** Save a portable backup copy. Returns null when the native dialog is cancelled. */ export async function saveNcryptsecCopy( ncryptsec: string, @@ -103,3 +130,16 @@ export async function verifyNcryptsecBackup( password, }); } + +/** Verify all three inputs for a 2SKD recovery without exposing the nsec. */ +export async function verifyTwoSkdBackup( + backup: string, + password: string, + recoverySecret: string, +): Promise { + return invokeTauri("verify_2skd_backup", { + backup, + password, + recoverySecret, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6a246572b4..1005679baf 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8171,6 +8171,9 @@ let backupSaveCallCount = 0; const MOCK_NCRYPTSEC = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; +const MOCK_TWO_SKD_BACKUP = `buzz2skd1:${"A".repeat(80)}`; +const MOCK_RECOVERY_SECRET = + "buzz-recovery-v1-00112233445566778899aabbccddeeff"; const MOCK_BACKUP_PASSPHRASE = "mock horse battery staple lake orbit"; const MOCK_PASSPHRASE_WORDS = [ "mock", @@ -11740,6 +11743,16 @@ export function maybeInstallE2eTauriMocks() { } return MOCK_NCRYPTSEC; } + case "create_2skd_backup": { + const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return { + backup: MOCK_TWO_SKD_BACKUP, + recoverySecret: MOCK_RECOVERY_SECRET, + }; + } case "save_ncryptsec_copy": { const paths = activeConfig?.mock?.backupSavePaths ?? [ "/tmp/buzz-identity.ncryptsec", @@ -11748,6 +11761,14 @@ export function maybeInstallE2eTauriMocks() { backupSaveCallCount += 1; return paths[index]; } + case "save_2skd_backup_copy": { + const paths = activeConfig?.mock?.backupSavePaths ?? [ + "/tmp/identity.buzzbackup", + ]; + const index = Math.min(backupSaveCallCount, paths.length - 1); + backupSaveCallCount += 1; + return paths[index]; + } case "verify_ncryptsec_backup": { const request = payload as { password?: string } | null; const configuredErrors = activeConfig?.mock?.backupVerificationErrors; @@ -11777,6 +11798,26 @@ export function maybeInstallE2eTauriMocks() { pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey), }; } + case "verify_2skd_backup": { + const request = payload as { + password?: string; + recoverySecret?: string; + } | null; + if ( + request?.password !== MOCK_BACKUP_PASSPHRASE || + request?.recoverySecret !== MOCK_RECOVERY_SECRET + ) { + throw new Error( + "wrong backup password, recovery code, or damaged backup", + ); + } + const pubkey = identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey; + return { + pubkey, + npub: npubEncode(pubkey), + matchesCurrentIdentity: true, + }; + } case "get_nsec": { const nsecSequence = activeConfig?.mock?.nsecErrors; if (nsecSequence && nsecSequence.length > 0) { @@ -11816,8 +11857,28 @@ export function maybeInstallE2eTauriMocks() { window.setTimeout(resolve, importDelayMs), ); } - const request = payload as { nsec?: string; password?: string } | null; + const request = payload as { + nsec?: string; + password?: string; + recoverySecret?: string; + } | null; const input = request?.nsec ?? ""; + if (input.trim().startsWith("buzz2skd1:")) { + if ( + input.trim() !== MOCK_TWO_SKD_BACKUP || + request?.password !== MOCK_BACKUP_PASSPHRASE || + request?.recoverySecret !== MOCK_RECOVERY_SECRET + ) { + throw new Error( + "wrong backup password, recovery code, or damaged backup", + ); + } + mockIdentityLostCleared = true; + mockIdentityLockedCleared = true; + return importMockIdentity( + nsecEncode(hexToBytes(DEFAULT_REAL_IDENTITY.privateKey)), + ); + } if (input.trim().startsWith("ncryptsec1")) { if ( input.trim() !== MOCK_NCRYPTSEC || diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index b085e7dab6..bf13a28ed2 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -937,11 +937,11 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ .getByTestId("nostr-import-file-input"); await expect(fileInput).toHaveAttribute( "accept", - ".key,.ncryptsec,text/plain", + ".key,.ncryptsec,.buzzbackup,text/plain", ); await fileInput.setInputFiles({ - buffer: Buffer.alloc(1_025, "x"), + buffer: Buffer.alloc(4_097, "x"), mimeType: "text/plain", name: "not-a-backup.txt", }); @@ -1031,6 +1031,36 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); }); +test("first-launch import accepts a 2SKD recovery kit", async ({ page }) => { + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + await page.goto("/"); + await page.getByRole("button", { name: "Use an existing key" }).click(); + await page.getByTestId("nostr-import-file-button").click(); + + const backupDialog = page.getByTestId("backup-recovery-dialog"); + const backup = `buzz2skd1:${"A".repeat(80)}`; + await backupDialog.getByTestId("nostr-import-file-input").setInputFiles({ + buffer: Buffer.from(backup), + mimeType: "text/plain", + name: "identity.buzzbackup", + }); + + const recoveryCode = backupDialog.getByTestId("nostr-import-recovery-code"); + await expect(recoveryCode).toBeFocused(); + await backupDialog + .getByTestId("nostr-import-passphrase") + .fill("mock horse battery staple lake orbit"); + await expect(backupDialog.getByTestId("nostr-import-submit")).toBeDisabled(); + await recoveryCode.fill("buzz-recovery-v1-00112233445566778899aabbccddeeff"); + await backupDialog.getByTestId("nostr-import-submit").click(); + + await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); + await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); +}); + test("non-local runtime override keeps community selection without release flag", async ({ page, }) => { diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts index edd66170da..a6972e8025 100644 --- a/desktop/tests/e2e/profile-backup-settings.spec.ts +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -11,6 +11,14 @@ const BACKUP_FILE = { mimeType: "text/plain", buffer: Buffer.from("ncryptsec1mockbackupmaterial"), }; +const TWO_SKD_BACKUP = `buzz2skd1:${"A".repeat(80)}`; +const TWO_SKD_RECOVERY_CODE = + "buzz-recovery-v1-00112233445566778899aabbccddeeff"; +const TWO_SKD_FILE = { + name: "identity.buzzbackup", + mimeType: "text/plain", + buffer: Buffer.from(TWO_SKD_BACKUP), +}; async function openIdentity(page: Page) { const identity = page.getByTestId("profile-identity-card"); @@ -50,6 +58,14 @@ async function openCreateBackup(page: Page) { return dialog; } +async function openCreateRecoveryKit(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-create-recovery-kit").click(); + const dialog = page.getByTestId("two-skd-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + async function openTestBackup(page: Page) { await openPrivateKeyMenu(page); await page.getByTestId("private-key-test-backup").click(); @@ -90,6 +106,9 @@ test("private key menu replaces the backup settings rows", async ({ page }) => { await expect(page.getByTestId("private-key-create-backup")).toHaveText( "Create backup", ); + await expect(page.getByTestId("private-key-create-recovery-kit")).toHaveText( + "Create recovery kit", + ); await expect(page.getByTestId("private-key-test-backup")).toHaveText( "Test backup", ); @@ -103,7 +122,58 @@ test("private key menu replaces the backup settings rows", async ({ page }) => { const testDialog = page.getByTestId("backup-test-dialog"); await expect(testDialog).toContainText("Test a key backup"); await expect(testDialog.getByText("Select your backup file")).toBeVisible(); - await expect(testDialog).toContainText("standard NIP-49 format"); + await expect(testDialog).toContainText("Standard NIP-49 backups"); +}); + +test("2SKD recovery kit keeps the recovery code separate from the saved backup", async ({ + page, +}) => { + await openBackupSettings(page, { + backupSavePaths: ["/Users/test/Downloads/identity.buzzbackup"], + }); + const dialog = await openCreateRecoveryKit(page); + const password = dialog.getByLabel("Recovery password"); + const create = dialog.getByTestId("two-skd-create"); + + await password.fill("short"); + await expect(create).toBeDisabled(); + await password.fill("mock horse battery staple lake orbit"); + await create.click(); + + await expect(dialog.getByTestId("two-skd-recovery-code-step")).toBeVisible(); + await expect(dialog.getByTestId("two-skd-recovery-code")).toHaveText( + TWO_SKD_RECOVERY_CODE, + ); + await expect(dialog).toContainText( + "/Users/test/Downloads/identity.buzzbackup", + ); + const commands = await page.evaluate( + () => window.__BUZZ_E2E_COMMANDS__ ?? [], + ); + expect(commands).toContain("create_2skd_backup"); + expect(commands).toContain("save_2skd_backup_copy"); +}); + +test("2SKD recovery kit verification requires the code and password", async ({ + page, +}) => { + await openBackupSettings(page); + const dialog = await openTestBackup(page); + await page.getByTestId("backup-test-file-input").setInputFiles(TWO_SKD_FILE); + await expect(dialog.getByTestId("backup-test-recovery-code")).toBeVisible(); + + await dialog + .getByTestId("backup-test-password") + .fill("mock horse battery staple lake orbit"); + await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled(); + await dialog + .getByTestId("backup-test-recovery-code") + .fill(TWO_SKD_RECOVERY_CODE); + await dialog.getByTestId("backup-test-verify").click(); + + await expect(dialog.getByTestId("backup-test-success")).toContainText( + "It restores your current Buzz identity.", + ); }); test("creation requires a sufficiently long password and exposes a temporary header download", async ({