//! On-disk persistence for recovery codes. One file per user under //! `cfg.recovery_dir`, mode 0600, root-owned. //! //! File format (two lines, UTF-8, trailing LFs): //! \n\n //! Picked over JSON so the C PAM module can parse it without linking json-c. #![allow(dead_code)] // wired through AppState in Task 5. use crate::recovery::{hash_code, verify_code}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; #[derive(Debug, Error)] pub(crate) enum RecoveryStoreError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("invalid username: {0:?}")] InvalidUser(String), #[error("malformed recovery file for {0:?}")] Malformed(String), #[error("hash error: {0}")] Hash(String), #[error("system clock before unix epoch")] Clock, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RecoveryEntry { pub user: String, pub expires_unix: u64, } pub(crate) struct RecoveryStore { dir: PathBuf, } impl RecoveryStore { pub fn new(dir: PathBuf) -> Self { Self { dir } } fn user_path(&self, user: &str) -> Result { super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e { super::safe_user::SegmentError::Invalid(s) => RecoveryStoreError::InvalidUser(s), }) } /// Generate, hash, and persist a fresh code for `user`. The file is /// written atomically (temp + rename) so a concurrent reader never sees /// a half-written file. Mode is set to 0600 before rename. Returns the /// plaintext code so the caller can show it to the admin once. pub fn issue(&self, user: &str, ttl_secs: u64) -> Result { fs::create_dir_all(&self.dir)?; // Tighten directory mode so a non-root snoop can't list users. fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?; let path = self.user_path(user)?; let code = crate::recovery::generate_code(); let phc = hash_code(&code).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?; let expires = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| RecoveryStoreError::Clock)? .as_secs() .saturating_add(ttl_secs); let body = format!("{expires}\n{phc}\n"); let tmp = path.with_extension("tmp"); fs::write(&tmp, body.as_bytes())?; fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?; fs::rename(&tmp, &path)?; Ok(code) } /// List all users with a recovery code on file (expired or not). Sorted /// alphabetically for stable display. pub fn list(&self) -> Result, RecoveryStoreError> { let mut out = Vec::new(); let rd = match fs::read_dir(&self.dir) { Ok(r) => r, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), Err(e) => return Err(e.into()), }; for entry in rd { let entry = entry?; if !entry.file_type()?.is_file() { continue; } let user = entry.file_name().to_string_lossy().to_string(); // Skip atomic-rename leftovers. if user.ends_with(".tmp") { continue; } if let Ok(e) = self.entry_for(&user) { out.push(e); } } out.sort_by(|a, b| a.user.cmp(&b.user)); Ok(out) } pub fn entry_for(&self, user: &str) -> Result { let path = self.user_path(user)?; let body = fs::read_to_string(&path)?; let mut lines = body.lines(); let expires: u64 = lines .next() .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))? .parse() .map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?; Ok(RecoveryEntry { user: user.to_string(), expires_unix: expires, }) } /// Delete the recovery file for `user`. Returns true if a file was /// removed, false if there was nothing there. pub fn revoke(&self, user: &str) -> Result { let path = self.user_path(user)?; match fs::remove_file(path) { Ok(()) => Ok(true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(e.into()), } } /// Verify a candidate code for `user`. On match: deletes the recovery /// file (one-shot semantics) and returns Ok(true). On expired: deletes /// the file as a side-effect cleanup and returns Ok(false). pub fn verify_and_consume( &self, user: &str, candidate: &str, ) -> Result { let path = self.user_path(user)?; let body = match fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(e) => return Err(e.into()), }; let mut lines = body.lines(); let expires: u64 = lines .next() .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))? .parse() .map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?; let phc = lines .next() .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| RecoveryStoreError::Clock)? .as_secs(); if now > expires { let _ = fs::remove_file(&path); return Ok(false); } let ok = verify_code(candidate, phc).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?; if ok { let _ = fs::remove_file(&path); } Ok(ok) } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn store() -> (tempfile::TempDir, RecoveryStore) { let d = tempdir().unwrap(); let s = RecoveryStore::new(d.path().to_path_buf()); (d, s) } #[test] fn issue_then_verify_consumes_file() { let (_d, s) = store(); let code = s.issue("alice", 600).unwrap(); assert!(s.entry_for("alice").is_ok()); assert!(s.verify_and_consume("alice", &code).unwrap()); assert!(matches!( s.entry_for("alice"), Err(RecoveryStoreError::Io(_)) )); } #[test] fn verify_with_wrong_code_does_not_consume() { let (_d, s) = store(); s.issue("alice", 600).unwrap(); assert!(!s.verify_and_consume("alice", "00000000").unwrap()); assert!(s.entry_for("alice").is_ok()); } #[test] fn expired_entry_is_treated_as_absent_and_cleaned_up() { let (_d, s) = store(); s.issue("alice", 0).unwrap(); std::thread::sleep(std::time::Duration::from_millis(1100)); assert!(!s.verify_and_consume("alice", "anything").unwrap()); assert!(matches!( s.entry_for("alice"), Err(RecoveryStoreError::Io(_)) )); } #[test] fn revoke_returns_false_on_missing_user() { let (_d, s) = store(); assert!(!s.revoke("ghost").unwrap()); } #[test] fn list_sorts_users_alphabetically() { let (_d, s) = store(); s.issue("charlie", 600).unwrap(); s.issue("alice", 600).unwrap(); s.issue("bob", 600).unwrap(); let users: Vec<_> = s.list().unwrap().into_iter().map(|e| e.user).collect(); assert_eq!(users, vec!["alice", "bob", "charlie"]); } #[test] fn rejects_traversal_usernames() { let (_d, s) = store(); for evil in ["", ".", "..", "a/b", "x\0y"] { assert!(s.issue(evil, 600).is_err()); } } #[test] fn issue_writes_file_with_mode_0600() { let (_d, s) = store(); s.issue("alice", 600).unwrap(); let path = s.user_path("alice").unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); } }