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

@@ -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<Credential> {
async fn enroll_own(
&self,
#[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>,
user: String,
nickname: String,
) -> zbus::fdo::Result<Credential> {
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<Credential> {
async fn enroll_other(
&self,
#[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>,
user: String,
nickname: String,
) -> zbus::fdo::Result<Credential> {
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<PolicyApplyResult> {
@@ -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;

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;

View File

@@ -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<dyn fido::authenticator::Authenticator> =
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),

View File

@@ -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(&central).unwrap();
assert!(body.starts_with("alice:"));
assert!(body.contains(",es256,+presence"));
}
}