use anyhow::{Context, Result}; use argon2::{Argon2, Params}; use chacha20poly1305::{ aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce, }; use rand::RngCore; use serde::{Deserialize, Serialize}; use totp_rs::{Algorithm, Secret, TOTP}; use zeroize::Zeroize; const ARGON2_M_COST: u32 = 65536; // 64 MiB const ARGON2_T_COST: u32 = 3; const ARGON2_P_COST: u32 = 4; const ARGON2_OUTPUT_LEN: usize = 32; const BACKUP_CODE_COUNT: usize = 8; const BACKUP_CODE_LEN: usize = 8; // 8 alphanumeric chars /// Encrypted TOTP data stored in user.json. The secret never touches disk in plaintext. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TotpData { /// Argon2id salt for KEK derivation (base64) pub kek_salt: String, /// Nonce for MEK encryption (base64) pub mek_nonce: String, /// MEK encrypted under KEK via ChaCha20-Poly1305 (base64) pub encrypted_mek: String, /// Nonce for TOTP secret encryption (base64) pub secret_nonce: String, /// TOTP secret encrypted under MEK via ChaCha20-Poly1305 (base64) pub encrypted_secret: String, /// Hashed backup codes (bcrypt), one-time use pub backup_codes: Vec, /// Recently used TOTP time steps for replay protection #[serde(default)] pub used_steps: Vec, } /// Result of setup: encrypted data for storage + plaintext for display (shown once). pub struct SetupResult { pub totp_data: TotpData, pub secret_base32: String, pub qr_svg: String, pub backup_codes: Vec, } /// Generate a TOTP setup. Returns encrypted data + display values. pub fn setup(password: &str) -> Result { // Generate the raw TOTP secret (20 bytes = 160 bits, standard for SHA1) let mut totp_secret = vec![0u8; 20]; rand::rngs::OsRng.fill_bytes(&mut totp_secret); // Generate MEK (Master Encryption Key) let mut mek = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut mek); // Derive KEK from password via Argon2id let mut kek_salt = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut kek_salt); let mut kek = derive_kek(password, &kek_salt)?; // Encrypt MEK under KEK let mut mek_nonce_bytes = [0u8; 12]; rand::rngs::OsRng.fill_bytes(&mut mek_nonce_bytes); let encrypted_mek = encrypt_chacha(&kek, &mek_nonce_bytes, &mek)?; // Encrypt TOTP secret under MEK let mut secret_nonce_bytes = [0u8; 12]; rand::rngs::OsRng.fill_bytes(&mut secret_nonce_bytes); let encrypted_secret = encrypt_chacha(&mek, &secret_nonce_bytes, &totp_secret)?; // Generate backup codes let (plaintext_codes, hashed_codes) = generate_backup_codes()?; // Encode the base32 secret for the authenticator app let secret_base32 = data_encoding::BASE32_NOPAD.encode(&totp_secret); // Generate QR code SVG let totp = TOTP::new( Algorithm::SHA1, 6, 1, // skew 30, Secret::Raw(totp_secret.clone()) .to_bytes() .map_err(|_| anyhow::anyhow!("Invalid TOTP secret"))?, Some("Archipelago".to_string()), "node".to_string(), ) .context("Failed to create TOTP")?; let otpauth_url = totp.get_url(); let qr_svg = generate_qr_svg(&otpauth_url)?; let totp_data = TotpData { kek_salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, kek_salt), mek_nonce: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, mek_nonce_bytes, ), encrypted_mek: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, &encrypted_mek, ), secret_nonce: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, secret_nonce_bytes, ), encrypted_secret: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, &encrypted_secret, ), backup_codes: hashed_codes, used_steps: Vec::new(), }; // Zeroize sensitive material mek.zeroize(); kek.zeroize(); totp_secret.zeroize(); Ok(SetupResult { totp_data, secret_base32, qr_svg, backup_codes: plaintext_codes, }) } /// Decrypt the TOTP secret from stored data using the user's password. /// Returns the raw secret bytes. pub fn decrypt_secret(data: &TotpData, password: &str) -> Result> { use base64::Engine; let b64 = &base64::engine::general_purpose::STANDARD; let kek_salt = b64 .decode(&data.kek_salt) .context("Invalid kek_salt base64")?; let mek_nonce = b64 .decode(&data.mek_nonce) .context("Invalid mek_nonce base64")?; let encrypted_mek = b64 .decode(&data.encrypted_mek) .context("Invalid encrypted_mek base64")?; let secret_nonce = b64 .decode(&data.secret_nonce) .context("Invalid secret_nonce base64")?; let encrypted_secret = b64 .decode(&data.encrypted_secret) .context("Invalid encrypted_secret base64")?; // Derive KEK let mut kek = derive_kek(password, &kek_salt)?; // Decrypt MEK let mut mek = decrypt_chacha(&kek, &mek_nonce, &encrypted_mek) .context("Failed to decrypt MEK — wrong password or corrupt data")?; // Decrypt TOTP secret let secret = decrypt_chacha(&mek, &secret_nonce, &encrypted_secret) .context("Failed to decrypt TOTP secret")?; kek.zeroize(); mek.zeroize(); Ok(secret) } /// Verify a TOTP code against the decrypted secret. Checks ±1 time step window. pub fn verify_code(secret: &[u8], code: &str, used_steps: &[i64]) -> Result> { let totp = TOTP::new( Algorithm::SHA1, 6, 1, 30, secret.to_vec(), None, String::new(), ) .context("Failed to create TOTP verifier")?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .context("System time error")? .as_secs(); // Check current step and ±1 for offset in [-1i64, 0, 1] { let time = (now as i64) + (offset * 30); if time < 0 { continue; } let step = time / 30; let expected = totp.generate(time as u64); // Constant-time comparison if constant_time_eq(code.as_bytes(), expected.as_bytes()) { // Replay protection if used_steps.contains(&step) { return Ok(None); // Code already used } return Ok(Some(step)); } } Ok(None) // No match } /// Verify a backup code against the stored bcrypt hashes. Returns the index if valid. pub fn verify_backup_code(hashed_codes: &[String], code: &str) -> Result> { let normalized = code.replace('-', "").to_uppercase(); for (i, hash) in hashed_codes.iter().enumerate() { if bcrypt::verify(&normalized, hash)? { return Ok(Some(i)); } } Ok(None) } /// Re-encrypt the MEK under a new password (for password change). pub fn rekey(data: &TotpData, old_password: &str, new_password: &str) -> Result { use base64::Engine; let b64 = &base64::engine::general_purpose::STANDARD; // Decrypt MEK with old password let old_kek_salt = b64.decode(&data.kek_salt)?; let old_mek_nonce = b64.decode(&data.mek_nonce)?; let old_encrypted_mek = b64.decode(&data.encrypted_mek)?; let mut old_kek = derive_kek(old_password, &old_kek_salt)?; let mut mek = decrypt_chacha(&old_kek, &old_mek_nonce, &old_encrypted_mek) .context("Failed to decrypt MEK with old password")?; // Re-encrypt MEK with new password let mut new_kek_salt = [0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut new_kek_salt); let mut new_kek = derive_kek(new_password, &new_kek_salt)?; let mut new_mek_nonce = [0u8; 12]; rand::rngs::OsRng.fill_bytes(&mut new_mek_nonce); let new_encrypted_mek = encrypt_chacha(&new_kek, &new_mek_nonce, &mek)?; old_kek.zeroize(); new_kek.zeroize(); mek.zeroize(); Ok(TotpData { kek_salt: b64.encode(new_kek_salt), mek_nonce: b64.encode(new_mek_nonce), encrypted_mek: b64.encode(&new_encrypted_mek), // TOTP secret ciphertext unchanged secret_nonce: data.secret_nonce.clone(), encrypted_secret: data.encrypted_secret.clone(), backup_codes: data.backup_codes.clone(), used_steps: data.used_steps.clone(), }) } // --- Internal helpers --- fn derive_kek(password: &str, salt: &[u8]) -> Result<[u8; 32]> { let params = Params::new( ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN), ) .map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?; let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); let mut kek = [0u8; 32]; argon2 .hash_password_into(password.as_bytes(), salt, &mut kek) .map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?; Ok(kek) } fn encrypt_chacha(key: &[u8; 32], nonce: &[u8], plaintext: &[u8]) -> Result> { let cipher = ChaCha20Poly1305::new(key.into()); let nonce = Nonce::from_slice(nonce); cipher .encrypt(nonce, plaintext) .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e)) } fn decrypt_chacha(key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> Result> { let key: &[u8; 32] = key .try_into() .map_err(|_| anyhow::anyhow!("Invalid key length"))?; let cipher = ChaCha20Poly1305::new(key.into()); let nonce = Nonce::from_slice(nonce); cipher .decrypt(nonce, ciphertext) .map_err(|e| anyhow::anyhow!("Decryption failed: {}", e)) } fn generate_backup_codes() -> Result<(Vec, Vec)> { let charset: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0/O/1/I ambiguity let mut plaintext = Vec::with_capacity(BACKUP_CODE_COUNT); let mut hashed = Vec::with_capacity(BACKUP_CODE_COUNT); for _ in 0..BACKUP_CODE_COUNT { let mut code = String::with_capacity(BACKUP_CODE_LEN); for _ in 0..BACKUP_CODE_LEN { let idx = (rand::random::() as usize) % charset.len(); code.push(charset[idx] as char); } let formatted = format!("{}-{}", &code[..4], &code[4..]); let hash = bcrypt::hash(&code, 10)?; plaintext.push(formatted); hashed.push(hash); } Ok((plaintext, hashed)) } fn generate_qr_svg(data: &str) -> Result { use qrcode::QrCode; let code = QrCode::new(data.as_bytes()).context("Failed to generate QR code")?; let svg = code .render::() .min_dimensions(200, 200) .quiet_zone(true) .build(); Ok(svg) } 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)] mod tests { use super::*; #[test] fn test_setup_and_verify() { let password = "TestPassword123!"; let result = setup(password).unwrap(); // Decrypt and verify a code let secret = decrypt_secret(&result.totp_data, password).unwrap(); let totp = TOTP::new( Algorithm::SHA1, 6, 1, 30, secret.clone(), None, String::new(), ) .unwrap(); let code = totp.generate_current().unwrap(); let step = verify_code(&secret, &code, &[]).unwrap(); assert!(step.is_some(), "Valid TOTP code should verify"); // Replay: same step should be rejected let used_step = step.unwrap(); let step2 = verify_code(&secret, &code, &[used_step]).unwrap(); assert!(step2.is_none(), "Replayed code should be rejected"); } #[test] fn test_wrong_password_fails() { let result = setup("CorrectPassword1!").unwrap(); let err = decrypt_secret(&result.totp_data, "WrongPassword1!"); assert!(err.is_err(), "Wrong password should fail decryption"); } #[test] fn test_rekey() { let old_pw = "OldPassword123!"; let new_pw = "NewPassword456!"; let result = setup(old_pw).unwrap(); // Get original secret let original_secret = decrypt_secret(&result.totp_data, old_pw).unwrap(); // Rekey let rekeyed = rekey(&result.totp_data, old_pw, new_pw).unwrap(); // Old password should fail assert!(decrypt_secret(&rekeyed, old_pw).is_err()); // New password should produce same secret let new_secret = decrypt_secret(&rekeyed, new_pw).unwrap(); assert_eq!(original_secret, new_secret); } #[test] fn test_backup_codes() { let result = setup("TestPassword123!").unwrap(); assert_eq!(result.backup_codes.len(), BACKUP_CODE_COUNT); // Verify a backup code works let code = &result.backup_codes[0]; let idx = verify_backup_code(&result.totp_data.backup_codes, code).unwrap(); assert_eq!(idx, Some(0)); // Invalid code should fail let bad = verify_backup_code(&result.totp_data.backup_codes, "ZZZZ-ZZZZ").unwrap(); assert!(bad.is_none()); } }