From eb3362c81ca7288cf0f29791f02909133537a13c Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 06:29:00 -0700 Subject: [PATCH] feat(daemon): storage::credentials parser, CRUD, and per-user vs central path Lands plan tasks 2.10 (pam_u2f line format parser via CredEntry::from_line/ to_line, credId extraction), 2.11 (CredentialsStore add/remove/list with idempotent add-by-credId), and 2.12 (CredsPathResolver dispatching central vs per-user paths). Bundled because the three pieces compose into one storage boundary. CredEntry treats post-username chunks as opaque blobs split on ':', preserving pam_u2f's full record on round-trip. credId = first comma-separated field of a blob. add() is idempotent on credId match (Phase 3 may decide to refresh publicKey on re-enroll; out of scope here). Path resolution: Central -> Storage.central_path verbatim. PerUser -> getpwnam via nix, with a /home//... fallback if NSS errors (CI users, distro quirks); pam_u2f does the real lookup at auth time, so the fallback only matters for write-on-enroll where the user does exist. 7 tests added; clippy + fmt clean. --- daemon/src/storage/credentials.rs | 257 ++++++++++++++++++++++++++++++ daemon/src/storage/mod.rs | 1 + 2 files changed, 258 insertions(+) create mode 100644 daemon/src/storage/credentials.rs diff --git a/daemon/src/storage/credentials.rs b/daemon/src/storage/credentials.rs new file mode 100644 index 0000000..0c981b7 --- /dev/null +++ b/daemon/src/storage/credentials.rs @@ -0,0 +1,257 @@ +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")); + } +} diff --git a/daemon/src/storage/mod.rs b/daemon/src/storage/mod.rs index a914602..12438e1 100644 --- a/daemon/src/storage/mod.rs +++ b/daemon/src/storage/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod credentials; pub(crate) mod pending; pub(crate) mod policy;