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:
37
daemon/src/fido/authenticator.rs
Normal file
37
daemon/src/fido/authenticator.rs
Normal 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
87
daemon/src/fido/ctap.rs
Normal 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
63
daemon/src/fido/format.rs
Normal 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
82
daemon/src/fido/mock.rs
Normal 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
11
daemon/src/fido/mod.rs
Normal 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;
|
||||
Reference in New Issue
Block a user