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;