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;
|
||||
|
||||
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};
|
||||
|
||||
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),
|
||||
|
||||
@@ -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(¢ral).unwrap();
|
||||
assert!(body.starts_with("alice:"));
|
||||
assert!(body.contains(",es256,+presence"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user