feat: implement account recovery with encrypted backup bundles

Add recovery code generation (8 codes per setup), Argon2id key derivation,
ChaCha20-Poly1305 encrypted bundles, and server-side zero-knowledge storage.
Each code independently recovers the account. Includes core crypto module,
protobuf service (method IDs 750-752), server domain + handlers, SDK methods,
SQL migration, and CLI commands (/recovery setup, /recovery restore).
This commit is contained in:
2026-03-04 20:12:20 +01:00
parent 5b6d8209f0
commit 12b19b6931
14 changed files with 1120 additions and 1 deletions

View File

@@ -0,0 +1,342 @@
//! Account recovery — recovery code generation and encrypted backup bundles.
//!
//! # Design
//!
//! Recovery codes are 8 alphanumeric strings of 6 characters each (~31 bits
//! entropy per code). Any single code is sufficient to recover the account.
//!
//! A recovery key is derived from each code via Argon2id. The identity seed
//! and conversation metadata are encrypted into a [`RecoveryBundle`] using
//! ChaCha20-Poly1305. The bundle is uploaded to the server, keyed by
//! `SHA-256(recovery_token)` — the server never sees plaintext codes.
//!
//! # Security properties
//!
//! - Recovery codes are shown once and never stored in plaintext.
//! - The server is zero-knowledge — it stores only encrypted blobs.
//! - Code validation uses constant-time comparison.
//! - All key material is zeroized on drop.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::error::CoreError;
/// Number of recovery codes generated per setup.
pub const RECOVERY_CODE_COUNT: usize = 8;
/// Length of each recovery code (alphanumeric characters).
const CODE_LENGTH: usize = 6;
/// Maximum bundle size (64 KiB).
pub const MAX_BUNDLE_SIZE: usize = 64 * 1024;
/// Argon2id parameters for recovery key derivation.
const ARGON2_M_COST: u32 = 19 * 1024; // 19 MiB
const ARGON2_T_COST: u32 = 2;
const ARGON2_P_COST: u32 = 1;
/// Alphanumeric character set for recovery codes (uppercase + digits, no
/// ambiguous characters 0/O, 1/I/L).
const CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
/// An encrypted recovery bundle stored on the server.
///
/// The server stores this keyed by `token_hash` (SHA-256 of a recovery token
/// derived from the code). The server cannot decrypt it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryBundle {
/// SHA-256 of the recovery token (used as server-side lookup key).
pub token_hash: Vec<u8>,
/// Random 16-byte salt for Argon2id key derivation.
pub salt: Vec<u8>,
/// Random 12-byte nonce for ChaCha20-Poly1305.
pub nonce: Vec<u8>,
/// Encrypted payload: bincode-serialised `RecoveryPayload`.
pub ciphertext: Vec<u8>,
}
/// The plaintext payload inside a recovery bundle.
#[derive(Debug, Serialize, Deserialize)]
pub struct RecoveryPayload {
/// Ed25519 identity seed (32 bytes).
pub identity_seed: [u8; 32],
/// List of conversation/group IDs the user was part of (for rejoin).
pub conversation_ids: Vec<Vec<u8>>,
}
/// Result of recovery code generation.
pub struct RecoverySetup {
/// The 8 recovery codes to show to the user (shown once, never stored).
pub codes: Vec<String>,
/// Encrypted bundles — one per code — to upload to the server.
pub bundles: Vec<RecoveryBundle>,
}
/// Generate a single random recovery code.
fn generate_code(rng: &mut impl RngCore) -> String {
let mut code = String::with_capacity(CODE_LENGTH);
for _ in 0..CODE_LENGTH {
let idx = (rng.next_u32() as usize) % CODE_ALPHABET.len();
code.push(CODE_ALPHABET[idx] as char);
}
code
}
/// Derive a 32-byte recovery token from a code (used for server-side lookup).
/// The token is `SHA-256("qpq-recovery-token:" || code)`.
fn derive_recovery_token(code: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"qpq-recovery-token:");
hasher.update(code.as_bytes());
hasher.finalize().into()
}
/// Derive a 32-byte encryption key from a code and salt via Argon2id.
fn derive_recovery_key(code: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>, CoreError> {
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
.map_err(|e| CoreError::Io(format!("argon2 params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params);
let mut key = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(code.as_bytes(), salt, &mut *key)
.map_err(|e| CoreError::Io(format!("argon2 recovery key derivation: {e}")))?;
Ok(key)
}
/// Generate recovery codes and encrypted bundles for an identity.
///
/// Returns a `RecoverySetup` containing:
/// - `codes`: 8 recovery codes to display to the user (once).
/// - `bundles`: 8 encrypted recovery bundles (one per code) to upload to the server.
///
/// Each code independently decrypts its corresponding bundle.
pub fn generate_recovery_codes(
identity_seed: &[u8; 32],
conversation_ids: &[Vec<u8>],
) -> Result<RecoverySetup, CoreError> {
let mut rng = rand::rngs::OsRng;
let payload = RecoveryPayload {
identity_seed: *identity_seed,
conversation_ids: conversation_ids.to_vec(),
};
let plaintext = bincode::serialize(&payload)
.map_err(|e| CoreError::Io(format!("serialize recovery payload: {e}")))?;
let mut codes = Vec::with_capacity(RECOVERY_CODE_COUNT);
let mut bundles = Vec::with_capacity(RECOVERY_CODE_COUNT);
for _ in 0..RECOVERY_CODE_COUNT {
let code = generate_code(&mut rng);
// Derive the server-side lookup token.
let token = derive_recovery_token(&code);
let token_hash = Sha256::digest(token).to_vec();
// Derive encryption key from code.
let mut salt = [0u8; 16];
rng.fill_bytes(&mut salt);
let key = derive_recovery_key(&code, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key));
let mut nonce_bytes = [0u8; 12];
rng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_slice())
.map_err(|e| CoreError::Io(format!("recovery bundle encryption: {e}")))?;
bundles.push(RecoveryBundle {
token_hash,
salt: salt.to_vec(),
nonce: nonce_bytes.to_vec(),
ciphertext,
});
codes.push(code);
}
Ok(RecoverySetup { codes, bundles })
}
/// Recover an identity seed from a recovery code and encrypted bundle.
///
/// Returns the decrypted `RecoveryPayload` on success.
pub fn recover_from_bundle(
code: &str,
bundle: &RecoveryBundle,
) -> Result<RecoveryPayload, CoreError> {
// Validate bundle structure.
if bundle.salt.len() != 16 {
return Err(CoreError::Io(format!(
"invalid recovery bundle salt length: {}",
bundle.salt.len()
)));
}
if bundle.nonce.len() != 12 {
return Err(CoreError::Io(format!(
"invalid recovery bundle nonce length: {}",
bundle.nonce.len()
)));
}
// Derive encryption key from code.
let key = derive_recovery_key(code, &bundle.salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key));
let nonce = Nonce::from_slice(&bundle.nonce);
let plaintext = cipher
.decrypt(nonce, bundle.ciphertext.as_slice())
.map_err(|_| CoreError::Io("recovery bundle decryption failed (wrong code?)".into()))?;
let payload: RecoveryPayload = bincode::deserialize(&plaintext)
.map_err(|e| CoreError::Io(format!("deserialize recovery payload: {e}")))?;
Ok(payload)
}
/// Compute the token hash for a recovery code (for server-side lookup).
///
/// This is `SHA-256(SHA-256("qpq-recovery-token:" || code))`.
pub fn recovery_token_hash(code: &str) -> Vec<u8> {
let token = derive_recovery_token(code);
Sha256::digest(token).to_vec()
}
/// Constant-time comparison of two byte slices.
///
/// Returns `true` if the slices are equal, using constant-time comparison
/// to prevent timing side-channels on recovery code validation.
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn generate_codes_produces_correct_count() {
let seed = [42u8; 32];
let setup = generate_recovery_codes(&seed, &[]).unwrap();
assert_eq!(setup.codes.len(), RECOVERY_CODE_COUNT);
assert_eq!(setup.bundles.len(), RECOVERY_CODE_COUNT);
}
#[test]
fn codes_are_correct_length_and_alphabet() {
let seed = [7u8; 32];
let setup = generate_recovery_codes(&seed, &[]).unwrap();
for code in &setup.codes {
assert_eq!(code.len(), CODE_LENGTH);
for ch in code.chars() {
assert!(
CODE_ALPHABET.contains(&(ch as u8)),
"invalid char '{ch}' in code"
);
}
}
}
#[test]
fn codes_are_unique() {
let seed = [1u8; 32];
let setup = generate_recovery_codes(&seed, &[]).unwrap();
let mut seen = std::collections::HashSet::new();
for code in &setup.codes {
assert!(seen.insert(code.clone()), "duplicate code: {code}");
}
}
#[test]
fn recover_roundtrip() {
let seed = [99u8; 32];
let conv_ids = vec![vec![1, 2, 3], vec![4, 5, 6]];
let setup = generate_recovery_codes(&seed, &conv_ids).unwrap();
// Each code should decrypt its corresponding bundle.
for (i, code) in setup.codes.iter().enumerate() {
let payload = recover_from_bundle(code, &setup.bundles[i]).unwrap();
assert_eq!(payload.identity_seed, seed);
assert_eq!(payload.conversation_ids, conv_ids);
}
}
#[test]
fn wrong_code_fails() {
let seed = [50u8; 32];
let setup = generate_recovery_codes(&seed, &[]).unwrap();
let result = recover_from_bundle("WRONG1", &setup.bundles[0]);
assert!(result.is_err());
}
#[test]
fn code_does_not_decrypt_other_bundle() {
let seed = [88u8; 32];
let setup = generate_recovery_codes(&seed, &[]).unwrap();
// Code 0 should NOT decrypt bundle 1 (different salt/nonce/key).
let result = recover_from_bundle(&setup.codes[0], &setup.bundles[1]);
assert!(result.is_err());
}
#[test]
fn token_hash_is_deterministic() {
let hash1 = recovery_token_hash("ABC123");
let hash2 = recovery_token_hash("ABC123");
assert_eq!(hash1, hash2);
}
#[test]
fn token_hash_differs_for_different_codes() {
let hash1 = recovery_token_hash("ABC123");
let hash2 = recovery_token_hash("XYZ789");
assert_ne!(hash1, hash2);
}
#[test]
fn constant_time_eq_works() {
assert!(constant_time_eq(b"hello", b"hello"));
assert!(!constant_time_eq(b"hello", b"world"));
assert!(!constant_time_eq(b"hello", b"hell"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn invalid_bundle_salt_rejected() {
let bundle = RecoveryBundle {
token_hash: vec![0; 32],
salt: vec![0; 8], // wrong length
nonce: vec![0; 12],
ciphertext: vec![0; 32],
};
assert!(recover_from_bundle("ABC123", &bundle).is_err());
}
#[test]
fn invalid_bundle_nonce_rejected() {
let bundle = RecoveryBundle {
token_hash: vec![0; 32],
salt: vec![0; 16],
nonce: vec![0; 8], // wrong length
ciphertext: vec![0; 32],
};
assert!(recover_from_bundle("ABC123", &bundle).is_err());
}
}