diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 7e9e723..bdb4397 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -7,6 +7,7 @@ mod fido; mod lockout; mod policy_apply; mod polkit; +mod recovery; mod state; mod storage; diff --git a/daemon/src/recovery.rs b/daemon/src/recovery.rs new file mode 100644 index 0000000..f5c3c54 --- /dev/null +++ b/daemon/src/recovery.rs @@ -0,0 +1,78 @@ +//! Pure recovery-code logic: generation, Argon2id hashing, verification. +//! No I/O — that lives in `storage::recovery::RecoveryStore`. + +#![allow(dead_code)] // wired through RecoveryStore (Task 4) and AppState (Task 5). + +use argon2::{ + password_hash::{rand_core::OsRng as PhcOsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use rand::Rng; + +/// Default validity window for a freshly generated recovery code. 7 days: +/// long enough for an admin to mail the code to a remote user, short enough +/// to limit blast radius if the file leaks. +pub(crate) const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum RecoveryError { + #[error("argon2: {0}")] + Argon2(String), +} + +/// Generate a fresh 8-digit recovery code. +pub(crate) fn generate_code() -> String { + let n: u32 = rand::rng().random_range(0..100_000_000); + format!("{n:08}") +} + +/// Hash a code with Argon2id, producing a PHC-format string. +pub(crate) fn hash_code(code: &str) -> Result { + let salt = SaltString::generate(&mut PhcOsRng); + let argon = Argon2::default(); + let phc = argon + .hash_password(code.as_bytes(), &salt) + .map_err(|e| RecoveryError::Argon2(e.to_string()))? + .to_string(); + Ok(phc) +} + +/// Constant-time verify a candidate code against a stored PHC hash. +pub(crate) fn verify_code(candidate: &str, phc: &str) -> Result { + let parsed = PasswordHash::new(phc).map_err(|e| RecoveryError::Argon2(e.to_string()))?; + Ok(Argon2::default() + .verify_password(candidate.as_bytes(), &parsed) + .is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_code_is_eight_digits() { + for _ in 0..50 { + let c = generate_code(); + assert_eq!(c.len(), 8); + assert!(c.chars().all(|ch| ch.is_ascii_digit())); + } + } + + #[test] + fn hash_roundtrips_correct_code() { + let phc = hash_code("12345678").unwrap(); + assert!(phc.starts_with("$argon2id$"), "phc was {phc}"); + assert!(verify_code("12345678", &phc).unwrap()); + } + + #[test] + fn verify_rejects_wrong_code() { + let phc = hash_code("12345678").unwrap(); + assert!(!verify_code("87654321", &phc).unwrap()); + } + + #[test] + fn verify_errors_on_malformed_phc() { + assert!(verify_code("12345678", "not a phc string").is_err()); + } +}