feat(daemon): TotpStore with atomic 0600 writes in pam_google_authenticator format

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-04-27 11:21:11 -07:00
parent e753a33ee4
commit c56ed0c9a1
2 changed files with 155 additions and 0 deletions

View File

@@ -3,4 +3,6 @@ pub(crate) mod pending;
pub(crate) mod policy;
pub(crate) mod recovery;
pub(crate) mod safe_user;
#[cfg(feature = "totp")]
pub(crate) mod totp;
pub(crate) mod userdb;

153
daemon/src/storage/totp.rs Normal file
View File

@@ -0,0 +1,153 @@
//! Per-user TOTP secret persistence. File at `<dir>/<user>` mode 0600,
//! root-owned. Format is pam_google_authenticator's expected layout:
//! <base32 secret>\n " TOTP_AUTH"\n
//! That's the minimum valid file; more options can be appended later.
#![allow(dead_code)] // wired through AppState in Task 4.
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum TotpStoreError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("invalid username: {0:?}")]
InvalidUser(String),
}
pub(crate) struct TotpStore {
dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TotpEnrollment {
pub user: String,
pub secret_b32: String,
pub otpauth_uri: String,
}
impl TotpStore {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
fn user_path(&self, user: &str) -> Result<PathBuf, TotpStoreError> {
super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e {
super::safe_user::SegmentError::Invalid(s) => TotpStoreError::InvalidUser(s),
})
}
/// Generate a fresh secret, write the pam_google_authenticator file
/// atomically, return the enrollment payload.
pub fn enroll(&self, user: &str, issuer: &str) -> Result<TotpEnrollment, TotpStoreError> {
fs::create_dir_all(&self.dir)?;
fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o755))?;
let path = self.user_path(user)?;
let secret = crate::totp::generate_secret();
let secret_b32 = crate::totp::encode_secret(&secret);
let body = format!("{secret_b32}\n\" TOTP_AUTH\"\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)?;
let uri = crate::totp::otpauth_uri(&secret_b32, user, issuer);
Ok(TotpEnrollment {
user: user.to_string(),
secret_b32,
otpauth_uri: uri,
})
}
pub fn is_enrolled(&self, user: &str) -> Result<bool, TotpStoreError> {
let path = self.user_path(user)?;
match fs::metadata(&path) {
Ok(m) => Ok(m.is_file()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.into()),
}
}
pub fn revoke(&self, user: &str) -> Result<bool, TotpStoreError> {
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()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn store() -> (tempfile::TempDir, TotpStore) {
let d = tempdir().unwrap();
let s = TotpStore::new(d.path().to_path_buf());
(d, s)
}
#[test]
fn enroll_creates_file_with_mode_0600() {
let (_d, s) = store();
let e = s.enroll("alice", "AuthForge").unwrap();
assert_eq!(e.user, "alice");
assert!(e.secret_b32.len() >= 32); // 160 bits → 32 base32 chars
assert!(e.otpauth_uri.starts_with("otpauth://totp/AuthForge:alice?"));
let path = s.user_path("alice").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn enroll_writes_pam_google_authenticator_format() {
let (_d, s) = store();
s.enroll("alice", "AuthForge").unwrap();
let body = fs::read_to_string(s.user_path("alice").unwrap()).unwrap();
let mut lines = body.lines();
let secret = lines.next().unwrap();
let opt = lines.next().unwrap();
// Line 1: base32 secret only — no leading space, no leading quote.
assert!(secret
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
assert_eq!(opt, "\" TOTP_AUTH\"");
}
#[test]
fn is_enrolled_reflects_disk_state() {
let (_d, s) = store();
assert!(!s.is_enrolled("alice").unwrap());
s.enroll("alice", "AuthForge").unwrap();
assert!(s.is_enrolled("alice").unwrap());
}
#[test]
fn revoke_returns_false_on_missing_user() {
let (_d, s) = store();
assert!(!s.revoke("ghost").unwrap());
}
#[test]
fn revoke_then_is_enrolled_false() {
let (_d, s) = store();
s.enroll("alice", "AuthForge").unwrap();
assert!(s.revoke("alice").unwrap());
assert!(!s.is_enrolled("alice").unwrap());
}
#[test]
fn rejects_traversal_usernames() {
let (_d, s) = store();
for evil in ["", ".", "..", "a/b", "x\0y"] {
assert!(s.enroll(evil, "AuthForge").is_err());
}
}
}