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.
This commit is contained in:
@@ -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<UserDb>,
|
||||
authn: Arc<dyn Authenticator>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn open(cfg: StorageConfig) -> Result<Self, StateError> {
|
||||
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)?);
|
||||
@@ -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<Credential> {
|
||||
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<Credential, StateError> {
|
||||
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<bool, StateError> {
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user