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/<user>/... 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.
This commit is contained in:
257
daemon/src/storage/credentials.rs
Normal file
257
daemon/src/storage/credentials.rs
Normal file
@@ -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<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
pub(crate) mod credentials;
|
||||||
pub(crate) mod pending;
|
pub(crate) mod pending;
|
||||||
pub(crate) mod policy;
|
pub(crate) mod policy;
|
||||||
|
|||||||
Reference in New Issue
Block a user