From 517b015996d6f26d2d85a5e6755c601585784168 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:32:40 -0700 Subject: [PATCH] feat(daemon): RecoveryStore with atomic 0600 writes and one-shot verify-and-consume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-line file format (expires_unix\nargon2id-PHC) keeps the C PAM module parser trivial — no json-c link needed. Atomic temp+rename means a concurrent reader never sees a half-written file. Co-Authored-By: Claude Opus 4.7 (1M context) --- daemon/src/storage/mod.rs | 1 + daemon/src/storage/recovery.rs | 247 +++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 daemon/src/storage/recovery.rs diff --git a/daemon/src/storage/mod.rs b/daemon/src/storage/mod.rs index 1d7d54b..8f189b0 100644 --- a/daemon/src/storage/mod.rs +++ b/daemon/src/storage/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod credentials; pub(crate) mod pending; pub(crate) mod policy; +pub(crate) mod recovery; pub(crate) mod safe_user; pub(crate) mod userdb; diff --git a/daemon/src/storage/recovery.rs b/daemon/src/storage/recovery.rs new file mode 100644 index 0000000..a5335cd --- /dev/null +++ b/daemon/src/storage/recovery.rs @@ -0,0 +1,247 @@ +//! 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); + } +}