From 230bf6fa7c7d48e2ffbb75ceab52db8535722d51 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 19:13:41 -0700 Subject: [PATCH 1/9] 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 77362b3f7e2..494030f7157 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 5bd19ef2b6d..8fb776ad84c 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 db089fc13fa..c25ca4d0da9 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 ec2357b85e7..0bdf29276c3 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 e8fcc8abe42..b3d07a48bb7 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 7f46ff2a7d4..5140d65e657 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 f8601b66d08..4c2619f306a 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 00000000000..9504f0562b6 --- /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 bc0bb4b4d7d..99179fd8609 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 0f6fc609ed7..9f775e2602f 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 a6a02f38c0b..d8b8cc314c9 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 27d6b8ce447..94632e2d0e1 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 814f2287412..96a89fe9462 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 f859414cf9f..135677510e5 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 65ef40a98a7..c6826645910 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 8eb0256a557..079d9c96078 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 00000000000..66cecba42c3 --- /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 161f25c211f..4b4f3607270 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 6a246572b45..1005679baff 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 b085e7dab69..bf13a28ed21 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 edd66170da4..a6972e80257 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 ({ From 91fe2ee2917eaaad7795e1b2bee1fc0557822934 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:02:14 -0700 Subject: [PATCH 2/9] Add printable recovery sheet PDFs Signed-off-by: Jordan Mecom --- desktop/src-tauri/Cargo.lock | 307 +++++++++++-- desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/src/commands/identity.rs | 59 +++ desktop/src-tauri/src/key_backup.rs | 37 +- desktop/src-tauri/src/key_backup_tests.rs | 17 + desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/recovery_sheet.rs | 425 ++++++++++++++++++ desktop/src-tauri/src/two_skd.rs | 11 +- .../settings/ui/TwoSkdRecoveryCreator.tsx | 67 ++- desktop/src/shared/api/tauriIdentity.ts | 13 + desktop/src/testing/e2eBridge.ts | 21 + .../tests/e2e/profile-backup-settings.spec.ts | 6 + 12 files changed, 924 insertions(+), 43 deletions(-) create mode 100644 desktop/src-tauri/src/recovery_sheet.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 8fb776ad84c..f72e8efcaa5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -122,6 +122,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -143,6 +149,29 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "allsorts-azul" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab097a7be305dd66b2b6917f5efb6b723129ce4b2cd11aa1a0afcc293c7dc08e" +dependencies = [ + "bitflags 2.13.0", + "brotli-decompressor", + "encoding_rs", + "enumflags2", + "flate2", + "glyph-names", + "log", + "ouroboros", + "pathfinder_geometry", + "rustc-hash", + "tinyvec", + "ucd-trie", + "unicode-canonical-combining-class", + "unicode-general-category", + "unicode-joining-type", +] + [[package]] name = "alsa" version = "0.11.0" @@ -210,7 +239,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -221,7 +250,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -932,6 +961,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -1135,6 +1173,8 @@ dependencies = [ "plist", "png 0.18.1", "portable-pty", + "printpdf", + "qrcode", "regex", "reqwest 0.13.4", "rodio", @@ -1420,6 +1460,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.2.66" @@ -1644,7 +1693,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2311,7 +2360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -2332,8 +2381,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ "aes 0.8.4", - "block-padding", - "cbc", + "block-padding 0.3.3", + "cbc 0.1.2", "dbus", "fastrand", "hkdf", @@ -2489,7 +2538,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2634,6 +2683,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7984231f8b4c72eb3b88c70040dc1e4ff6803fa9169e93c0ac465942d74fa36a" +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -2770,7 +2828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3288,7 +3346,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -3369,7 +3427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.12", ] [[package]] @@ -3520,6 +3578,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glyph-names" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3531d702d6c1a3ba92a5fb55a404c7b8c476c8e7ca249951077afcbe4bc807f" + [[package]] name = "gobject-sys" version = "0.18.0" @@ -4300,7 +4364,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] @@ -4310,6 +4374,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding 0.4.2", "hybrid-array", ] @@ -4983,6 +5048,32 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lopdf" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" +dependencies = [ + "aes 0.9.1", + "bitflags 2.13.0", + "cbc 0.2.1", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap 2.14.0", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "sha2 0.11.0", + "stringprep", + "thiserror 2.0.18", + "weezl 0.2.1", +] + [[package]] name = "lru" version = "0.18.1" @@ -5136,6 +5227,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if 1.0.4", + "digest 0.11.3", +] + [[package]] name = "md5" version = "0.8.1" @@ -5746,7 +5847,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5905,7 +6006,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6307,7 +6408,7 @@ dependencies = [ "bech32 0.11.1", "bip39", "bitcoin_hashes 0.14.101", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", "chacha20poly1305", "getrandom 0.2.17", @@ -6331,7 +6432,7 @@ dependencies = [ "bech32 0.12.0", "bip39", "bitcoin_hashes 1.2.0", - "cbc", + "cbc 0.1.2", "chacha20 0.9.1", "chacha20poly1305", "faster-hex", @@ -6418,7 +6519,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7122,7 +7223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7148,6 +7249,30 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.118", +] + [[package]] name = "palette" version = "0.7.6" @@ -7265,6 +7390,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -7765,6 +7909,28 @@ dependencies = [ "num-integer", ] +[[package]] +name = "printpdf" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75e1f6d16f80c8f34918a4cc68743d703e8ee5016fac9508e223eb8e60445a0" +dependencies = [ + "allsorts-azul", + "base64 0.22.1", + "flate2", + "getrandom 0.4.3", + "image", + "lopdf", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "time", + "wasm-bindgen", + "wasm-bindgen-futures", + "weezl 0.2.1", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -7827,6 +7993,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "version_check", + "yansi", +] + [[package]] name = "process-wrap" version = "9.1.0" @@ -8069,6 +8248,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" @@ -8148,7 +8333,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8276,6 +8461,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "ratatui" version = "0.30.2" @@ -8879,7 +9070,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8949,7 +9140,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9161,7 +9352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" dependencies = [ "aes 0.8.4", - "cbc", + "cbc 0.1.2", "futures-util", "generic-array", "hkdf", @@ -9216,7 +9407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9816,6 +10007,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket-pktinfo" @@ -9835,7 +10029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10005,6 +10199,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -10849,10 +11054,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10874,7 +11079,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10999,7 +11204,7 @@ dependencies = [ "flate2", "half", "quick-error", - "weezl", + "weezl 0.1.12", "zune-jpeg", ] @@ -11616,7 +11821,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11717,7 +11922,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11783,12 +11988,36 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-canonical-combining-class" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c99d5174052d02ce765418e826597a1be18f32c114e35d9e22f92390239561" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -11807,6 +12036,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -12455,6 +12690,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -12568,7 +12809,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13503,6 +13744,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index c25ca4d0da9..0a109dd857c 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -133,6 +133,8 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4", "v5"] } png = "0.18" +printpdf = { version = "0.12.6", default-features = false, features = ["png"] } +qrcode = { version = "0.14.1", default-features = false } # wayland-data-control: without it arboard is X11-only on Linux, so copies made # in a Wayland session land in XWayland's clipboard where Wayland-native apps # never see them (set_text still returns Ok). The backing wl-clipboard-rs dep is diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 0bdf29276c3..9ac6ca2ec02 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -414,6 +414,65 @@ pub async fn save_2skd_backup_copy( Ok(Some(dest.display().to_string())) } +/// Render and save the secret half of a recovery kit as a printable PDF. +#[tauri::command] +pub async fn save_2skd_recovery_sheet( + backup: String, + recovery_secret: String, + app_handle: tauri::AppHandle, +) -> Result, String> { + use chrono::Datelike; + + let recovery_secret = zeroize::Zeroizing::new(recovery_secret); + crate::two_skd::validate_recovery_secret(&recovery_secret)?; + let npub = crate::two_skd::backup_public_key(&backup)? + .to_bech32() + .map_err(|error| format!("encode recovery-sheet identity: {error}"))?; + + let date = chrono::Local::now().date_naive(); + let month = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ][date.month0() as usize]; + let created_date = format!("{month} {}, {}", date.day(), date.year()); + + let dest = match crate::commands::export_util::pick_save_path( + &app_handle, + crate::recovery_sheet::RECOVERY_SHEET_FILE_NAME, + "Buzz recovery sheet", + &["pdf"], + ) + .await? + { + Some(path) => path, + None => return Ok(None), + }; + + let dest_for_write = dest.clone(); + tokio::task::spawn_blocking(move || { + let pdf = zeroize::Zeroizing::new(crate::recovery_sheet::render_recovery_sheet( + &recovery_secret, + &npub, + &created_date, + )?); + crate::key_backup::write_portable_secret_file(&dest_for_write, &pdf) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))??; + + Ok(Some(dest.display().to_string())) +} + #[tauri::command] pub async fn import_identity( nsec: String, diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index b3d07a48bb7..6aba046d43d 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -188,6 +188,22 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), /// the destination already exists. After writing, the file is synced and its /// persisted bytes are reread before success is reported. pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + write_portable_secret_file_with_label(path, ncryptsec.as_bytes(), "backup file") +} + +/// Write secret-bearing bytes to an exclusively created, owner-only file. +/// +/// This supports portable non-text artifacts such as recovery-sheet PDFs while +/// preserving the save-panel and reread guarantees used for key backups. +pub fn write_portable_secret_file(path: &std::path::Path, contents: &[u8]) -> Result<(), String> { + write_portable_secret_file_with_label(path, contents, "recovery sheet") +} + +fn write_portable_secret_file_with_label( + path: &std::path::Path, + contents: &[u8], + label: &str, +) -> Result<(), String> { use std::io::Write; let mut options = std::fs::OpenOptions::new(); @@ -200,23 +216,22 @@ pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Re let mut file = options.open(path).map_err(|error| { if error.kind() == std::io::ErrorKind::AlreadyExists { - "backup file already exists; choose a new filename so the existing backup stays safe" - .to_string() + format!("{label} already exists; choose a new filename so the existing copy stays safe") } else { - format!("create portable backup file: {error}") + format!("create portable {label}: {error}") } })?; let write_result = file - .write_all(ncryptsec.as_bytes()) - .map_err(|e| format!("write portable backup file: {e}")) + .write_all(contents) + .map_err(|e| format!("write portable {label}: {e}")) .and_then(|()| { file.sync_all() - .map_err(|e| format!("sync portable backup file: {e}")) + .map_err(|e| format!("sync portable {label}: {e}")) }); drop(file); - let result = write_result.and_then(|()| verify_backup_file(path, ncryptsec)); + let result = write_result.and_then(|()| verify_secret_file(path, contents, label)); if result.is_err() { // This function created the destination exclusively, so cleanup cannot // clobber a backup that existed before the save attempt. @@ -225,6 +240,14 @@ pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Re result } +fn verify_secret_file(path: &std::path::Path, contents: &[u8], label: &str) -> Result<(), String> { + let on_disk = std::fs::read(path).map_err(|e| format!("reread {label}: {e}"))?; + if on_disk != contents { + return Err(format!("{label} verification failed: on-disk bytes differ")); + } + Ok(()) +} + fn verify_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { // Reread and byte-compare: only report success for bytes that are // actually on disk. diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 5140d65e657..3e39162a08b 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -220,6 +220,23 @@ fn write_portable_backup_file_persists_0600_without_a_sibling() { } } +#[test] +fn write_portable_secret_file_persists_binary_bytes_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("recovery-sheet.pdf"); + let contents = b"%PDF-1.7\0binary recovery sheet"; + + write_portable_secret_file(&path, contents).unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), contents); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "recovery sheet must be owner-only"); + } +} + #[test] fn write_portable_backup_file_preserves_an_existing_backup() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4c2619f306a..230ce7b40e1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ mod observed_unread; mod persona_catalog; mod prevent_sleep; mod ptt_shortcut; +mod recovery_sheet; mod relay; mod relay_admission; mod reset; @@ -562,6 +563,7 @@ pub fn run() { verify_2skd_backup, save_ncryptsec_copy, save_2skd_backup_copy, + save_2skd_recovery_sheet, import_identity, persist_current_identity, get_profile, diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs new file mode 100644 index 00000000000..55073c81ab9 --- /dev/null +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -0,0 +1,425 @@ +//! Printable recovery-code sheet for a Buzz 2SKD recovery kit. +//! +//! The PDF deliberately contains only the recovery code and public identity. +//! It never contains the password, private key, or encrypted backup artifact. + +use printpdf::{ + BuiltinFont, Color, Mm, Op, PaintMode, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, + Point, Pt, RawImage, Rect, Rgb, TextItem, WindingOrder, XObjectTransform, +}; +use qrcode::{Color as QrColor, EcLevel, QrCode}; + +pub const RECOVERY_SHEET_FILE_NAME: &str = "buzz-recovery-sheet.pdf"; + +const PAGE_WIDTH_MM: f32 = 215.9; +const PAGE_HEIGHT_MM: f32 = 279.4; +const BUZZ_WORDMARK: &[u8] = include_bytes!("../../public/landing/buzz-wordmark.png"); + +fn charcoal() -> Color { + Color::Rgb(Rgb::new(35.0 / 255.0, 30.0 / 255.0, 30.0 / 255.0, None)) +} + +fn buzz_yellow() -> Color { + Color::Rgb(Rgb::new(215.0 / 255.0, 215.0 / 255.0, 46.0 / 255.0, None)) +} + +fn cream() -> Color { + Color::Rgb(Rgb::new(247.0 / 255.0, 247.0 / 255.0, 235.0 / 255.0, None)) +} + +fn muted() -> Color { + Color::Rgb(Rgb::new(100.0 / 255.0, 94.0 / 255.0, 83.0 / 255.0, None)) +} + +fn white() -> Color { + Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)) +} + +fn rect(x: f32, y: f32, width: f32, height: f32, mode: PaintMode) -> Rect { + Rect { + x: Mm(x).into(), + y: Mm(y).into(), + width: Mm(width).into(), + height: Mm(height).into(), + mode: Some(mode), + winding_order: Some(WindingOrder::NonZero), + } +} + +fn fill_rect(ops: &mut Vec, x: f32, y: f32, width: f32, height: f32, color: Color) { + ops.push(Op::SetFillColor { col: color }); + ops.push(Op::DrawRectangle { + rectangle: rect(x, y, width, height, PaintMode::Fill), + }); +} + +fn fill_stroke_rect( + ops: &mut Vec, + x: f32, + y: f32, + width: f32, + height: f32, + fill: Color, + stroke: Color, +) { + ops.push(Op::SetFillColor { col: fill }); + ops.push(Op::SetOutlineColor { col: stroke }); + ops.push(Op::SetOutlineThickness { pt: Pt(0.8) }); + ops.push(Op::DrawRectangle { + rectangle: rect(x, y, width, height, PaintMode::FillStroke), + }); +} + +fn text( + ops: &mut Vec, + x: f32, + y: f32, + size: f32, + font: BuiltinFont, + color: Color, + value: impl Into, +) { + ops.extend([ + Op::StartTextSection, + Op::SetFillColor { col: color }, + Op::SetTextCursor { + pos: Point::new(Mm(x), Mm(y)), + }, + Op::SetFont { + font: PdfFontHandle::Builtin(font), + size: Pt(size), + }, + Op::ShowText { + items: vec![TextItem::Text(value.into())], + }, + Op::EndTextSection, + ]); +} + +fn draw_dot_grid(ops: &mut Vec) { + let dot = Color::Rgb(Rgb::new(211.0 / 255.0, 209.0 / 255.0, 198.0 / 255.0, None)); + let mut y = 215.0; + let mut row = 0usize; + while y < 274.0 { + let offset = if row.is_multiple_of(2) { 0.0 } else { 3.5 }; + let mut x = 136.0 + offset; + while x < 211.0 { + fill_rect(ops, x, y, 0.4, 0.4, dot.clone()); + x += 7.0; + } + y += 7.0; + row += 1; + } +} + +fn draw_qr(ops: &mut Vec, recovery_secret: &str) -> Result<(), String> { + let code = QrCode::with_error_correction_level(recovery_secret.as_bytes(), EcLevel::Q) + .map_err(|error| format!("encode recovery-code QR: {error}"))?; + let quiet_modules = 4usize; + let total_modules = code.width() + quiet_modules * 2; + let qr_size_mm = 56.0f32; + let module_mm = qr_size_mm / total_modules as f32; + let qr_x = 136.0f32; + let qr_y = 105.0f32; + + fill_rect(ops, qr_x, qr_y, qr_size_mm, qr_size_mm, white()); + for y in 0..code.width() { + for x in 0..code.width() { + if code[(x, y)] == QrColor::Dark { + let module_x = qr_x + (x + quiet_modules) as f32 * module_mm; + let module_y = qr_y + (code.width() - 1 - y + quiet_modules) as f32 * module_mm; + fill_rect(ops, module_x, module_y, module_mm, module_mm, charcoal()); + } + } + } + Ok(()) +} + +fn split_npub(npub: &str) -> (&str, &str) { + let split_at = npub.len().min(32); + npub.split_at(split_at) +} + +/// Render a one-page, print-ready recovery sheet. +pub(crate) fn render_recovery_sheet( + recovery_secret: &str, + npub: &str, + created_date: &str, +) -> Result, String> { + crate::two_skd::validate_recovery_secret(recovery_secret)?; + if !npub.starts_with("npub1") || npub.len() < 20 { + return Err("invalid recovery-sheet identity".to_string()); + } + + let mut document = PdfDocument::new("Buzz recovery sheet"); + let mut ops = Vec::new(); + + fill_rect(&mut ops, 0.0, 0.0, PAGE_WIDTH_MM, PAGE_HEIGHT_MM, white()); + fill_rect(&mut ops, 0.0, 276.4, PAGE_WIDTH_MM, 3.0, buzz_yellow()); + draw_dot_grid(&mut ops); + + let mut warnings = Vec::new(); + let wordmark = RawImage::decode_from_bytes(BUZZ_WORDMARK, &mut warnings) + .map_err(|error| format!("decode Buzz wordmark: {error}"))?; + let wordmark_id = document.add_image(&wordmark); + ops.push(Op::UseXobject { + id: wordmark_id, + transform: XObjectTransform { + translate_x: Some(Mm(18.0).into()), + translate_y: Some(Mm(242.0).into()), + scale_x: Some(0.82), + scale_y: Some(0.82), + dpi: Some(300.0), + ..Default::default() + }, + }); + + text( + &mut ops, + 18.0, + 231.0, + 8.0, + BuiltinFont::HelveticaBold, + charcoal(), + "RECOVERY SHEET / KEEP OFFLINE", + ); + text( + &mut ops, + 18.0, + 211.0, + 27.0, + BuiltinFont::HelveticaBold, + charcoal(), + "Keep this separate.", + ); + text( + &mut ops, + 18.0, + 200.5, + 11.0, + BuiltinFont::Helvetica, + charcoal(), + "This paper is one of three things required to recover your identity.", + ); + fill_rect(&mut ops, 18.0, 188.0, 24.0, 2.0, buzz_yellow()); + + fill_stroke_rect(&mut ops, 18.0, 78.0, 179.9, 104.0, white(), charcoal()); + text( + &mut ops, + 28.0, + 165.0, + 8.0, + BuiltinFont::HelveticaBold, + muted(), + "YOUR RECOVERY CODE", + ); + text( + &mut ops, + 28.0, + 151.0, + 19.0, + BuiltinFont::HelveticaBold, + charcoal(), + "One code.", + ); + text( + &mut ops, + 28.0, + 142.5, + 19.0, + BuiltinFont::HelveticaBold, + charcoal(), + "Store it offline.", + ); + fill_stroke_rect(&mut ops, 28.0, 122.0, 96.0, 11.0, cream(), muted()); + fill_rect(&mut ops, 28.0, 122.0, 2.0, 11.0, buzz_yellow()); + text( + &mut ops, + 32.0, + 126.0, + 8.0, + BuiltinFont::CourierBold, + charcoal(), + recovery_secret, + ); + + text( + &mut ops, + 28.0, + 111.0, + 7.0, + BuiltinFont::HelveticaBold, + muted(), + "BUZZ IDENTITY", + ); + let (npub_first, npub_second) = split_npub(npub); + text( + &mut ops, + 28.0, + 104.5, + 7.5, + BuiltinFont::Courier, + charcoal(), + npub_first, + ); + text( + &mut ops, + 28.0, + 99.5, + 7.5, + BuiltinFont::Courier, + charcoal(), + npub_second, + ); + text( + &mut ops, + 28.0, + 89.0, + 7.0, + BuiltinFont::HelveticaBold, + muted(), + format!("CREATED {created_date}"), + ); + + draw_qr(&mut ops, recovery_secret)?; + text( + &mut ops, + 143.0, + 96.0, + 7.0, + BuiltinFont::Helvetica, + muted(), + "QR CONTAINS THE RECOVERY CODE", + ); + + text( + &mut ops, + 18.0, + 67.5, + 8.0, + BuiltinFont::HelveticaBold, + muted(), + "RECOVERY NEEDS ALL THREE", + ); + let requirements = [ + ("1", "Encrypted backup", "identity.buzzbackup"), + ("2", "Your password", "Known only to you"), + ("3", "This sheet", "Stored separately"), + ]; + for (index, (number, title, detail)) in requirements.iter().enumerate() { + let x = 18.0 + index as f32 * 61.0; + fill_stroke_rect(&mut ops, x, 39.0, 57.9, 23.0, white(), charcoal()); + fill_rect(&mut ops, x, 54.0, 8.0, 8.0, buzz_yellow()); + text( + &mut ops, + x + 2.55, + 56.2, + 8.0, + BuiltinFont::HelveticaBold, + charcoal(), + *number, + ); + text( + &mut ops, + x + 4.0, + 48.0, + 9.0, + BuiltinFont::HelveticaBold, + charcoal(), + *title, + ); + text( + &mut ops, + x + 4.0, + 42.5, + 7.0, + BuiltinFont::Helvetica, + muted(), + *detail, + ); + } + + fill_stroke_rect(&mut ops, 18.0, 14.0, 179.9, 17.0, white(), charcoal()); + fill_rect(&mut ops, 18.0, 14.0, 3.0, 17.0, buzz_yellow()); + text( + &mut ops, + 24.0, + 24.0, + 9.0, + BuiltinFont::HelveticaBold, + charcoal(), + "DO NOT STORE THIS SHEET WITH YOUR ENCRYPTED BACKUP.", + ); + text( + &mut ops, + 24.0, + 18.5, + 8.0, + BuiltinFont::Helvetica, + muted(), + "Buzz cannot recreate this code or reset your recovery password.", + ); + text( + &mut ops, + 18.0, + 6.0, + 7.0, + BuiltinFont::HelveticaBold, + muted(), + "buzz.xyz / recovery sheet v1", + ); + + let page = PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), ops); + let bytes = document + .with_pages(vec![page]) + .save(&PdfSaveOptions::default(), &mut warnings); + if !bytes.starts_with(b"%PDF-") { + return Err("render recovery sheet: invalid PDF output".to_string()); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use printpdf::{PdfDocument, PdfParseOptions}; + + const RECOVERY_CODE: &str = "buzz-recovery-v1-00112233445566778899aabbccddeeff"; + const NPUB: &str = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5hq3g5"; + + #[test] + fn renders_a_single_page_with_recovery_material_and_no_backup() { + let bytes = render_recovery_sheet(RECOVERY_CODE, NPUB, "August 21, 2026").unwrap(); + let mut warnings = Vec::new(); + let parsed = PdfDocument::parse( + &bytes, + &PdfParseOptions { + fail_on_error: true, + }, + &mut warnings, + ) + .unwrap(); + let text = parsed + .extract_text() + .into_iter() + .flatten() + .collect::(); + + assert_eq!(parsed.pages.len(), 1); + assert!(text.contains(RECOVERY_CODE)); + assert!(text.contains("Keep this separate.")); + assert!(text.contains(NPUB)); + assert!(!text.contains("buzz2skd1:")); + assert!(!text.contains("password=")); + + if let Ok(path) = std::env::var("BUZZ_RECOVERY_SHEET_PREVIEW") { + std::fs::write(path, bytes).unwrap(); + } + } + + #[test] + fn rejects_malformed_recovery_material() { + assert!(render_recovery_sheet("not-a-code", NPUB, "August 21, 2026").is_err()); + assert!(render_recovery_sheet(RECOVERY_CODE, "not-an-npub", "August 21, 2026").is_err()); + } +} diff --git a/desktop/src-tauri/src/two_skd.rs b/desktop/src-tauri/src/two_skd.rs index 9504f0562b6..e660a55f8bb 100644 --- a/desktop/src-tauri/src/two_skd.rs +++ b/desktop/src-tauri/src/two_skd.rs @@ -12,7 +12,7 @@ use base64::Engine; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use hkdf::Hkdf; -use nostr::{Keys, SecretKey}; +use nostr::{Keys, PublicKey, SecretKey}; use serde::{Deserialize, Serialize}; use sha2_legacy::Sha256; use unicode_normalization::UnicodeNormalization; @@ -240,6 +240,11 @@ pub(crate) fn validate_backup(input: &str) -> Result<(), String> { parse_backup(input).map(|_| ()) } +pub(crate) fn backup_public_key(input: &str) -> Result { + let payload = parse_backup(input)?; + PublicKey::parse(&payload.pubkey).map_err(|_| "invalid backup public key".to_string()) +} + fn validate_payload_shape(payload: &BackupPayload) -> Result<(), String> { validate_cost(payload.cost())?; decode_array::(&payload.salt, "backup salt")?; @@ -264,6 +269,10 @@ fn parse_recovery_secret(input: &str) -> Result Result<(), String> { + parse_recovery_secret(input).map(|_| ()) +} + fn decode_array(encoded: &str, label: &str) -> Result<[u8; N], String> { let bytes = URL_SAFE_NO_PAD .decode(encoded) diff --git a/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx b/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx index 66cecba42c3..59821aa7fb1 100644 --- a/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx +++ b/desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx @@ -1,10 +1,11 @@ -import { Check, Copy, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, FileDown, ShieldCheck } from "lucide-react"; import * as React from "react"; import { createTwoSkdBackup, type CreatedTwoSkdBackup, saveTwoSkdBackupCopy, + saveTwoSkdRecoverySheet, } from "@/shared/api/tauriIdentity"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; @@ -35,6 +36,9 @@ export function TwoSkdRecoveryCreator({ null, ); const [savedPath, setSavedPath] = React.useState(null); + const [recoverySheetPath, setRecoverySheetPath] = React.useState< + string | null + >(null); const [pending, setPending] = React.useState(false); const [copied, setCopied] = React.useState(false); const [error, setError] = React.useState(null); @@ -47,6 +51,7 @@ export function TwoSkdRecoveryCreator({ setRevealed(false); setCreated(null); setSavedPath(null); + setRecoverySheetPath(null); setPending(false); setCopied(false); setError(null); @@ -118,6 +123,30 @@ export function TwoSkdRecoveryCreator({ } }, [created]); + const saveRecoverySheet = React.useCallback(async () => { + if (!created || pending) return; + const requestId = ++requestRef.current; + setPending(true); + setError(null); + try { + const path = await saveTwoSkdRecoverySheet( + created.backup, + created.recoverySecret, + ); + if (requestId !== requestRef.current) return; + if (path) setRecoverySheetPath(path); + } catch (cause) { + if (requestId !== requestRef.current) return; + setError( + cause instanceof Error + ? cause.message + : "Could not save the recovery sheet.", + ); + } finally { + if (requestId === requestRef.current) setPending(false); + } + }, [created, pending]); + const retrySave = React.useCallback(async () => { if (!created || pending) return; const requestId = ++requestRef.current; @@ -227,10 +256,19 @@ export function TwoSkdRecoveryCreator({

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. + different place. The printable PDF includes a QR code and recovery + instructions, but never your password or encrypted backup. Buzz + cannot recreate it later.

