use authforge_common::policy::{Storage, StorageBackend}; use std::path::PathBuf; use thiserror::Error; #[derive(Debug, Error)] pub(crate) enum CredsError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("malformed line for {user:?}: {reason}")] Malformed { user: String, reason: &'static str }, } /// One line of a `pam_u2f` keys file. Format per pam_u2f(8): /// `username:cred1[:cred2...]`, where each `cred` is /// `keyHandle,publicKey,coseType,attributes` (commas, hex). We treat each /// post-`username` chunk opaquely — only `keyHandle` (the first comma field) /// matters for add/remove. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CredEntry { pub user: String, pub creds: Vec, } #[allow(dead_code)] // wired through AppState in Task 2.15. impl CredEntry { pub fn from_line(line: &str) -> Result { let mut it = line.splitn(2, ':'); let user = it.next().unwrap_or(""); if user.is_empty() { return Err(CredsError::Malformed { user: String::new(), reason: "empty user", }); } let rest = it.next().unwrap_or(""); let creds: Vec = if rest.is_empty() { vec![] } else { rest.split(':').map(|s| s.to_string()).collect() }; Ok(Self { user: user.to_string(), creds, }) } pub fn to_line(&self) -> String { let mut s = String::with_capacity(self.user.len() + 64); s.push_str(&self.user); for c in &self.creds { s.push(':'); s.push_str(c); } s } /// The credential ID — first comma-separated field of a credential blob. pub fn cred_id(cred: &str) -> &str { cred.split(',').next().unwrap_or("") } } #[allow(dead_code)] // wired through AppState in Task 2.15. pub(crate) struct CredentialsStore { path: PathBuf, } #[allow(dead_code)] // wired through AppState in Task 2.15. impl CredentialsStore { pub fn new(path: PathBuf) -> Self { Self { path } } fn read_all(&self) -> Result, CredsError> { match std::fs::read_to_string(&self.path) { Ok(s) => s .lines() .filter(|l| !l.trim().is_empty()) .map(CredEntry::from_line) .collect(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), Err(e) => Err(e.into()), } } fn write_all(&self, entries: &[CredEntry]) -> Result<(), CredsError> { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } let body = entries .iter() .map(CredEntry::to_line) .collect::>() .join("\n"); std::fs::write(&self.path, body)?; Ok(()) } pub fn list(&self, user: &str) -> Result, CredsError> { Ok(self .read_all()? .into_iter() .find(|e| e.user == user) .map(|e| e.creds) .unwrap_or_default()) } pub fn add(&self, user: &str, cred: &str) -> Result<(), CredsError> { let new_id = CredEntry::cred_id(cred); let mut entries = self.read_all()?; match entries.iter_mut().find(|e| e.user == user) { Some(entry) => { if entry.creds.iter().any(|c| CredEntry::cred_id(c) == new_id) { // Idempotent: same credId already present. Phase 3 may // refresh the public key on re-enroll; not Phase 2's call. return Ok(()); } entry.creds.push(cred.to_string()); } None => entries.push(CredEntry { user: user.to_string(), creds: vec![cred.to_string()], }), } self.write_all(&entries) } pub fn remove(&self, user: &str, cred_id: &str) -> Result { let mut entries = self.read_all()?; let mut changed = false; if let Some(entry) = entries.iter_mut().find(|e| e.user == user) { let before = entry.creds.len(); entry.creds.retain(|c| CredEntry::cred_id(c) != cred_id); changed = entry.creds.len() != before; } if changed { self.write_all(&entries)?; } Ok(changed) } } /// Picks the on-disk pam_u2f file path based on policy storage config. #[allow(dead_code)] // wired through AppState in Task 2.15. pub(crate) struct CredsPathResolver { storage: Storage, } #[allow(dead_code)] // wired through AppState in Task 2.15. impl CredsPathResolver { pub fn new(storage: Storage) -> Self { Self { storage } } pub fn path_for(&self, user: &str) -> Result { match self.storage.backend { StorageBackend::Central => Ok(PathBuf::from(&self.storage.central_path)), StorageBackend::PerUser => { // pam_u2f does the real getpwnam() at auth time; the daemon's // chosen path only matters for *writes* on enrollment, where // the user does exist. For users not in /etc/passwd (CI, tests), // or if getpwnam_r reports any error (NSS quirks across distros), // fall back to /home//.config/Yubico/u2f_keys. let home = nix::unistd::User::from_name(user) .ok() .flatten() .map(|u| u.dir) .unwrap_or_else(|| PathBuf::from(format!("/home/{user}"))); Ok(home.join(".config/Yubico/u2f_keys")) } } } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn parse_user_with_two_credentials() { let line = "alice:khA,pkA,es256,present:khB,pkB,es256,present"; let e = CredEntry::from_line(line).unwrap(); assert_eq!(e.user, "alice"); assert_eq!(e.creds.len(), 2); assert_eq!(CredEntry::cred_id(&e.creds[0]), "khA"); } #[test] fn parse_user_no_credentials() { let e = CredEntry::from_line("bob:").unwrap(); assert_eq!(e.user, "bob"); assert!(e.creds.is_empty()); } #[test] fn line_roundtrips() { let line = "alice:khA,pkA,es256,present:khB,pkB,es256,present"; let e = CredEntry::from_line(line).unwrap(); assert_eq!(e.to_line(), line); } #[test] fn rejects_empty_user() { assert!(CredEntry::from_line(":khA,pkA,es256,present").is_err()); } #[test] fn add_then_remove_credential_idempotent() { let d = tempdir().unwrap(); let store = CredentialsStore::new(d.path().join("u2f_keys")); store.add("alice", "kh1,pk1,es256,present").unwrap(); store.add("alice", "kh2,pk2,es256,present").unwrap(); // Idempotent: adding kh1 again should not duplicate. store.add("alice", "kh1,pk1-renewed,es256,present").unwrap(); let creds = store.list("alice").unwrap(); assert_eq!(creds.len(), 2); assert!(creds.iter().any(|c| CredEntry::cred_id(c) == "kh1")); assert!(creds.iter().any(|c| CredEntry::cred_id(c) == "kh2")); assert!(store.remove("alice", "kh1").unwrap()); let creds = store.list("alice").unwrap(); assert_eq!(creds.len(), 1); assert_eq!(CredEntry::cred_id(&creds[0]), "kh2"); assert!(!store.remove("alice", "kh1").unwrap()); } #[test] fn list_for_unknown_user_is_empty() { let d = tempdir().unwrap(); let store = CredentialsStore::new(d.path().join("u2f_keys")); assert!(store.list("noone").unwrap().is_empty()); } #[test] fn per_user_path_resolution() { let central = PathBuf::from("/etc/u2f_mappings"); let resolver = CredsPathResolver::new(Storage { backend: StorageBackend::Central, central_path: central.display().to_string(), }); assert_eq!(resolver.path_for("alice").unwrap(), central); let resolver = CredsPathResolver::new(Storage { backend: StorageBackend::PerUser, central_path: String::new(), }); let p = resolver.path_for("alice").unwrap(); assert!(p.ends_with(".config/Yubico/u2f_keys")); } }