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::polkit::Polkit;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use authforge_common::policy::Policy;
|
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;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct AuthForge {
|
pub struct AuthForge {
|
||||||
@@ -22,13 +22,6 @@ impl AuthForge {
|
|||||||
.map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?;
|
.map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?;
|
||||||
Ok(())
|
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")]
|
#[zbus::interface(name = "io.dangerousthings.AuthForge1")]
|
||||||
@@ -44,22 +37,28 @@ impl AuthForge {
|
|||||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
.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")
|
self.authz("io.dangerousthings.AuthForge.enroll-own")
|
||||||
.await?;
|
.await?;
|
||||||
let now = Self::now();
|
// Phase 3 emits a synthetic "touch required" before the real call so
|
||||||
let cred = Credential {
|
// GUIs (Phase 8) can surface a prompt. busctl monitor sees it too.
|
||||||
id: format!("stub-{user}-{now}"),
|
let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await;
|
||||||
nickname,
|
match self.state.enroll(&user, &nickname).await {
|
||||||
method: Method::Fido2,
|
Ok(c) => {
|
||||||
transport: Transport::Usb,
|
let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await;
|
||||||
created_unix: now,
|
Ok(c)
|
||||||
};
|
}
|
||||||
self.state
|
Err(e) => {
|
||||||
.add_credential(&user, cred.clone())
|
let msg = e.to_string();
|
||||||
.await
|
let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await;
|
||||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
Err(zbus::fdo::Error::Failed(msg))
|
||||||
Ok(cred)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> {
|
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")
|
self.authz("io.dangerousthings.AuthForge.enroll-other")
|
||||||
.await?;
|
.await?;
|
||||||
let now = Self::now();
|
let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await;
|
||||||
let cred = Credential {
|
match self.state.enroll(&user, &nickname).await {
|
||||||
id: format!("stub-{user}-{now}"),
|
Ok(c) => {
|
||||||
nickname,
|
let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await;
|
||||||
method: Method::Fido2,
|
Ok(c)
|
||||||
transport: Transport::Usb,
|
}
|
||||||
created_unix: now,
|
Err(e) => {
|
||||||
};
|
let msg = e.to_string();
|
||||||
self.state
|
let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await;
|
||||||
.add_credential(&user, cred.clone())
|
Err(zbus::fdo::Error::Failed(msg))
|
||||||
.await
|
}
|
||||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
}
|
||||||
Ok(cred)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_policy(&self, p: Policy) -> zbus::fdo::Result<PolicyApplyResult> {
|
async fn set_policy(&self, p: Policy) -> zbus::fdo::Result<PolicyApplyResult> {
|
||||||
@@ -145,6 +148,36 @@ impl AuthForge {
|
|||||||
pub async fn policy_changed(
|
pub async fn policy_changed(
|
||||||
signal_ctxt: &zbus::object_server::SignalContext<'_>,
|
signal_ctxt: &zbus::object_server::SignalContext<'_>,
|
||||||
) -> zbus::Result<()>;
|
) -> 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)]
|
#[cfg(test)]
|
||||||
@@ -182,11 +215,14 @@ central_path = "{}"
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let state = Arc::new(
|
let state = Arc::new(
|
||||||
AppState::open(crate::state::StorageConfig {
|
AppState::open(
|
||||||
policy_dir,
|
crate::state::StorageConfig {
|
||||||
pending_dir: tmp.path().join("pending"),
|
policy_dir,
|
||||||
userdb_path: tmp.path().join("users.db"),
|
pending_dir: tmp.path().join("pending"),
|
||||||
})
|
userdb_path: tmp.path().join("users.db"),
|
||||||
|
},
|
||||||
|
Arc::new(crate::fido::mock::MockAuthenticator::with_one_yubikey()),
|
||||||
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
let auth = AuthForge {
|
let auth = AuthForge {
|
||||||
@@ -330,6 +366,21 @@ central_path = "{}"
|
|||||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
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]
|
#[tokio::test]
|
||||||
async fn policy_changed_signal_fires_on_emit() {
|
async fn policy_changed_signal_fires_on_emit() {
|
||||||
use futures_util::stream::StreamExt;
|
use futures_util::stream::StreamExt;
|
||||||
|
|||||||
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;
|
||||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
mod dbus;
|
mod dbus;
|
||||||
|
mod fido;
|
||||||
mod polkit;
|
mod polkit;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
@@ -32,7 +33,9 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let cfg = state::StorageConfig::from_env_or_defaults();
|
let cfg = state::StorageConfig::from_env_or_defaults();
|
||||||
let policy_dir = cfg.policy_dir.clone();
|
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 {
|
let auth = dbus::AuthForge {
|
||||||
state: state.clone(),
|
state: state.clone(),
|
||||||
polkit: Arc::new(polkit),
|
polkit: Arc::new(polkit),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::fido::authenticator::Authenticator;
|
||||||
use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver};
|
use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver};
|
||||||
use crate::storage::pending::{PendingError, PendingStore};
|
use crate::storage::pending::{PendingError, PendingStore};
|
||||||
use crate::storage::policy::PolicyStore;
|
use crate::storage::policy::PolicyStore;
|
||||||
@@ -5,6 +6,7 @@ use crate::storage::userdb::{UserDb, UserDbError};
|
|||||||
use authforge_common::policy::{Policy, PolicyError};
|
use authforge_common::policy::{Policy, PolicyError};
|
||||||
use authforge_common::types::{Credential, Method, PendingFlag, Transport};
|
use authforge_common::types::{Credential, Method, PendingFlag, Transport};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ pub enum StateError {
|
|||||||
Creds(#[from] CredsError),
|
Creds(#[from] CredsError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
UserDb(#[from] UserDbError),
|
UserDb(#[from] UserDbError),
|
||||||
|
#[error("authenticator: {0}")]
|
||||||
|
Authn(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StorageConfig {
|
pub struct StorageConfig {
|
||||||
@@ -45,10 +49,11 @@ pub struct AppState {
|
|||||||
policy: PolicyStore,
|
policy: PolicyStore,
|
||||||
pending: PendingStore,
|
pending: PendingStore,
|
||||||
userdb: Mutex<UserDb>,
|
userdb: Mutex<UserDb>,
|
||||||
|
authn: Arc<dyn Authenticator>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
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 policy = PolicyStore::new(cfg.policy_dir);
|
||||||
let pending = PendingStore::new(cfg.pending_dir);
|
let pending = PendingStore::new(cfg.pending_dir);
|
||||||
let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?);
|
let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?);
|
||||||
@@ -56,6 +61,7 @@ impl AppState {
|
|||||||
policy,
|
policy,
|
||||||
pending,
|
pending,
|
||||||
userdb,
|
userdb,
|
||||||
|
authn,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +70,6 @@ impl AppState {
|
|||||||
Ok(CredsPathResolver::new(pol.storage))
|
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> {
|
pub async fn list_credentials(&self, user: &str) -> Vec<Credential> {
|
||||||
let resolver = match self.creds_resolver() {
|
let resolver = match self.creds_resolver() {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
@@ -94,15 +96,34 @@ impl AppState {
|
|||||||
.collect()
|
.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 resolver = self.creds_resolver()?;
|
||||||
let path = resolver.path_for(user)?;
|
let path = resolver.path_for(user)?;
|
||||||
let store = CredentialsStore::new(path);
|
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;
|
let db = self.userdb.lock().await;
|
||||||
db.record_enrollment(user, c.method)?;
|
db.record_enrollment(user, Method::Fido2)?;
|
||||||
Ok(())
|
|
||||||
|
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> {
|
pub async fn remove_credential(&self, user: &str, cred_id: &str) -> Result<bool, StateError> {
|
||||||
@@ -149,14 +170,18 @@ impl AppState {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::fido::mock::MockAuthenticator;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn open_in(d: &std::path::Path) -> AppState {
|
fn open_in(d: &std::path::Path) -> AppState {
|
||||||
AppState::open(StorageConfig {
|
AppState::open(
|
||||||
policy_dir: d.join("policy.d"),
|
StorageConfig {
|
||||||
pending_dir: d.join("pending"),
|
policy_dir: d.join("policy.d"),
|
||||||
userdb_path: d.join("users.db"),
|
pending_dir: d.join("pending"),
|
||||||
})
|
userdb_path: d.join("users.db"),
|
||||||
|
},
|
||||||
|
Arc::new(MockAuthenticator::with_one_yubikey()),
|
||||||
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,4 +209,41 @@ mod tests {
|
|||||||
assert!(s.clear_pending("alice").await.unwrap());
|
assert!(s.clear_pending("alice").await.unwrap());
|
||||||
assert!(!s.has_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(¢ral).unwrap();
|
||||||
|
assert!(body.starts_with("alice:"));
|
||||||
|
assert!(body.contains(",es256,+presence"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user