-
+ {recoverySheetPath ? ( +

+ Recovery sheet saved to {recoverySheetPath} +

+ ) : null} +
- +
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index 4b4f3607270..7451f8d9574 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -104,6 +104,19 @@ export async function saveTwoSkdBackupCopy( ); } +/** Save the recovery code and public identity as a printable PDF. */ +export async function saveTwoSkdRecoverySheet( + backup: string, + recoverySecret: string, +): Promise { + return ( + (await invokeTauri("save_2skd_recovery_sheet", { + backup, + recoverySecret, + })) ?? null + ); +} + /** Save a portable backup copy. Returns null when the native dialog is cancelled. */ export async function saveNcryptsecCopy( ncryptsec: string, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1005679baff..aa26b02da81 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -568,6 +568,8 @@ type E2eConfig = { backupEncryptionDelayMs?: number; /** Native paths returned by successive backup saves. */ backupSavePaths?: Array; + /** Native paths returned by successive recovery-sheet PDF saves. */ + recoverySheetSavePaths?: Array; /** * When set, `get_nsec` throws with this message instead of returning the * mock nsec string. Use `nsecErrors` for sequenced failure/success. @@ -8168,6 +8170,7 @@ let mockGlobalAgentConfig: { let nsecCallCount = 0; let backupVerificationCallCount = 0; let backupSaveCallCount = 0; +let recoverySheetSaveCallCount = 0; const MOCK_NCRYPTSEC = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; @@ -11769,6 +11772,24 @@ export function maybeInstallE2eTauriMocks() { backupSaveCallCount += 1; return paths[index]; } + case "save_2skd_recovery_sheet": { + const request = payload as { + backup?: string; + recoverySecret?: string; + } | null; + if ( + request?.backup !== MOCK_TWO_SKD_BACKUP || + request?.recoverySecret !== MOCK_RECOVERY_SECRET + ) { + throw new Error("invalid recovery sheet material"); + } + const paths = activeConfig?.mock?.recoverySheetSavePaths ?? [ + "/tmp/buzz-recovery-sheet.pdf", + ]; + const index = Math.min(recoverySheetSaveCallCount, paths.length - 1); + recoverySheetSaveCallCount += 1; + return paths[index]; + } case "verify_ncryptsec_backup": { const request = payload as { password?: string } | null; const configuredErrors = activeConfig?.mock?.backupVerificationErrors; diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts index a6972e80257..1fc05d20c8d 100644 --- a/desktop/tests/e2e/profile-backup-settings.spec.ts +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -130,6 +130,7 @@ test("2SKD recovery kit keeps the recovery code separate from the saved backup", }) => { await openBackupSettings(page, { backupSavePaths: ["/Users/test/Downloads/identity.buzzbackup"], + recoverySheetSavePaths: ["/Users/test/Documents/buzz-recovery-sheet.pdf"], }); const dialog = await openCreateRecoveryKit(page); const password = dialog.getByLabel("Recovery password"); @@ -147,11 +148,16 @@ test("2SKD recovery kit keeps the recovery code separate from the saved backup", await expect(dialog).toContainText( "/Users/test/Downloads/identity.buzzbackup", ); + await dialog.getByTestId("two-skd-save-recovery-sheet").click(); + await expect(dialog.getByTestId("two-skd-recovery-sheet-path")).toContainText( + "/Users/test/Documents/buzz-recovery-sheet.pdf", + ); const commands = await page.evaluate( () => window.__BUZZ_E2E_COMMANDS__ ?? [], ); expect(commands).toContain("create_2skd_backup"); expect(commands).toContain("save_2skd_backup_copy"); + expect(commands).toContain("save_2skd_recovery_sheet"); }); test("2SKD recovery kit verification requires the code and password", async ({ From 34bcdb8eb69baf22d28176251473696a563d6dfd Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:05:31 -0700 Subject: [PATCH 3/9] Use plain language on recovery sheets Signed-off-by: Jordan Mecom --- desktop/src-tauri/src/recovery_sheet.rs | 41 +++++++++++++++---------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs index 55073c81ab9..8d68869fd16 100644 --- a/desktop/src-tauri/src/recovery_sheet.rs +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -181,7 +181,7 @@ pub(crate) fn render_recovery_sheet( 8.0, BuiltinFont::HelveticaBold, charcoal(), - "RECOVERY SHEET / KEEP OFFLINE", + "BUZZ RECOVERY CODE", ); text( &mut ops, @@ -190,7 +190,7 @@ pub(crate) fn render_recovery_sheet( 27.0, BuiltinFont::HelveticaBold, charcoal(), - "Keep this separate.", + "Your Buzz recovery code.", ); text( &mut ops, @@ -199,7 +199,16 @@ pub(crate) fn render_recovery_sheet( 11.0, BuiltinFont::Helvetica, charcoal(), - "This paper is one of three things required to recover your identity.", + "Use this code with your backup file and password", + ); + text( + &mut ops, + 18.0, + 195.5, + 11.0, + BuiltinFont::Helvetica, + charcoal(), + "to recover your Buzz identity.", ); fill_rect(&mut ops, 18.0, 188.0, 24.0, 2.0, buzz_yellow()); @@ -211,7 +220,7 @@ pub(crate) fn render_recovery_sheet( 8.0, BuiltinFont::HelveticaBold, muted(), - "YOUR RECOVERY CODE", + "RECOVERY CODE", ); text( &mut ops, @@ -220,16 +229,16 @@ pub(crate) fn render_recovery_sheet( 19.0, BuiltinFont::HelveticaBold, charcoal(), - "One code.", + "Recovery code", ); text( &mut ops, 28.0, 142.5, - 19.0, - BuiltinFont::HelveticaBold, + 11.0, + BuiltinFont::Helvetica, charcoal(), - "Store it offline.", + "Scan the QR code or type the code below.", ); fill_stroke_rect(&mut ops, 28.0, 122.0, 96.0, 11.0, cream(), muted()); fill_rect(&mut ops, 28.0, 122.0, 2.0, 11.0, buzz_yellow()); @@ -289,7 +298,7 @@ pub(crate) fn render_recovery_sheet( 7.0, BuiltinFont::Helvetica, muted(), - "QR CONTAINS THE RECOVERY CODE", + "SCAN TO COPY THE RECOVERY CODE", ); text( @@ -299,12 +308,12 @@ pub(crate) fn render_recovery_sheet( 8.0, BuiltinFont::HelveticaBold, muted(), - "RECOVERY NEEDS ALL THREE", + "TO RECOVER YOUR IDENTITY, YOU NEED", ); let requirements = [ - ("1", "Encrypted backup", "identity.buzzbackup"), - ("2", "Your password", "Known only to you"), - ("3", "This sheet", "Stored separately"), + ("1", "Backup file", "identity.buzzbackup"), + ("2", "Password", "The password you chose"), + ("3", "Recovery code", "Printed on this page"), ]; for (index, (number, title, detail)) in requirements.iter().enumerate() { let x = 18.0 + index as f32 * 61.0; @@ -348,7 +357,7 @@ pub(crate) fn render_recovery_sheet( 9.0, BuiltinFont::HelveticaBold, charcoal(), - "DO NOT STORE THIS SHEET WITH YOUR ENCRYPTED BACKUP.", + "KEEP THIS PAGE SEPARATE FROM YOUR BACKUP FILE.", ); text( &mut ops, @@ -357,7 +366,7 @@ pub(crate) fn render_recovery_sheet( 8.0, BuiltinFont::Helvetica, muted(), - "Buzz cannot recreate this code or reset your recovery password.", + "Buzz cannot replace this recovery code if you lose it.", ); text( &mut ops, @@ -407,7 +416,7 @@ mod tests { assert_eq!(parsed.pages.len(), 1); assert!(text.contains(RECOVERY_CODE)); - assert!(text.contains("Keep this separate.")); + assert!(text.contains("Your Buzz recovery code.")); assert!(text.contains(NPUB)); assert!(!text.contains("buzz2skd1:")); assert!(!text.contains("password=")); From b8bf3a0f2e2063807d5ce3d62a2f084cc2fd17fc Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:08:01 -0700 Subject: [PATCH 4/9] Trim recovery sheet copy Signed-off-by: Jordan Mecom --- desktop/src-tauri/src/recovery_sheet.rs | 93 +++---------------------- 1 file changed, 9 insertions(+), 84 deletions(-) diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs index 8d68869fd16..cca3202ee5d 100644 --- a/desktop/src-tauri/src/recovery_sheet.rs +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -177,75 +177,30 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 18.0, - 231.0, - 8.0, - BuiltinFont::HelveticaBold, - charcoal(), - "BUZZ RECOVERY CODE", - ); - text( - &mut ops, - 18.0, - 211.0, + 224.0, 27.0, BuiltinFont::HelveticaBold, charcoal(), "Your Buzz recovery code.", ); - text( - &mut ops, - 18.0, - 200.5, - 11.0, - BuiltinFont::Helvetica, - charcoal(), - "Use this code with your backup file and password", - ); - text( - &mut ops, - 18.0, - 195.5, - 11.0, - BuiltinFont::Helvetica, - charcoal(), - "to recover your Buzz identity.", - ); - fill_rect(&mut ops, 18.0, 188.0, 24.0, 2.0, buzz_yellow()); + fill_rect(&mut ops, 18.0, 204.0, 24.0, 2.0, buzz_yellow()); fill_stroke_rect(&mut ops, 18.0, 78.0, 179.9, 104.0, white(), charcoal()); - text( - &mut ops, - 28.0, - 165.0, - 8.0, - BuiltinFont::HelveticaBold, - muted(), - "RECOVERY CODE", - ); text( &mut ops, 28.0, 151.0, - 19.0, - BuiltinFont::HelveticaBold, - charcoal(), - "Recovery code", - ); - text( - &mut ops, - 28.0, - 142.5, - 11.0, + 12.0, BuiltinFont::Helvetica, charcoal(), "Scan the QR code or type the code below.", ); - fill_stroke_rect(&mut ops, 28.0, 122.0, 96.0, 11.0, cream(), muted()); - fill_rect(&mut ops, 28.0, 122.0, 2.0, 11.0, buzz_yellow()); + fill_stroke_rect(&mut ops, 28.0, 132.0, 96.0, 11.0, cream(), muted()); + fill_rect(&mut ops, 28.0, 132.0, 2.0, 11.0, buzz_yellow()); text( &mut ops, 32.0, - 126.0, + 136.0, 8.0, BuiltinFont::CourierBold, charcoal(), @@ -255,7 +210,7 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 28.0, - 111.0, + 116.0, 7.0, BuiltinFont::HelveticaBold, muted(), @@ -265,7 +220,7 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 28.0, - 104.5, + 109.5, 7.5, BuiltinFont::Courier, charcoal(), @@ -274,7 +229,7 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 28.0, - 99.5, + 104.5, 7.5, BuiltinFont::Courier, charcoal(), @@ -291,16 +246,6 @@ pub(crate) fn render_recovery_sheet( ); draw_qr(&mut ops, recovery_secret)?; - text( - &mut ops, - 143.0, - 96.0, - 7.0, - BuiltinFont::Helvetica, - muted(), - "SCAN TO COPY THE RECOVERY CODE", - ); - text( &mut ops, 18.0, @@ -348,26 +293,6 @@ pub(crate) fn render_recovery_sheet( ); } - fill_stroke_rect(&mut ops, 18.0, 14.0, 179.9, 17.0, white(), charcoal()); - fill_rect(&mut ops, 18.0, 14.0, 3.0, 17.0, buzz_yellow()); - text( - &mut ops, - 24.0, - 24.0, - 9.0, - BuiltinFont::HelveticaBold, - charcoal(), - "KEEP THIS PAGE SEPARATE FROM YOUR BACKUP FILE.", - ); - text( - &mut ops, - 24.0, - 18.5, - 8.0, - BuiltinFont::Helvetica, - muted(), - "Buzz cannot replace this recovery code if you lose it.", - ); text( &mut ops, 18.0, From 81346051b1209f50a3a7624c71f23700d6bb90c4 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:11:15 -0700 Subject: [PATCH 5/9] Refine recovery sheet hierarchy Signed-off-by: Jordan Mecom --- desktop/src-tauri/src/recovery_sheet.rs | 131 ++++++++++++------------ 1 file changed, 64 insertions(+), 67 deletions(-) diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs index cca3202ee5d..d5ee6ee433c 100644 --- a/desktop/src-tauri/src/recovery_sheet.rs +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -31,6 +31,10 @@ fn muted() -> Color { Color::Rgb(Rgb::new(100.0 / 255.0, 94.0 / 255.0, 83.0 / 255.0, None)) } +fn light_rule() -> Color { + Color::Rgb(Rgb::new(211.0 / 255.0, 209.0 / 255.0, 198.0 / 255.0, None)) +} + fn white() -> Color { Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)) } @@ -53,23 +57,6 @@ fn fill_rect(ops: &mut Vec, x: f32, y: f32, width: f32, height: f32, color: }); } -fn fill_stroke_rect( - ops: &mut Vec, - x: f32, - y: f32, - width: f32, - height: f32, - fill: Color, - stroke: Color, -) { - ops.push(Op::SetFillColor { col: fill }); - ops.push(Op::SetOutlineColor { col: stroke }); - ops.push(Op::SetOutlineThickness { pt: Pt(0.8) }); - ops.push(Op::DrawRectangle { - rectangle: rect(x, y, width, height, PaintMode::FillStroke), - }); -} - fn text( ops: &mut Vec, x: f32, @@ -97,14 +84,13 @@ fn text( } fn draw_dot_grid(ops: &mut Vec) { - let dot = Color::Rgb(Rgb::new(211.0 / 255.0, 209.0 / 255.0, 198.0 / 255.0, None)); - let mut y = 215.0; + let mut y = 219.0; let mut row = 0usize; while y < 274.0 { let offset = if row.is_multiple_of(2) { 0.0 } else { 3.5 }; - let mut x = 136.0 + offset; + let mut x = 143.0 + offset; while x < 211.0 { - fill_rect(ops, x, y, 0.4, 0.4, dot.clone()); + fill_rect(ops, x, y, 0.4, 0.4, light_rule()); x += 7.0; } y += 7.0; @@ -119,9 +105,17 @@ fn draw_qr(ops: &mut Vec, recovery_secret: &str) -> Result<(), String> { let total_modules = code.width() + quiet_modules * 2; let qr_size_mm = 56.0f32; let module_mm = qr_size_mm / total_modules as f32; - let qr_x = 136.0f32; - let qr_y = 105.0f32; - + let qr_x = 140.0f32; + let qr_y = 116.0f32; + + fill_rect( + ops, + qr_x + 3.0, + qr_y - 3.0, + qr_size_mm, + qr_size_mm, + buzz_yellow(), + ); fill_rect(ops, qr_x, qr_y, qr_size_mm, qr_size_mm, white()); for y in 0..code.width() { for x in 0..code.width() { @@ -155,7 +149,7 @@ pub(crate) fn render_recovery_sheet( let mut ops = Vec::new(); fill_rect(&mut ops, 0.0, 0.0, PAGE_WIDTH_MM, PAGE_HEIGHT_MM, white()); - fill_rect(&mut ops, 0.0, 276.4, PAGE_WIDTH_MM, 3.0, buzz_yellow()); + fill_rect(&mut ops, 0.0, 278.2, PAGE_WIDTH_MM, 1.2, buzz_yellow()); draw_dot_grid(&mut ops); let mut warnings = Vec::new(); @@ -166,9 +160,9 @@ pub(crate) fn render_recovery_sheet( id: wordmark_id, transform: XObjectTransform { translate_x: Some(Mm(18.0).into()), - translate_y: Some(Mm(242.0).into()), - scale_x: Some(0.82), - scale_y: Some(0.82), + translate_y: Some(Mm(245.0).into()), + scale_x: Some(0.72), + scale_y: Some(0.72), dpi: Some(300.0), ..Default::default() }, @@ -177,31 +171,32 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 18.0, - 224.0, - 27.0, + 220.0, + 30.0, BuiltinFont::HelveticaBold, charcoal(), "Your Buzz recovery code.", ); - fill_rect(&mut ops, 18.0, 204.0, 24.0, 2.0, buzz_yellow()); + fill_rect(&mut ops, 18.0, 204.0, 34.0, 1.5, buzz_yellow()); - fill_stroke_rect(&mut ops, 18.0, 78.0, 179.9, 104.0, white(), charcoal()); + fill_rect(&mut ops, 18.0, 184.0, 179.9, 0.45, charcoal()); + fill_rect(&mut ops, 18.0, 96.0, 179.9, 0.45, light_rule()); text( &mut ops, - 28.0, - 151.0, - 12.0, - BuiltinFont::Helvetica, + 18.0, + 172.0, + 13.0, + BuiltinFont::HelveticaBold, charcoal(), - "Scan the QR code or type the code below.", + "Scan or type the code.", ); - fill_stroke_rect(&mut ops, 28.0, 132.0, 96.0, 11.0, cream(), muted()); - fill_rect(&mut ops, 28.0, 132.0, 2.0, 11.0, buzz_yellow()); + fill_rect(&mut ops, 18.0, 149.0, 110.0, 16.0, cream()); + fill_rect(&mut ops, 18.0, 149.0, 3.0, 16.0, buzz_yellow()); text( &mut ops, - 32.0, - 136.0, - 8.0, + 23.0, + 155.0, + 8.5, BuiltinFont::CourierBold, charcoal(), recovery_secret, @@ -209,8 +204,8 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, - 28.0, - 116.0, + 18.0, + 136.0, 7.0, BuiltinFont::HelveticaBold, muted(), @@ -219,8 +214,8 @@ pub(crate) fn render_recovery_sheet( let (npub_first, npub_second) = split_npub(npub); text( &mut ops, - 28.0, - 109.5, + 18.0, + 129.5, 7.5, BuiltinFont::Courier, charcoal(), @@ -228,8 +223,8 @@ pub(crate) fn render_recovery_sheet( ); text( &mut ops, - 28.0, - 104.5, + 18.0, + 124.5, 7.5, BuiltinFont::Courier, charcoal(), @@ -237,8 +232,8 @@ pub(crate) fn render_recovery_sheet( ); text( &mut ops, - 28.0, - 89.0, + 18.0, + 106.0, 7.0, BuiltinFont::HelveticaBold, muted(), @@ -249,43 +244,45 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 18.0, - 67.5, + 82.0, 8.0, BuiltinFont::HelveticaBold, muted(), - "TO RECOVER YOUR IDENTITY, YOU NEED", + "TO RECOVER YOUR IDENTITY", ); let requirements = [ - ("1", "Backup file", "identity.buzzbackup"), - ("2", "Password", "The password you chose"), - ("3", "Recovery code", "Printed on this page"), + ("01", "Backup file", "identity.buzzbackup"), + ("02", "Password", "The password you chose"), + ("03", "Recovery code", "Printed on this page"), ]; for (index, (number, title, detail)) in requirements.iter().enumerate() { let x = 18.0 + index as f32 * 61.0; - fill_stroke_rect(&mut ops, x, 39.0, 57.9, 23.0, white(), charcoal()); - fill_rect(&mut ops, x, 54.0, 8.0, 8.0, buzz_yellow()); + if index > 0 { + fill_rect(&mut ops, x - 3.0, 46.0, 0.35, 29.0, light_rule()); + } text( &mut ops, - x + 2.55, - 56.2, - 8.0, + x, + 68.0, + 18.0, BuiltinFont::HelveticaBold, charcoal(), *number, ); + fill_rect(&mut ops, x, 62.0, 13.0, 1.5, buzz_yellow()); text( &mut ops, - x + 4.0, - 48.0, - 9.0, + x, + 54.0, + 10.0, BuiltinFont::HelveticaBold, charcoal(), *title, ); text( &mut ops, - x + 4.0, - 42.5, + x, + 47.5, 7.0, BuiltinFont::Helvetica, muted(), @@ -296,11 +293,11 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 18.0, - 6.0, + 15.0, 7.0, BuiltinFont::HelveticaBold, muted(), - "buzz.xyz / recovery sheet v1", + "buzz.xyz", ); let page = PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), ops); From 3ac2bb7b98d638d0d7a288f5c0812713c1d86f44 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:12:58 -0700 Subject: [PATCH 6/9] Use plain recovery requirement copy Signed-off-by: Jordan Mecom --- desktop/src-tauri/src/recovery_sheet.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs index d5ee6ee433c..b0a9d045243 100644 --- a/desktop/src-tauri/src/recovery_sheet.rs +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -244,11 +244,11 @@ pub(crate) fn render_recovery_sheet( text( &mut ops, 18.0, - 82.0, - 8.0, - BuiltinFont::HelveticaBold, - muted(), - "TO RECOVER YOUR IDENTITY", + 80.0, + 11.0, + BuiltinFont::Helvetica, + charcoal(), + "You'll need all three:", ); let requirements = [ ("01", "Backup file", "identity.buzzbackup"), From 68e4ac35c363b6d63d193e36c375d8c0107e7f5e Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:26:24 -0700 Subject: [PATCH 7/9] Clarify recovery sheet instructions Signed-off-by: Jordan Mecom --- desktop/src-tauri/src/recovery_sheet.rs | 70 ++++++++++--------------- 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs index b0a9d045243..c36884aa569 100644 --- a/desktop/src-tauri/src/recovery_sheet.rs +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -241,54 +241,35 @@ pub(crate) fn render_recovery_sheet( ); draw_qr(&mut ops, recovery_secret)?; + fill_rect(&mut ops, 18.0, 45.0, 179.9, 36.0, cream()); + fill_rect(&mut ops, 18.0, 45.0, 3.0, 36.0, buzz_yellow()); text( &mut ops, - 18.0, - 80.0, - 11.0, + 25.0, + 70.0, + 10.0, BuiltinFont::Helvetica, charcoal(), - "You'll need all three:", + "If you need to get back into Buzz, choose \"Restore from a backup file.\"", + ); + text( + &mut ops, + 25.0, + 60.0, + 10.0, + BuiltinFont::Helvetica, + charcoal(), + "Select identity.buzzbackup. Buzz saved it when you made this page.", + ); + text( + &mut ops, + 25.0, + 50.0, + 10.0, + BuiltinFont::Helvetica, + charcoal(), + "Enter your password, then scan or type the recovery code above.", ); - let requirements = [ - ("01", "Backup file", "identity.buzzbackup"), - ("02", "Password", "The password you chose"), - ("03", "Recovery code", "Printed on this page"), - ]; - for (index, (number, title, detail)) in requirements.iter().enumerate() { - let x = 18.0 + index as f32 * 61.0; - if index > 0 { - fill_rect(&mut ops, x - 3.0, 46.0, 0.35, 29.0, light_rule()); - } - text( - &mut ops, - x, - 68.0, - 18.0, - BuiltinFont::HelveticaBold, - charcoal(), - *number, - ); - fill_rect(&mut ops, x, 62.0, 13.0, 1.5, buzz_yellow()); - text( - &mut ops, - x, - 54.0, - 10.0, - BuiltinFont::HelveticaBold, - charcoal(), - *title, - ); - text( - &mut ops, - x, - 47.5, - 7.0, - BuiltinFont::Helvetica, - muted(), - *detail, - ); - } text( &mut ops, @@ -339,6 +320,9 @@ mod tests { assert_eq!(parsed.pages.len(), 1); assert!(text.contains(RECOVERY_CODE)); assert!(text.contains("Your Buzz recovery code.")); + assert!(text.contains("Restore from a backup file.")); + assert!(text.contains("identity.buzzbackup")); + assert!(text.contains("Enter your password")); assert!(text.contains(NPUB)); assert!(!text.contains("buzz2skd1:")); assert!(!text.contains("password=")); From eb448fed73772b853ae86b0a4b7b3993d12b0088 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:28:02 -0700 Subject: [PATCH 8/9] Explain cross-device recovery Signed-off-by: Jordan Mecom --- desktop/src-tauri/src/recovery_sheet.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs index c36884aa569..b1754b3beac 100644 --- a/desktop/src-tauri/src/recovery_sheet.rs +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -250,7 +250,7 @@ pub(crate) fn render_recovery_sheet( 10.0, BuiltinFont::Helvetica, charcoal(), - "If you need to get back into Buzz, choose \"Restore from a backup file.\"", + "On the computer where you want to use Buzz, choose \"Restore from a backup file.\"", ); text( &mut ops, @@ -259,7 +259,7 @@ pub(crate) fn render_recovery_sheet( 10.0, BuiltinFont::Helvetica, charcoal(), - "Select identity.buzzbackup. Buzz saved it when you made this page.", + "Copy identity.buzzbackup from wherever you saved it to this computer, then select it.", ); text( &mut ops, @@ -321,7 +321,7 @@ mod tests { assert!(text.contains(RECOVERY_CODE)); assert!(text.contains("Your Buzz recovery code.")); assert!(text.contains("Restore from a backup file.")); - assert!(text.contains("identity.buzzbackup")); + assert!(text.contains("Copy identity.buzzbackup")); assert!(text.contains("Enter your password")); assert!(text.contains(NPUB)); assert!(!text.contains("buzz2skd1:")); From fa769b7ed3697c8944d2cc8db2e4607004f9636d Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 21 Aug 2026 20:31:17 -0700 Subject: [PATCH 9/9] Explain off-device recovery storage Signed-off-by: Jordan Mecom --- desktop/src-tauri/src/recovery_sheet.rs | 31 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/recovery_sheet.rs b/desktop/src-tauri/src/recovery_sheet.rs index b1754b3beac..bc4eb4e4ee9 100644 --- a/desktop/src-tauri/src/recovery_sheet.rs +++ b/desktop/src-tauri/src/recovery_sheet.rs @@ -241,34 +241,43 @@ pub(crate) fn render_recovery_sheet( ); draw_qr(&mut ops, recovery_secret)?; - fill_rect(&mut ops, 18.0, 45.0, 179.9, 36.0, cream()); - fill_rect(&mut ops, 18.0, 45.0, 3.0, 36.0, buzz_yellow()); + fill_rect(&mut ops, 18.0, 39.0, 179.9, 47.0, cream()); + fill_rect(&mut ops, 18.0, 39.0, 3.0, 47.0, buzz_yellow()); text( &mut ops, 25.0, - 70.0, + 76.0, 10.0, BuiltinFont::Helvetica, charcoal(), - "On the computer where you want to use Buzz, choose \"Restore from a backup file.\"", + "Keep identity.buzzbackup somewhere you can reach if this computer is lost,", ); text( &mut ops, 25.0, - 60.0, + 66.0, 10.0, BuiltinFont::Helvetica, charcoal(), - "Copy identity.buzzbackup from wherever you saved it to this computer, then select it.", + "such as a USB drive or cloud storage.", ); text( &mut ops, 25.0, - 50.0, + 53.0, 10.0, BuiltinFont::Helvetica, charcoal(), - "Enter your password, then scan or type the recovery code above.", + "On a new computer, install Buzz and choose \"Restore from a backup file.\"", + ); + text( + &mut ops, + 25.0, + 43.0, + 10.0, + BuiltinFont::Helvetica, + charcoal(), + "Select identity.buzzbackup, enter your password, then use the code above.", ); text( @@ -320,9 +329,11 @@ mod tests { assert_eq!(parsed.pages.len(), 1); assert!(text.contains(RECOVERY_CODE)); assert!(text.contains("Your Buzz recovery code.")); + assert!(text.contains("if this computer is lost")); + assert!(text.contains("USB drive or cloud storage")); + assert!(text.contains("On a new computer")); assert!(text.contains("Restore from a backup file.")); - assert!(text.contains("Copy identity.buzzbackup")); - assert!(text.contains("Enter your password")); + assert!(text.contains("Select identity.buzzbackup")); assert!(text.contains(NPUB)); assert!(!text.contains("buzz2skd1:")); assert!(!text.contains("password="));