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());
+ }
+ }
+}