diff --git a/daemon/src/storage/mod.rs b/daemon/src/storage/mod.rs index 8f189b0..62cb540 100644 --- a/daemon/src/storage/mod.rs +++ b/daemon/src/storage/mod.rs @@ -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; diff --git a/daemon/src/storage/totp.rs b/daemon/src/storage/totp.rs new file mode 100644 index 0000000..dc7b220 --- /dev/null +++ b/daemon/src/storage/totp.rs @@ -0,0 +1,153 @@ +//! Per-user TOTP secret persistence. File at `/` mode 0600, +//! root-owned. Format is pam_google_authenticator's expected layout: +//! \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 { + 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 { + 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 { + 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 { + 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()); + } + } +}