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,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;
|
||||
|
||||
Reference in New Issue
Block a user