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:
michael
2026-04-27 08:29:42 -07:00
parent 01df2109d8
commit 22938f657a
8 changed files with 453 additions and 57 deletions

View File

@@ -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<Vec<DiscoveredDevice>, 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<PamU2fCred, AuthnError>;
}

87
daemon/src/fido/ctap.rs Normal file
View File

@@ -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<u8> {
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<Vec<DiscoveredDevice>, 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<PamU2fCred, AuthnError> {
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,
})
}
}

63
daemon/src/fido/format.rs Normal file
View File

@@ -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<u8>,
pub public_key_der: Vec<u8>,
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,");
}
}

82
daemon/src/fido/mock.rs Normal file
View File

@@ -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<u32>,
pub devices: Vec<DiscoveredDevice>,
}
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<Vec<DiscoveredDevice>, AuthnError> {
Ok(self.devices.clone())
}
fn make_credential(
&self,
_rp_id: &str,
_user: &str,
_pin: Option<&str>,
) -> Result<PamU2fCred, AuthnError> {
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)));
}
}

11
daemon/src/fido/mod.rs Normal file
View File

@@ -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;