Files
authforge/daemon/src/storage/credentials.rs
michael 24237cbf8b refactor(daemon): AppState delegates to storage modules; dbus tests use tempdir
Lands plan tasks 2.15 (AppState refactor with StorageConfig + open()) and 2.16
(dbus.rs tests switched to tempdir-backed AppState; storage errors threaded
through D-Bus methods as Failed). Bundled because the AppState surface change
forces dbus.rs adjustments in the same commit.

- daemon/src/state.rs: AppState::open(StorageConfig) replaces with_fixtures().
  StorageConfig.from_env_or_defaults() reads AUTHFORGE_POLICY_DIR /
  _PENDING_DIR / _USERDB env vars (defaults: /etc/authforge/policy.d,
  /var/lib/authforge/pending, /var/lib/authforge/users.db). State delegates
  list/add/remove credentials to CredsPathResolver + CredentialsStore picked
  per-call from current Policy; pending and userdb operate independently.
- daemon/src/main.rs: opens state via env-driven config; reuses cfg.policy_dir
  for the watcher to keep one source of truth.
- daemon/src/dbus.rs: every write method maps StateError to fdo::Error::Failed.
  p2p_pair seeds 00-test.conf with [storage] backend = central pointing into
  the tempdir so credential writes don't try to touch /home/<user>/...
  (alice/bob/carol aren't real accounts in tests).
- Renamed: list_credentials_returns_fixture_for_alice ->
  list_credentials_after_enroll. Removed: with_fixtures().
- .gitignore: add .claude/ so leftover Phase 1 worktree state isn't committed.

Test count: 26/26 daemon tests green (was 17). Common: 13/13. Clippy + fmt clean.
2026-04-27 06:44:07 -07:00

255 lines
8.3 KiB
Rust

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<String>,
}
#[allow(dead_code)] // wired through AppState in Task 2.15.
impl CredEntry {
pub fn from_line(line: &str) -> Result<Self, CredsError> {
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<String> = 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<Vec<CredEntry>, 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::<Vec<_>>()
.join("\n");
std::fs::write(&self.path, body)?;
Ok(())
}
pub fn list(&self, user: &str) -> Result<Vec<String>, 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<bool, CredsError> {
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<PathBuf, CredsError> {
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/<user>/.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"));
}
}