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>
249 lines
8.2 KiB
Rust
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);
|
|
}
|
|
}
|