From 22938f657a9e54b70bbd2f729db5820507bf2c72 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 08:29:42 -0700 Subject: [PATCH] feat(daemon-fido): Authenticator trait + Mock + Ctap impl + signals (Lane A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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. --- daemon/src/dbus.rs | 133 +++++++++++++++++++++---------- daemon/src/fido/authenticator.rs | 37 +++++++++ daemon/src/fido/ctap.rs | 87 ++++++++++++++++++++ daemon/src/fido/format.rs | 63 +++++++++++++++ daemon/src/fido/mock.rs | 82 +++++++++++++++++++ daemon/src/fido/mod.rs | 11 +++ daemon/src/main.rs | 5 +- daemon/src/state.rs | 92 +++++++++++++++++---- 8 files changed, 453 insertions(+), 57 deletions(-) create mode 100644 daemon/src/fido/authenticator.rs create mode 100644 daemon/src/fido/ctap.rs create mode 100644 daemon/src/fido/format.rs create mode 100644 daemon/src/fido/mock.rs create mode 100644 daemon/src/fido/mod.rs diff --git a/daemon/src/dbus.rs b/daemon/src/dbus.rs index 78e6613..de2f88f 100644 --- a/daemon/src/dbus.rs +++ b/daemon/src/dbus.rs @@ -1,7 +1,7 @@ use crate::polkit::Polkit; use crate::state::AppState; use authforge_common::policy::Policy; -use authforge_common::types::{Credential, Method, PendingFlag, PolicyApplyResult, Transport}; +use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult}; use std::sync::Arc; pub struct AuthForge { @@ -22,13 +22,6 @@ impl AuthForge { .map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?; Ok(()) } - - fn now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - } } #[zbus::interface(name = "io.dangerousthings.AuthForge1")] @@ -44,22 +37,28 @@ impl AuthForge { .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } - async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result { + async fn enroll_own( + &self, + #[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>, + user: String, + nickname: String, + ) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.enroll-own") .await?; - let now = Self::now(); - let cred = Credential { - id: format!("stub-{user}-{now}"), - nickname, - method: Method::Fido2, - transport: Transport::Usb, - created_unix: now, - }; - self.state - .add_credential(&user, cred.clone()) - .await - .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; - Ok(cred) + // Phase 3 emits a synthetic "touch required" before the real call so + // GUIs (Phase 8) can surface a prompt. busctl monitor sees it too. + let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await; + match self.state.enroll(&user, &nickname).await { + Ok(c) => { + let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await; + Ok(c) + } + Err(e) => { + let msg = e.to_string(); + let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await; + Err(zbus::fdo::Error::Failed(msg)) + } + } } async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> { @@ -79,22 +78,26 @@ impl AuthForge { } } - async fn enroll_other(&self, user: String, nickname: String) -> zbus::fdo::Result { + async fn enroll_other( + &self, + #[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>, + user: String, + nickname: String, + ) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.enroll-other") .await?; - let now = Self::now(); - let cred = Credential { - id: format!("stub-{user}-{now}"), - nickname, - method: Method::Fido2, - transport: Transport::Usb, - created_unix: now, - }; - self.state - .add_credential(&user, cred.clone()) - .await - .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; - Ok(cred) + let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await; + match self.state.enroll(&user, &nickname).await { + Ok(c) => { + let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await; + Ok(c) + } + Err(e) => { + let msg = e.to_string(); + let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await; + Err(zbus::fdo::Error::Failed(msg)) + } + } } async fn set_policy(&self, p: Policy) -> zbus::fdo::Result { @@ -145,6 +148,36 @@ impl AuthForge { pub async fn policy_changed( signal_ctxt: &zbus::object_server::SignalContext<'_>, ) -> zbus::Result<()>; + + /// Emitted by enrollment hot-plug discovery (Phase 3 stub: synthetic). + #[zbus(signal)] + pub async fn device_found( + ctx: &zbus::object_server::SignalContext<'_>, + name: String, + transport: String, + ) -> zbus::Result<()>; + + /// Emitted right before the daemon waits for the user to touch the device. + #[zbus(signal)] + pub async fn touch_required( + ctx: &zbus::object_server::SignalContext<'_>, + device: String, + ) -> zbus::Result<()>; + + /// Fired after a successful EnrollOwn / EnrollOther so the GUI can dismiss + /// its "touch your key" overlay without polling. + #[zbus(signal)] + pub async fn enrollment_succeeded( + ctx: &zbus::object_server::SignalContext<'_>, + cred_id: String, + ) -> zbus::Result<()>; + + /// Fired when enrollment fails — payload is the StateError message. + #[zbus(signal)] + pub async fn enrollment_failed( + ctx: &zbus::object_server::SignalContext<'_>, + reason: String, + ) -> zbus::Result<()>; } #[cfg(test)] @@ -182,11 +215,14 @@ central_path = "{}" .unwrap(); let state = Arc::new( - AppState::open(crate::state::StorageConfig { - policy_dir, - pending_dir: tmp.path().join("pending"), - userdb_path: tmp.path().join("users.db"), - }) + AppState::open( + crate::state::StorageConfig { + policy_dir, + pending_dir: tmp.path().join("pending"), + userdb_path: tmp.path().join("users.db"), + }, + Arc::new(crate::fido::mock::MockAuthenticator::with_one_yubikey()), + ) .unwrap(), ); let auth = AuthForge { @@ -330,6 +366,21 @@ central_path = "{}" assert!(code.chars().all(|c| c.is_ascii_digit())); } + #[tokio::test] + async fn enrollment_succeeded_signal_fires_on_enroll_own() { + use futures_util::stream::StreamExt; + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let mut stream = p.receive_signal("EnrollmentSucceeded").await.unwrap(); + + let _: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap(); + + let _msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next()) + .await + .expect("EnrollmentSucceeded signal not received within 2s") + .expect("stream closed before signal"); + } + #[tokio::test] async fn policy_changed_signal_fires_on_emit() { use futures_util::stream::StreamExt; diff --git a/daemon/src/fido/authenticator.rs b/daemon/src/fido/authenticator.rs new file mode 100644 index 0000000..a2b811b --- /dev/null +++ b/daemon/src/fido/authenticator.rs @@ -0,0 +1,37 @@ +use crate::fido::format::PamU2fCred; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(crate) enum AuthnError { + #[error("no FIDO2 device found")] + NoDevice, + #[error("user cancelled or device timed out")] + Cancelled, + #[error("PIN required but not provided")] + PinRequired, + #[error("backend: {0}")] + Backend(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DiscoveredDevice { + pub name: String, + pub transport: authforge_common::types::Transport, + pub path: String, +} + +/// Backend-agnostic enrollment surface. The fake impl is used in unit tests; +/// the real impl wraps `ctap-hid-fido2`. Picking the concrete type happens at +/// the call site in `state.rs` / `main.rs`. +pub(crate) trait Authenticator: Send + Sync { + fn discover(&self) -> Result, AuthnError>; + + /// Enroll a new credential on the currently-attached authenticator. + /// `rp_id` follows pam_u2f convention (`pam://localhost`). + fn make_credential( + &self, + rp_id: &str, + user: &str, + pin: Option<&str>, + ) -> Result; +} diff --git a/daemon/src/fido/ctap.rs b/daemon/src/fido/ctap.rs new file mode 100644 index 0000000..0ebab15 --- /dev/null +++ b/daemon/src/fido/ctap.rs @@ -0,0 +1,87 @@ +//! Real-hardware FIDO2 path wrapping `ctap-hid-fido2`. +//! +//! This module compiles unconditionally but only succeeds at runtime when a +//! USB HID FIDO2 device is connected. Tests use `MockAuthenticator`; manual +//! smoke against a real Yubikey is the acceptance gate. + +use crate::fido::authenticator::{Authenticator, AuthnError, DiscoveredDevice}; +use crate::fido::format::{CoseType, PamU2fCred}; +use authforge_common::types::Transport; + +pub(crate) struct CtapAuthenticator; + +impl CtapAuthenticator { + pub fn new() -> Self { + Self + } + + fn random_challenge() -> Vec { + use rand::RngCore; + let mut buf = [0u8; 32]; + rand::rng().fill_bytes(&mut buf); + buf.to_vec() + } +} + +impl Authenticator for CtapAuthenticator { + fn discover(&self) -> Result, AuthnError> { + use ctap_hid_fido2::HidParam; + let devices = ctap_hid_fido2::get_fidokey_devices(); + Ok(devices + .into_iter() + .map(|d| { + let path = match d.param { + HidParam::Path(p) => p, + HidParam::VidPid { vid, pid } => format!("usb-{vid:04x}:{pid:04x}"), + }; + DiscoveredDevice { + name: d.product_string, + transport: Transport::Usb, + path, + } + }) + .collect()) + } + + fn make_credential( + &self, + rp_id: &str, + _user: &str, + pin: Option<&str>, + ) -> Result { + use ctap_hid_fido2::FidoKeyHidFactory; + use ctap_hid_fido2::LibCfg; + + let cfg = LibCfg::init(); + let fk = FidoKeyHidFactory::create(&cfg) + .map_err(|e| AuthnError::Backend(format!("open device: {e}")))?; + + let challenge = Self::random_challenge(); + let att = fk.make_credential(rp_id, &challenge, pin).map_err(|e| { + let msg = format!("make_credential: {e}"); + let lower = msg.to_lowercase(); + if lower.contains("pin") { + AuthnError::PinRequired + } else if lower.contains("cancel") || lower.contains("timeout") { + AuthnError::Cancelled + } else { + AuthnError::Backend(msg) + } + })?; + + let cose_type = match att.attstmt_alg { + -7 => CoseType::Es256, + -8 => CoseType::Eddsa, + other => { + return Err(AuthnError::Backend(format!("unsupported COSE alg {other}"))); + } + }; + + Ok(PamU2fCred { + key_handle: att.credential_descriptor.id, + public_key_der: att.credential_publickey.der, + cose_type, + user_presence: true, + }) + } +} diff --git a/daemon/src/fido/format.rs b/daemon/src/fido/format.rs new file mode 100644 index 0000000..ed45cba --- /dev/null +++ b/daemon/src/fido/format.rs @@ -0,0 +1,63 @@ +//! Convert a make_credential result into a pam_u2f keys file line. +//! +//! pam_u2f format: `username:cred1[:cred2...]`, where each `cred` is +//! `keyHandle,publicKey,COSEType,Attributes` with hex-encoded blobs and +//! algorithm name (`es256` for COSE -7, `eddsa` for -8). Attributes are a +//! comma-separated list of `+presence` / `+verification` / `+pin`; v1 emits +//! `+presence` for every credential (touch required). + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PamU2fCred { + pub key_handle: Vec, + pub public_key_der: Vec, + pub cose_type: CoseType, + pub user_presence: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CoseType { + Es256, + Eddsa, +} + +impl PamU2fCred { + /// Encode as a pam_u2f *credential* segment (the part after `username:`). + /// Caller joins `username:` + this + (optional `:` + next). + pub fn to_pam_segment(&self) -> String { + let kh = hex::encode(&self.key_handle); + let pk = hex::encode(&self.public_key_der); + let cose = match self.cose_type { + CoseType::Es256 => "es256", + CoseType::Eddsa => "eddsa", + }; + let attrs = if self.user_presence { "+presence" } else { "" }; + format!("{kh},{pk},{cose},{attrs}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn segment_uses_lowercase_hex_es256_presence() { + let c = PamU2fCred { + key_handle: vec![0xDE, 0xAD, 0xBE, 0xEF], + public_key_der: vec![0x30, 0x59], + cose_type: CoseType::Es256, + user_presence: true, + }; + assert_eq!(c.to_pam_segment(), "deadbeef,3059,es256,+presence"); + } + + #[test] + fn segment_handles_eddsa_no_presence() { + let c = PamU2fCred { + key_handle: vec![0x01], + public_key_der: vec![0x02], + cose_type: CoseType::Eddsa, + user_presence: false, + }; + assert_eq!(c.to_pam_segment(), "01,02,eddsa,"); + } +} diff --git a/daemon/src/fido/mock.rs b/daemon/src/fido/mock.rs new file mode 100644 index 0000000..13e6b3d --- /dev/null +++ b/daemon/src/fido/mock.rs @@ -0,0 +1,82 @@ +use crate::fido::authenticator::{Authenticator, AuthnError, DiscoveredDevice}; +use crate::fido::format::{CoseType, PamU2fCred}; +use authforge_common::types::Transport; +use std::sync::Mutex; + +/// In-memory authenticator for tests. Records every call; produces a +/// deterministic PamU2fCred from a counter so two enrollments differ. +pub(crate) struct MockAuthenticator { + next: Mutex, + pub devices: Vec, +} + +impl MockAuthenticator { + pub fn with_one_yubikey() -> Self { + Self { + next: Mutex::new(1), + devices: vec![DiscoveredDevice { + name: "Mock Yubikey 5".to_string(), + transport: Transport::Usb, + path: "/dev/mock0".to_string(), + }], + } + } +} + +impl Authenticator for MockAuthenticator { + fn discover(&self) -> Result, AuthnError> { + Ok(self.devices.clone()) + } + + fn make_credential( + &self, + _rp_id: &str, + _user: &str, + _pin: Option<&str>, + ) -> Result { + if self.devices.is_empty() { + return Err(AuthnError::NoDevice); + } + let mut n = self.next.lock().unwrap(); + let kh = vec![*n as u8, 0xCA, 0xFE]; + let pk = vec![*n as u8, 0xBE, 0xEF]; + *n += 1; + Ok(PamU2fCred { + key_handle: kh, + public_key_der: pk, + cose_type: CoseType::Es256, + user_presence: true, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_discover_lists_one_device() { + let m = MockAuthenticator::with_one_yubikey(); + let devs = m.discover().unwrap(); + assert_eq!(devs.len(), 1); + assert_eq!(devs[0].transport, Transport::Usb); + } + + #[test] + fn mock_make_credential_is_unique_per_call() { + let m = MockAuthenticator::with_one_yubikey(); + let a = m.make_credential("pam://localhost", "alice", None).unwrap(); + let b = m.make_credential("pam://localhost", "alice", None).unwrap(); + assert_ne!(a.key_handle, b.key_handle); + } + + #[test] + fn mock_no_device_errors() { + let m = MockAuthenticator { + next: Mutex::new(1), + devices: vec![], + }; + let r = m.make_credential("pam://localhost", "alice", None); + assert!(matches!(r, Err(AuthnError::NoDevice))); + } +} diff --git a/daemon/src/fido/mod.rs b/daemon/src/fido/mod.rs new file mode 100644 index 0000000..cec9ecf --- /dev/null +++ b/daemon/src/fido/mod.rs @@ -0,0 +1,11 @@ +// Some authenticator-trait methods (discover, fields on DiscoveredDevice, +// AuthnError variants) are populated via the trait but called from later +// phases (hot-plug polling lands when the GUI consumes DeviceFound signals). +// Blanket-allow keeps `cargo clippy -D warnings` clean while preserving the +// trait surface. +#![allow(dead_code)] + +pub(crate) mod authenticator; +pub(crate) mod ctap; +pub(crate) mod format; +pub(crate) mod mock; diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 3a9ba23..48cb332 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use tracing::{info, warn}; mod dbus; +mod fido; mod polkit; mod state; mod storage; @@ -32,7 +33,9 @@ async fn main() -> Result<()> { let cfg = state::StorageConfig::from_env_or_defaults(); let policy_dir = cfg.policy_dir.clone(); - let state = Arc::new(state::AppState::open(cfg)?); + let authn: Arc = + Arc::new(fido::ctap::CtapAuthenticator::new()); + let state = Arc::new(state::AppState::open(cfg, authn)?); let auth = dbus::AuthForge { state: state.clone(), polkit: Arc::new(polkit), diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 2a9ef76..bd1337f 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -1,3 +1,4 @@ +use crate::fido::authenticator::Authenticator; use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver}; use crate::storage::pending::{PendingError, PendingStore}; use crate::storage::policy::PolicyStore; @@ -5,6 +6,7 @@ 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; @@ -18,6 +20,8 @@ pub enum StateError { Creds(#[from] CredsError), #[error(transparent)] UserDb(#[from] UserDbError), + #[error("authenticator: {0}")] + Authn(String), } pub struct StorageConfig { @@ -45,10 +49,11 @@ pub struct AppState { policy: PolicyStore, pending: PendingStore, userdb: Mutex, + authn: Arc, } impl AppState { - pub fn open(cfg: StorageConfig) -> Result { + pub fn open(cfg: StorageConfig, authn: Arc) -> Result { let policy = PolicyStore::new(cfg.policy_dir); let pending = PendingStore::new(cfg.pending_dir); let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?); @@ -56,6 +61,7 @@ impl AppState { policy, pending, userdb, + authn, }) } @@ -64,10 +70,6 @@ impl AppState { Ok(CredsPathResolver::new(pol.storage)) } - /// Phase 2 returns opaque cred IDs. Richer Credential metadata (real - /// nickname, transport, created_unix) is recorded at enrollment time - /// in Phase 3; until then the keyHandle stands in for both id and - /// nickname so the GUI shows *something*. pub async fn list_credentials(&self, user: &str) -> Vec { let resolver = match self.creds_resolver() { Ok(r) => r, @@ -94,15 +96,34 @@ impl AppState { .collect() } - pub async fn add_credential(&self, user: &str, c: Credential) -> Result<(), StateError> { + /// 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 { let resolver = self.creds_resolver()?; let path = resolver.path_for(user)?; let store = CredentialsStore::new(path); - // Phase 2 records id-only; Phase 3 swaps in the full pam_u2f line. - store.add(user, &c.id)?; + + 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, c.method)?; - Ok(()) + 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 { @@ -149,14 +170,18 @@ impl AppState { #[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"), - }) + 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() } @@ -184,4 +209,41 @@ mod tests { 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(¢ral).unwrap(); + assert!(body.starts_with("alice:")); + assert!(body.contains(",es256,+presence")); + } }