Files
authforge/daemon/src/storage/recovery.rs
michael a420aa5f75 feat(daemon): real GenerateRecoveryCode + ListRecoveryCodes + RevokeRecoveryCode
Replace the random-number stub at dbus.rs:132 with state.issue_recovery,
add ListRecoveryCodes (returns Vec<RecoveryCodeSummary>) and RevokeRecoveryCode.
New polkit actions list-recovery / revoke-recovery (auth_admin_keep). Adds
4 D-Bus integration tests; the previously-stub generate-code test now
exercises the real Argon2id-backed write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:39:16 -07:00

249 lines
8.2 KiB
Rust

//! 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);
}
}