Files
authforge/daemon/src/state.rs
michael 22938f657a feat(daemon-fido): Authenticator trait + Mock + Ctap impl + signals (Lane A)
Bundles plan tasks A2 (PamU2fCred encoder), A3 (Authenticator trait), A4
(MockAuthenticator), A5 (CtapAuthenticator wrapping ctap-hid-fido2 3.5.9),
A6 (wire enrollment through AppState::enroll), A7 (DeviceFound /
TouchRequired / EnrollmentSucceeded / EnrollmentFailed D-Bus signals).

- daemon/src/fido/format.rs — PamU2fCred -> 'kh,pk,es256,+presence' with
  hex::encode for the binary blobs and CoseType matching COSE alg -7/-8.
- daemon/src/fido/authenticator.rs — Authenticator trait with discover() and
  make_credential(rp_id, user, pin); AuthnError covers NoDevice / Cancelled /
  PinRequired / Backend.
- daemon/src/fido/mock.rs — MockAuthenticator::with_one_yubikey produces
  deterministic-but-distinct PamU2fCreds (counter-bumped per call).
- daemon/src/fido/ctap.rs — CtapAuthenticator. discover via
  ctap_hid_fido2::get_fidokey_devices(); make_credential via
  FidoKeyHidFactory::create + fk.make_credential. Heuristic error mapping
  to AuthnError variants. Real-hardware path; compile-clean gate only.
- daemon/src/state.rs — AppState::open now takes Arc<dyn Authenticator>.
  New enroll(user, nickname) replaces the Phase 2 add_credential stub: calls
  authn.make_credential, writes pam_u2f line via CredentialsStore::add,
  records enrollment in userdb, returns Credential with hex(keyHandle) as id.
- daemon/src/dbus.rs — enroll_own / enroll_other now emit TouchRequired
  before the call and EnrollmentSucceeded / EnrollmentFailed after. Removed
  the unused stub-credential builder + import baggage.
- daemon/src/main.rs — picks CtapAuthenticator for prod; tests inject Mock.

Test count: 33 daemon tests pass (was 26). Adds 2 fido::format tests, 3
fido::mock tests, 1 state::enroll_writes_real_pam_u2f_line, 1 dbus::
enrollment_succeeded_signal_fires_on_enroll_own. cargo clippy --workspace
--all-targets -D warnings clean. cargo fmt clean.

Plan deviations:
- HidInfo doesn't have a serial_number field in 3.5.9; switched to using
  product_string and HidParam::Path/VidPid for the device path label.
- FidoKeyHidFactory and LibCfg are at the crate root, not under fidokey::.
- fido/mod.rs has #![allow(dead_code)] for now: discover() and DiscoveredDevice
  fields are wired via the trait but only called from tests until Phase 8
  GUI consumes the DeviceFound signal.
2026-04-27 08:29:42 -07:00

250 lines
8.2 KiB
Rust

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<UserDb>,
authn: Arc<dyn Authenticator>,
}
impl AppState {
pub fn open(cfg: StorageConfig, authn: Arc<dyn Authenticator>) -> Result<Self, StateError> {
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<CredsPathResolver, StateError> {
let pol = self.policy.load()?;
Ok(CredsPathResolver::new(pol.storage))
}
pub async fn list_credentials(&self, user: &str) -> Vec<Credential> {
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<Credential, StateError> {
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<bool, StateError> {
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<Policy, StateError> {
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<bool, StateError> {
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<bool, StateError> {
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(&central).unwrap();
assert!(body.starts_with("alice:"));
assert!(body.contains(",es256,+presence"));
}
}