mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
Add two-secret identity recovery kits
Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
410
desktop/src-tauri/src/two_skd.rs
Normal file
410
desktop/src-tauri/src/two_skd.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user