use crate::fido::authenticator::Authenticator; use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver}; use crate::storage::pending::{PendingError, PendingStore}; use crate::storage::policy::PolicyStore; use crate::storage::userdb::{UserDb, UserDbError}; use authforge_common::policy::{Policy, PolicyError}; use authforge_common::types::{Credential, Method, PendingFlag, Transport}; use std::path::PathBuf; use std::sync::Arc; use thiserror::Error; use tokio::sync::Mutex; #[derive(Debug, Error)] pub enum StateError { #[error(transparent)] Policy(#[from] PolicyError), #[error(transparent)] Pending(#[from] PendingError), #[error(transparent)] Creds(#[from] CredsError), #[error(transparent)] UserDb(#[from] UserDbError), #[error("authenticator: {0}")] Authn(String), } pub struct StorageConfig { pub policy_dir: PathBuf, pub pending_dir: PathBuf, pub userdb_path: PathBuf, } impl StorageConfig { pub fn from_env_or_defaults() -> Self { let env = |k: &str, d: &str| -> PathBuf { std::env::var_os(k) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(d)) }; Self { policy_dir: env("AUTHFORGE_POLICY_DIR", "/etc/authforge/policy.d"), pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"), userdb_path: env("AUTHFORGE_USERDB", "/var/lib/authforge/users.db"), } } } pub struct AppState { policy: PolicyStore, pending: PendingStore, userdb: Mutex, authn: Arc, } impl AppState { pub fn open(cfg: StorageConfig, authn: Arc) -> Result { let policy = PolicyStore::new(cfg.policy_dir); let pending = PendingStore::new(cfg.pending_dir); let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?); Ok(Self { policy, pending, userdb, authn, }) } fn creds_resolver(&self) -> Result { let pol = self.policy.load()?; Ok(CredsPathResolver::new(pol.storage)) } pub async fn list_credentials(&self, user: &str) -> Vec { let resolver = match self.creds_resolver() { Ok(r) => r, Err(_) => return vec![], }; let path = match resolver.path_for(user) { Ok(p) => p, Err(_) => return vec![], }; let store = CredentialsStore::new(path); let lines = store.list(user).unwrap_or_default(); lines .iter() .map(|c| { let id = CredEntry::cred_id(c).to_string(); Credential { id: id.clone(), nickname: id, method: Method::Fido2, transport: Transport::Unknown, created_unix: 0, } }) .collect() } /// Run the authenticator's `make_credential` and persist the resulting /// pam_u2f line + userdb entry. Returns the Credential the GUI displays. pub async fn enroll(&self, user: &str, nickname: &str) -> Result { let resolver = self.creds_resolver()?; let path = resolver.path_for(user)?; let store = CredentialsStore::new(path); let pam_cred = self .authn .make_credential("pam://localhost", user, None) .map_err(|e| StateError::Authn(e.to_string()))?; let segment = pam_cred.to_pam_segment(); let cred_id = hex::encode(&pam_cred.key_handle); store.add(user, &segment)?; let db = self.userdb.lock().await; db.record_enrollment(user, Method::Fido2)?; Ok(Credential { id: cred_id, nickname: nickname.to_string(), method: Method::Fido2, transport: Transport::Usb, created_unix: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0), }) } pub async fn remove_credential(&self, user: &str, cred_id: &str) -> Result { let resolver = self.creds_resolver()?; let path = resolver.path_for(user)?; let store = CredentialsStore::new(path); let removed = store.remove(user, cred_id)?; if removed && store.list(user)?.is_empty() { self.userdb .lock() .await .drop_enrollment(user, Method::Fido2)?; } Ok(removed) } pub async fn get_policy(&self) -> Result { Ok(self.policy.load()?) } pub async fn set_policy(&self, p: Policy) -> Result<(), StateError> { self.policy.save(&p)?; Ok(()) } pub async fn set_pending(&self, user: &str, f: PendingFlag) -> Result<(), StateError> { self.pending.set(user, &f)?; Ok(()) } pub async fn clear_pending(&self, user: &str) -> Result { Ok(self.pending.clear(user)?) } /// Tests assert pending flag round-trips through SetPendingFlag / /// ClearPendingFlag via this read accessor. Production readers (PAM /// module, GUI status) read the on-disk file directly in later phases. #[cfg(test)] pub async fn has_pending(&self, user: &str) -> Result { Ok(self.pending.get(user)?.is_some()) } } #[cfg(test)] mod tests { use super::*; use crate::fido::mock::MockAuthenticator; use tempfile::tempdir; fn open_in(d: &std::path::Path) -> AppState { AppState::open( StorageConfig { policy_dir: d.join("policy.d"), pending_dir: d.join("pending"), userdb_path: d.join("users.db"), }, Arc::new(MockAuthenticator::with_one_yubikey()), ) .unwrap() } #[tokio::test] async fn empty_state_has_nothing() { let d = tempdir().unwrap(); let s = open_in(d.path()); assert!(s.list_credentials("alice").await.is_empty()); assert!(!s.has_pending("alice").await.unwrap()); assert!(s.get_policy().await.unwrap().stacks.is_empty()); } #[tokio::test] async fn pending_set_clear_roundtrip() { let d = tempdir().unwrap(); let s = open_in(d.path()); let f = PendingFlag { required_methods: vec![Method::Fido2], created_unix: 1, deadline_unix: 0, re_enroll: false, }; s.set_pending("alice", f).await.unwrap(); assert!(s.has_pending("alice").await.unwrap()); assert!(s.clear_pending("alice").await.unwrap()); assert!(!s.has_pending("alice").await.unwrap()); } #[tokio::test] async fn enroll_writes_real_pam_u2f_line() { let d = tempdir().unwrap(); // Configure central storage so the resolver doesn't try /home/alice. let policy_dir = d.path().join("policy.d"); std::fs::create_dir_all(&policy_dir).unwrap(); let central = d.path().join("u2f_keys"); std::fs::write( policy_dir.join("00.conf"), format!( "[storage]\nbackend = \"central\"\ncentral_path = \"{}\"\n", central.display() ), ) .unwrap(); let s = AppState::open( StorageConfig { policy_dir, pending_dir: d.path().join("pending"), userdb_path: d.path().join("users.db"), }, Arc::new(MockAuthenticator::with_one_yubikey()), ) .unwrap(); let cred = s.enroll("alice", "Yellow").await.unwrap(); // cred.id should be hex-encoded keyHandle. assert!(cred.id.chars().all(|c| c.is_ascii_hexdigit())); assert_eq!(cred.nickname, "Yellow"); assert_eq!(cred.method, Method::Fido2); // The on-disk pam_u2f file holds a real line — username:kh,pk,es256,+presence let body = std::fs::read_to_string(¢ral).unwrap(); assert!(body.starts_with("alice:")); assert!(body.contains(",es256,+presence")); } }