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:
@@ -3,4 +3,6 @@ pub(crate) mod pending;
|
|||||||
pub(crate) mod policy;
|
pub(crate) mod policy;
|
||||||
pub(crate) mod recovery;
|
pub(crate) mod recovery;
|
||||||
pub(crate) mod safe_user;
|
pub(crate) mod safe_user;
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
pub(crate) mod totp;
|
||||||
pub(crate) mod userdb;
|
pub(crate) mod userdb;
|
||||||
|
|||||||
153
daemon/src/storage/totp.rs
Normal file
153
daemon/src/storage/totp.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user