Add two-secret identity recovery kits

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-08-21 19:13:41 -07:00
parent cd0d33f085
commit 230bf6fa7c
21 changed files with 1223 additions and 49 deletions

View File

@@ -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<crate::two_skd::CreatedBackup, String> {
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::<AppState>();
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<BackupVerification, String> {
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::<AppState>();
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<Option<String>, 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<String>,
recovery_secret: Option<String>,
app_handle: tauri::AppHandle,
) -> Result<IdentityInfo, String> {
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

View File

@@ -114,8 +114,24 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result<Keys, String> {
/// 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<Keys, String> {
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<Keys, String> {
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));

View File

@@ -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]

View File

@@ -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,

View File

@@ -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<u8> {
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<CreatedBackup, String> {
create_backup_with_cost(keys, passphrase, Argon2Cost::PRODUCTION)
}
pub(crate) fn create_backup_with_cost(
keys: &Keys,
passphrase: &str,
cost: Argon2Cost,
) -> Result<CreatedBackup, String> {
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<Keys, String> {
let payload = parse_backup(input)?;
let cost = payload.cost();
validate_cost(cost)?;
let salt = decode_array::<SALT_BYTES>(&payload.salt, "backup salt")?;
let nonce = decode_array::<NONCE_BYTES>(&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<BackupPayload, String> {
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::<SALT_BYTES>(&payload.salt, "backup salt")?;
decode_array::<NONCE_BYTES>(&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::<CIPHERTEXT_BYTES>(&payload.ciphertext, "backup ciphertext")?;
Ok(())
}
fn parse_recovery_secret(input: &str) -> Result<Zeroizing<[u8; RECOVERY_SECRET_BYTES]>, 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<const N: usize>(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<Zeroizing<[u8; KEY_BYTES]>, 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::<String>());
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::<Sha256>::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"
);
}
}