feat(daemon): RecoveryStore with atomic 0600 writes and one-shot verify-and-consume
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
247
daemon/src/storage/recovery.rs
Normal file
247
daemon/src/storage/recovery.rs
Normal file
@@ -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):
|
||||
//! <expires_unix>\n<argon2id-PHC-string>\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<PathBuf, RecoveryStoreError> {
|
||||
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<String, RecoveryStoreError> {
|
||||
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<Vec<RecoveryEntry>, 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<RecoveryEntry, RecoveryStoreError> {
|
||||
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<bool, RecoveryStoreError> {
|
||||
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<bool, RecoveryStoreError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user