feat(daemon): recovery code generation and Argon2id hashing
Pure-logic module: generate 8-digit codes, Argon2id PHC hash with OS RNG salt, verify via constant-time PasswordVerifier. Dead-code allow until Task 4 wires the store on top. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ mod fido;
|
||||
mod lockout;
|
||||
mod policy_apply;
|
||||
mod polkit;
|
||||
mod recovery;
|
||||
mod state;
|
||||
mod storage;
|
||||
|
||||
|
||||
78
daemon/src/recovery.rs
Normal file
78
daemon/src/recovery.rs
Normal file
@@ -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<String, RecoveryError> {
|
||||
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<bool, RecoveryError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user