1417 lines
46 KiB
Markdown
1417 lines
46 KiB
Markdown
# Phase 3 + 6 + 7: FIDO2 Backend, PAM Module, CLI — Multi-Lane Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Land three parallel-safe phases — Phase 3 (FIDO2 enrollment via `ctap-hid-fido2`), Phase 6 (`pam_authforge_pending.so` C module), Phase 7 (`authforgectl` CLI) — in one bundled work cycle. They touch disjoint file trees (`daemon/src/fido/`, `pam/`, `cli/src/`) and consume only the D-Bus contract from Phase 1 plus the storage façade from Phase 2.
|
||
|
||
**Architecture:** Phase 3 hides `ctap-hid-fido2` behind an `Authenticator` trait with a fake impl for unit tests and a hardware-backed real impl. Phase 6 replaces the Phase 0 PAM stub with a real pending-flag check that reads `/var/lib/authforge/pending/<user>` and returns `PAM_AUTH_ERR` if present. Phase 7 is a thin `clap` CLI that maps subcommands to the existing 9 D-Bus methods (with `--json` for fleet/Ansible).
|
||
|
||
**Tech Stack:**
|
||
- Phase 3: `ctap-hid-fido2 = "3"` (already in workspace deps), `hex = "0.4"` for the pam_u2f format encoder. Hardware-dependent paths gated behind `#[cfg(target_os = "linux")]` plus a runtime check.
|
||
- Phase 6: ANSI C against `<security/pam_modules.h>` and `<security/pam_ext.h>`. Build via `pam/Makefile` (already exists).
|
||
- Phase 7: `clap = "4"` with `derive` (workspace dep), `zbus = "4"` (workspace dep), `serde_json` for `--json` output.
|
||
|
||
**Reference:**
|
||
- Master plan & lane diagram: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Parallel Execution Lanes.
|
||
- Design doc: [2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) — § Trust boundary, § PAM and policy details.
|
||
|
||
---
|
||
|
||
## Conventions
|
||
|
||
- One logical change per commit. Conventional prefixes: `feat:`, `test:`, `chore:`, `refactor:`. Lane prefix on the scope: `feat(daemon-fido):`, `feat(pam):`, `feat(cli):`.
|
||
- Always commit with `--no-gpg-sign`.
|
||
- TDD for everything mechanical. Real-hardware paths in Phase 3 are exempt — they get a compile-clean gate plus a "manual smoke pending Phase 14 VM" note.
|
||
- After each commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test -p authforge-{common,daemon,cli}`. PAM lane verifies via `make -C pam` (deferred if `libpam0g-dev` not installed; already documented in BUILDING.md).
|
||
- Bundle execution order suggestion: **C (CLI) → B (PAM) → A (FIDO2)**. CLI is the lowest-risk lane (pure Rust, talks settled contract); PAM is small and self-contained; FIDO2 is the largest with real-hardware caveats. Order can shift if blocked.
|
||
|
||
---
|
||
|
||
## Lane A: Phase 3 — FIDO2 Enrollment Backend
|
||
|
||
### Task A1: Add `ctap-hid-fido2` + `hex` to daemon deps
|
||
|
||
**Files:**
|
||
- Modify: `Cargo.toml` (workspace root) — add `hex = "0.4"`.
|
||
- Modify: `daemon/Cargo.toml` — add `ctap-hid-fido2 = { workspace = true }` and `hex = { workspace = true }`.
|
||
|
||
`ctap-hid-fido2` is already in workspace deps from Phase 0 but never pulled into a member crate; the resolver hasn't even fetched it. First use is in this lane.
|
||
|
||
**Step 1:** Append to root `Cargo.toml` `[workspace.dependencies]`:
|
||
|
||
```toml
|
||
hex = "0.4"
|
||
```
|
||
|
||
**Step 2:** Append to `daemon/Cargo.toml` `[dependencies]`:
|
||
|
||
```toml
|
||
ctap-hid-fido2 = { workspace = true }
|
||
hex = { workspace = true }
|
||
```
|
||
|
||
**Step 3:** `cargo check -p authforge-daemon`. Expected: clean (deps unused, but the compile fetches them).
|
||
|
||
**Step 4:** Commit.
|
||
|
||
```bash
|
||
git add Cargo.toml Cargo.lock daemon/Cargo.toml
|
||
git commit --no-gpg-sign -m "chore: add ctap-hid-fido2 and hex deps for Phase 3"
|
||
```
|
||
|
||
---
|
||
|
||
### Task A2: pam_u2f line-format encoder
|
||
|
||
**Files:**
|
||
- Create: `daemon/src/fido/mod.rs`
|
||
- Create: `daemon/src/fido/format.rs`
|
||
- Modify: `daemon/src/main.rs` (add `mod fido;`)
|
||
|
||
**Background:** pam_u2f file format per its source:
|
||
|
||
```
|
||
username:keyHandle,publicKey,COSEType,Attributes[:keyHandle,publicKey,COSEType,Attributes...]
|
||
```
|
||
|
||
- `keyHandle` and `publicKey` are lowercase hex.
|
||
- `COSEType` is `es256` (COSE alg -7) or `eddsa` (-8).
|
||
- `Attributes` is one of `+presence`, `+verification`, `+pin` joined with `,` if multiple. We always emit `+presence` for v1 (touch required); UV / PIN attributes land in Phase 11 alongside TOTP.
|
||
|
||
**Step 1:** Create `daemon/src/fido/mod.rs`:
|
||
|
||
```rust
|
||
pub(crate) mod format;
|
||
```
|
||
|
||
**Step 2:** Failing test in `daemon/src/fido/format.rs`:
|
||
|
||
```rust
|
||
//! Convert a make_credential result into a pam_u2f keys file line.
|
||
|
||
#[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,
|
||
}
|
||
|
||
#[allow(dead_code)] // wired through fido/enroll.rs in Task A5.
|
||
impl PamU2fCred {
|
||
/// Encode as a pam_u2f file *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,");
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3:** Add `mod fido;` to `daemon/src/main.rs`.
|
||
|
||
**Step 4:** Run `cargo test -p authforge-daemon fido::format`. Expected: 2 PASS.
|
||
|
||
**Step 5:** Commit.
|
||
|
||
```bash
|
||
git add daemon/src/fido/mod.rs daemon/src/fido/format.rs daemon/src/main.rs
|
||
git commit --no-gpg-sign -m "feat(daemon-fido): pam_u2f line-format encoder"
|
||
```
|
||
|
||
---
|
||
|
||
### Task A3: `Authenticator` trait + types
|
||
|
||
**Files:**
|
||
- Create: `daemon/src/fido/authenticator.rs`
|
||
- Modify: `daemon/src/fido/mod.rs`
|
||
|
||
**Step 1:** Add `pub(crate) mod authenticator;` to `daemon/src/fido/mod.rs`.
|
||
|
||
**Step 2:** Create `daemon/src/fido/authenticator.rs`:
|
||
|
||
```rust
|
||
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`.
|
||
pub(crate) trait Authenticator: Send + Sync {
|
||
/// List devices currently visible to the host.
|
||
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>;
|
||
}
|
||
```
|
||
|
||
**Step 3:** `cargo build -p authforge-daemon`. Expected: clean (no impls yet, but the trait compiles).
|
||
|
||
**Step 4:** Commit.
|
||
|
||
```bash
|
||
git add daemon/src/fido/mod.rs daemon/src/fido/authenticator.rs
|
||
git commit --no-gpg-sign -m "feat(daemon-fido): Authenticator trait with DiscoveredDevice and AuthnError"
|
||
```
|
||
|
||
---
|
||
|
||
### Task A4: `MockAuthenticator` for unit tests
|
||
|
||
**Files:**
|
||
- Create: `daemon/src/fido/mock.rs`
|
||
- Modify: `daemon/src/fido/mod.rs`
|
||
|
||
**Step 1:** Add `pub(crate) mod mock;` to `daemon/src/fido/mod.rs`.
|
||
|
||
**Step 2:** Failing test plus impl in `daemon/src/fido/mock.rs`:
|
||
|
||
```rust
|
||
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 {
|
||
#[allow(dead_code)] // used by Task A6.
|
||
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);
|
||
matches!(r, Err(AuthnError::NoDevice));
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3:** Run, confirm 3 PASS.
|
||
|
||
**Step 4:** Commit.
|
||
|
||
```bash
|
||
git add daemon/src/fido/mod.rs daemon/src/fido/mock.rs
|
||
git commit --no-gpg-sign -m "feat(daemon-fido): MockAuthenticator for unit tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task A5: `CtapAuthenticator` real impl wrapping `ctap-hid-fido2`
|
||
|
||
**Files:**
|
||
- Create: `daemon/src/fido/ctap.rs`
|
||
- Modify: `daemon/src/fido/mod.rs`
|
||
|
||
**ctap-hid-fido2 3.5.x reference (verified against `~/.cargo/registry/src/index.crates.io-*/ctap-hid-fido2-3.5.9/src/`):**
|
||
- `ctap_hid_fido2::get_fidokey_devices() -> Vec<HidInfo>`
|
||
- `FidoKeyHidFactory::create(cfg: &LibCfg) -> Result<FidoKeyHid>`
|
||
- `FidoKeyHid::make_credential(rpid, challenge, pin) -> Result<Attestation>`
|
||
- `Attestation.credential_descriptor.id: Vec<u8>` is the keyHandle.
|
||
- `Attestation.credential_publickey.der: Vec<u8>` is the SPKI-DER public key (what pam_u2f expects).
|
||
- COSE algorithm sits in `Attestation.attstmt_alg: i32` — `-7` = es256, `-8` = eddsa. Anything else → return `AuthnError::Backend("unsupported COSE alg N")`.
|
||
|
||
**Step 1:** Add `pub(crate) mod ctap;` to `daemon/src/fido/mod.rs`.
|
||
|
||
**Step 2:** Create `daemon/src/fido/ctap.rs`:
|
||
|
||
```rust
|
||
use crate::fido::authenticator::{Authenticator, AuthnError, DiscoveredDevice};
|
||
use crate::fido::format::{CoseType, PamU2fCred};
|
||
use authforge_common::types::Transport;
|
||
|
||
pub(crate) struct CtapAuthenticator;
|
||
|
||
impl CtapAuthenticator {
|
||
#[allow(dead_code)] // selected by main.rs in Task A6.
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
fn random_challenge() -> Vec<u8> {
|
||
// pam_u2f doesn't validate the challenge for *enrollment* (the
|
||
// attestation signature does, but pam_u2f only stores the credential).
|
||
// Still, generate 32 bytes of randomness so we don't trip authenticator
|
||
// anti-replay heuristics.
|
||
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> {
|
||
// ctap-hid-fido2 is sync; calling on the tokio runtime is fine because
|
||
// device probing finishes in milliseconds. If it grows, wrap in
|
||
// spawn_blocking at the call site (state.rs).
|
||
let devices = ctap_hid_fido2::get_fidokey_devices();
|
||
Ok(devices
|
||
.into_iter()
|
||
.map(|d| DiscoveredDevice {
|
||
name: format!("{} {}", d.product_string, d.serial_number),
|
||
transport: Transport::Usb,
|
||
path: d.path,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
fn make_credential(
|
||
&self,
|
||
rp_id: &str,
|
||
_user: &str,
|
||
pin: Option<&str>,
|
||
) -> Result<PamU2fCred, AuthnError> {
|
||
use ctap_hid_fido2::fidokey::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}");
|
||
if msg.to_lowercase().contains("pin") {
|
||
AuthnError::PinRequired
|
||
} else if msg.to_lowercase().contains("cancel") || msg.to_lowercase().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,
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3:** `cargo build -p authforge-daemon` (no test — real hardware required). Expected: clean compile.
|
||
|
||
If `LibCfg::init` doesn't exist, check the actual constructor in
|
||
`~/.cargo/registry/src/index.crates.io-*/ctap-hid-fido2-3.5.9/src/lib.rs` and adapt — it may be `LibCfg::default()` or a builder.
|
||
|
||
**Step 4:** Commit.
|
||
|
||
```bash
|
||
git add daemon/src/fido/mod.rs daemon/src/fido/ctap.rs
|
||
git commit --no-gpg-sign -m "feat(daemon-fido): CtapAuthenticator wrapping ctap-hid-fido2 (hardware-dep)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task A6: Wire enrollment into `AppState::add_credential`
|
||
|
||
**Files:**
|
||
- Modify: `daemon/src/state.rs` — accept an `Arc<dyn Authenticator>` at open-time; switch `add_credential` to use real format.
|
||
- Modify: `daemon/src/dbus.rs` — `enroll_own` / `enroll_other` call the new path.
|
||
- Modify: `daemon/src/main.rs` — pick `CtapAuthenticator` for prod.
|
||
|
||
**Why:** Phase 2 stub stored cred ID as opaque "stub-{user}-{ts}". Now the format is real `keyHandle,publicKey,cose,attrs`.
|
||
|
||
**Step 1:** Modify `state.rs`:
|
||
|
||
```rust
|
||
// New field:
|
||
pub struct AppState {
|
||
policy: PolicyStore,
|
||
pending: PendingStore,
|
||
userdb: Mutex<UserDb>,
|
||
authn: Arc<dyn Authenticator>,
|
||
}
|
||
|
||
impl AppState {
|
||
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)?);
|
||
Ok(Self { policy, pending, userdb, authn })
|
||
}
|
||
```
|
||
|
||
Replace `add_credential` to call the authenticator and write the real pam_u2f line:
|
||
|
||
```rust
|
||
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);
|
||
|
||
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, 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),
|
||
})
|
||
}
|
||
```
|
||
|
||
Add to `StateError`:
|
||
|
||
```rust
|
||
#[error("authenticator: {0}")]
|
||
Authn(String),
|
||
```
|
||
|
||
Drop the Phase 2 `add_credential(user, c: Credential)` method — `dbus.rs` was its only caller.
|
||
|
||
**Step 2:** In `dbus.rs`, replace the body of `enroll_own` and `enroll_other` to call `self.state.enroll(&user, &nickname).await`:
|
||
|
||
```rust
|
||
async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result<Credential> {
|
||
self.authz("io.dangerousthings.AuthForge.enroll-own").await?;
|
||
self.state
|
||
.enroll(&user, &nickname)
|
||
.await
|
||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||
}
|
||
```
|
||
|
||
(Same for `enroll_other` with the different polkit action string.)
|
||
|
||
**Step 3:** In `main.rs`:
|
||
|
||
```rust
|
||
let authn: Arc<dyn fido::authenticator::Authenticator> =
|
||
Arc::new(fido::ctap::CtapAuthenticator::new());
|
||
let state = Arc::new(state::AppState::open(cfg, authn)?);
|
||
```
|
||
|
||
**Step 4:** Update `state.rs` and `dbus.rs` tests to inject `Arc::new(MockAuthenticator::with_one_yubikey())`. The dbus `p2p_pair` constructs `AppState::open(cfg, Arc::new(MockAuthenticator::with_one_yubikey()))`.
|
||
|
||
**Step 5:** Run `cargo test -p authforge-daemon`. Expected: all green. Tests that previously asserted `cred.id == "stub-..."` must now match a hex pattern; update assertions to `assert!(cred.id.chars().all(|c| c.is_ascii_hexdigit()))`.
|
||
|
||
**Step 6:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||
|
||
**Step 7:** Commit.
|
||
|
||
```bash
|
||
git add daemon/src/state.rs daemon/src/dbus.rs daemon/src/main.rs
|
||
git commit --no-gpg-sign -m "feat(daemon-fido): wire enrollment through Authenticator + write pam_u2f format"
|
||
```
|
||
|
||
---
|
||
|
||
### Task A7: D-Bus signals for enrollment progress
|
||
|
||
**Files:**
|
||
- Modify: `daemon/src/dbus.rs` — add `DeviceFound`, `TouchRequired`, `EnrollmentSucceeded`, `EnrollmentFailed` signals.
|
||
|
||
**Why deferred until A6 lands:** signals report on the `enroll` flow — until that flow exists, there's nothing to signal. Phase 8 (GUI) consumes these.
|
||
|
||
**Step 1:** Add signal declarations to the `#[zbus::interface]` impl in `dbus.rs`:
|
||
|
||
```rust
|
||
#[zbus(signal)]
|
||
pub async fn device_found(
|
||
ctx: &zbus::object_server::SignalContext<'_>,
|
||
name: String,
|
||
transport: String,
|
||
) -> zbus::Result<()>;
|
||
|
||
#[zbus(signal)]
|
||
pub async fn touch_required(
|
||
ctx: &zbus::object_server::SignalContext<'_>,
|
||
device: String,
|
||
) -> zbus::Result<()>;
|
||
|
||
#[zbus(signal)]
|
||
pub async fn enrollment_succeeded(
|
||
ctx: &zbus::object_server::SignalContext<'_>,
|
||
cred_id: String,
|
||
) -> zbus::Result<()>;
|
||
|
||
#[zbus(signal)]
|
||
pub async fn enrollment_failed(
|
||
ctx: &zbus::object_server::SignalContext<'_>,
|
||
reason: String,
|
||
) -> zbus::Result<()>;
|
||
```
|
||
|
||
**Step 2:** In `enroll_own` / `enroll_other`, emit signals around the call. Use `#[zbus(signal_context)]` to get the context inside an interface method:
|
||
|
||
```rust
|
||
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?;
|
||
// Phase 3 emits a synthetic "touch required" before calling. Phase 8 will
|
||
// surface this in the GUI; until then it shows up in busctl monitor.
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3:** Add an integration test mirroring `policy_changed_signal_fires_on_emit` for `EnrollmentSucceeded`:
|
||
|
||
```rust
|
||
#[tokio::test]
|
||
async fn enrollment_succeeded_signal_fires() {
|
||
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");
|
||
}
|
||
```
|
||
|
||
**Step 4:** Run `cargo test -p authforge-daemon`. Expected: green.
|
||
|
||
**Step 5:** Commit.
|
||
|
||
```bash
|
||
git add daemon/src/dbus.rs
|
||
git commit --no-gpg-sign -m "feat(daemon-fido): emit DeviceFound/TouchRequired/EnrollmentSucceeded/Failed signals"
|
||
```
|
||
|
||
---
|
||
|
||
## Lane B: Phase 6 — `pam_authforge_pending.so`
|
||
|
||
### Task B1: Replace stub with real pending-flag check
|
||
|
||
**Files:**
|
||
- Modify: `pam/pam_authforge_pending.c` (full rewrite of the stub)
|
||
|
||
**Behavior per design doc § PAM module backstop:**
|
||
1. Read `PAM_USER` from the conv stack.
|
||
2. Reject usernames containing `/`, `\0`, `..`, or that are `.`/empty.
|
||
3. `stat("/var/lib/authforge/pending/<user>")`:
|
||
- File present → `pam_error()` with the user-facing message + return `PAM_AUTH_ERR`.
|
||
- `ENOENT` → return `PAM_IGNORE` (let the rest of the stack decide).
|
||
- Other errors → `pam_syslog(LOG_ERR, ...)` + return `PAM_AUTH_ERR` (fail closed).
|
||
4. `pam_sm_setcred` always returns `PAM_SUCCESS`.
|
||
|
||
**Recovery code** (Phase 12) — for Phase 6 we add a hook that the recovery flow can wire up later, but the hook is a no-op for now:
|
||
|
||
```c
|
||
static int recovery_code_matches(const char *user, const char *code) {
|
||
(void)user; (void)code;
|
||
return 0; // placeholder; Phase 12 implements Argon2id constant-time check.
|
||
}
|
||
```
|
||
|
||
**Step 1:** Replace `pam/pam_authforge_pending.c`:
|
||
|
||
```c
|
||
#define PAM_SM_AUTH
|
||
#include <security/pam_modules.h>
|
||
#include <security/pam_ext.h>
|
||
|
||
#include <errno.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <syslog.h>
|
||
#include <sys/stat.h>
|
||
|
||
#define PENDING_DIR "/var/lib/authforge/pending/"
|
||
#define USER_MSG "Account setup incomplete. Please complete enrollment in the Authentication app."
|
||
|
||
static int username_is_safe(const char *u) {
|
||
if (u == NULL || u[0] == '\0') return 0;
|
||
if (strcmp(u, ".") == 0 || strcmp(u, "..") == 0) return 0;
|
||
for (const char *p = u; *p; ++p) {
|
||
if (*p == '/' || *p == '\0') return 0;
|
||
}
|
||
/* Also reject any '..' substring just to be safe against weird locales. */
|
||
if (strstr(u, "..") != NULL) return 0;
|
||
return 1;
|
||
}
|
||
|
||
static int pending_flag_present(const char *user) {
|
||
char path[1024];
|
||
int n = snprintf(path, sizeof(path), "%s%s", PENDING_DIR, user);
|
||
if (n < 0 || (size_t)n >= sizeof(path)) return -1;
|
||
struct stat st;
|
||
if (stat(path, &st) == 0) return 1;
|
||
if (errno == ENOENT) return 0;
|
||
return -1;
|
||
}
|
||
|
||
PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags,
|
||
int argc, const char **argv) {
|
||
(void)flags; (void)argc; (void)argv;
|
||
|
||
const char *user = NULL;
|
||
if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || user == NULL) {
|
||
pam_syslog(pamh, LOG_ERR, "authforge_pending: pam_get_user failed");
|
||
return PAM_AUTH_ERR;
|
||
}
|
||
if (!username_is_safe(user)) {
|
||
pam_syslog(pamh, LOG_ERR, "authforge_pending: rejected unsafe user %s", user);
|
||
return PAM_AUTH_ERR;
|
||
}
|
||
|
||
int present = pending_flag_present(user);
|
||
if (present < 0) {
|
||
pam_syslog(pamh, LOG_ERR, "authforge_pending: stat error for %s", user);
|
||
return PAM_AUTH_ERR; /* fail closed */
|
||
}
|
||
if (present == 0) {
|
||
return PAM_IGNORE;
|
||
}
|
||
|
||
pam_error(pamh, "%s", USER_MSG);
|
||
return PAM_AUTH_ERR;
|
||
}
|
||
|
||
PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags,
|
||
int argc, const char **argv) {
|
||
(void)pamh; (void)flags; (void)argc; (void)argv;
|
||
return PAM_SUCCESS;
|
||
}
|
||
```
|
||
|
||
**Step 2:** Build verification:
|
||
|
||
```bash
|
||
make -C pam
|
||
```
|
||
|
||
If `libpam0g-dev` is not installed, the build fails with `<security/pam_modules.h>: No such file or directory`. That matches the deferred-build convention in BUILDING.md; record the deferral and move on.
|
||
|
||
**Step 3:** Commit.
|
||
|
||
```bash
|
||
git add pam/pam_authforge_pending.c
|
||
git commit --no-gpg-sign -m "feat(pam): real pending-flag check with path-traversal guard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task B2: Test plan via `pamtester` (manual, deferred)
|
||
|
||
**Files:**
|
||
- Create: `pam/TESTING.md`
|
||
|
||
**Step 1:** Create `pam/TESTING.md` documenting the manual smoke test that runs in a VM with `libpam0g-dev` and `pamtester` installed:
|
||
|
||
```markdown
|
||
# Testing pam_authforge_pending.so
|
||
|
||
Smoke test (run in a VM or container with `libpam0g-dev` + `pamtester` installed):
|
||
|
||
```bash
|
||
sudo make -C pam install # builds + installs to /usr/lib/.../security/
|
||
sudo cp pam/test/authforge.pamd /etc/pam.d/authforge
|
||
|
||
# Pending case: should be denied with the user-facing message.
|
||
sudo mkdir -p /var/lib/authforge/pending
|
||
sudo touch /var/lib/authforge/pending/$USER
|
||
pamtester authforge "$USER" authenticate
|
||
# expect: "Account setup incomplete..." + non-zero exit
|
||
|
||
# Cleared case: should fall through to PAM_IGNORE -> success via the rest of the stack.
|
||
sudo rm /var/lib/authforge/pending/$USER
|
||
pamtester authforge "$USER" authenticate
|
||
# expect: success
|
||
```
|
||
|
||
Path-traversal regression:
|
||
|
||
```bash
|
||
# These usernames must be rejected with a logged error and PAM_AUTH_ERR:
|
||
for u in "../etc" "a/b" "" "." ".." "x..y"; do
|
||
pamtester -v authforge "$u" authenticate 2>&1 | head -3
|
||
done
|
||
```
|
||
|
||
CI cannot run `pamtester` against a real PAM stack without root and a VM, so
|
||
this is the last manual gate before Phase 14 (PPA build).
|
||
```
|
||
|
||
**Step 2:** Add a minimal `pam/test/authforge.pamd` for use by the smoke test:
|
||
|
||
```
|
||
# /etc/pam.d/authforge — only used by manual pamtester runs.
|
||
auth required pam_authforge_pending.so
|
||
auth required pam_unix.so
|
||
account required pam_unix.so
|
||
```
|
||
|
||
**Step 3:** Commit.
|
||
|
||
```bash
|
||
git add pam/TESTING.md pam/test/authforge.pamd
|
||
git commit --no-gpg-sign -m "docs(pam): smoke-test recipe for pamtester (manual, VM-gated)"
|
||
```
|
||
|
||
---
|
||
|
||
## Lane C: Phase 7 — `authforgectl` CLI
|
||
|
||
### Task C1: Subcommand structure
|
||
|
||
**Files:**
|
||
- Create: `cli/src/commands/mod.rs`
|
||
- Modify: `cli/src/main.rs` — replace the Phase 0 stub with full clap derives.
|
||
|
||
**Subcommands per master plan § Phase 7:**
|
||
|
||
```
|
||
authforgectl status
|
||
authforgectl enroll [--user USER] [--nickname NAME]
|
||
authforgectl list [--user USER]
|
||
authforgectl remove [--user USER] CRED_ID
|
||
authforgectl policy show
|
||
authforgectl policy set <stack> <mode> [--methods METHOD,...]
|
||
authforgectl policy apply [--force-i-know-what-im-doing]
|
||
authforgectl policy validate
|
||
authforgectl pending set USER [--methods METHOD,...]
|
||
authforgectl pending clear USER
|
||
authforgectl pending list
|
||
authforgectl recovery generate USER
|
||
authforgectl recovery list USER
|
||
```
|
||
|
||
**Step 1:** Replace `cli/src/main.rs`:
|
||
|
||
```rust
|
||
use anyhow::Result;
|
||
use clap::{Parser, Subcommand};
|
||
|
||
mod commands;
|
||
|
||
#[derive(Parser)]
|
||
#[command(name = "authforgectl", version, about = "Manage authforge configuration")]
|
||
struct Cli {
|
||
/// Emit machine-parseable JSON.
|
||
#[arg(long, global = true)]
|
||
json: bool,
|
||
|
||
#[command(subcommand)]
|
||
cmd: Cmd,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum Cmd {
|
||
/// Show daemon + policy status.
|
||
Status,
|
||
/// Enroll a security key for a user (interactive).
|
||
Enroll {
|
||
#[arg(long)]
|
||
user: Option<String>,
|
||
#[arg(long)]
|
||
nickname: Option<String>,
|
||
},
|
||
/// List enrolled credentials for a user.
|
||
List {
|
||
#[arg(long)]
|
||
user: Option<String>,
|
||
},
|
||
/// Remove a credential by ID.
|
||
Remove {
|
||
#[arg(long)]
|
||
user: Option<String>,
|
||
cred_id: String,
|
||
},
|
||
/// Manage MFA policy.
|
||
Policy {
|
||
#[command(subcommand)]
|
||
cmd: PolicyCmd,
|
||
},
|
||
/// Manage first-login pending flags.
|
||
Pending {
|
||
#[command(subcommand)]
|
||
cmd: PendingCmd,
|
||
},
|
||
/// Manage recovery codes.
|
||
Recovery {
|
||
#[command(subcommand)]
|
||
cmd: RecoveryCmd,
|
||
},
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum PolicyCmd {
|
||
Show,
|
||
Set {
|
||
stack: String,
|
||
/// disabled | optional | required
|
||
mode: String,
|
||
#[arg(long, value_delimiter = ',')]
|
||
methods: Vec<String>,
|
||
},
|
||
Apply {
|
||
#[arg(long = "force-i-know-what-im-doing")]
|
||
force: bool,
|
||
},
|
||
Validate,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum PendingCmd {
|
||
Set {
|
||
user: String,
|
||
#[arg(long, value_delimiter = ',')]
|
||
methods: Vec<String>,
|
||
},
|
||
Clear {
|
||
user: String,
|
||
},
|
||
List,
|
||
}
|
||
|
||
#[derive(Subcommand)]
|
||
enum RecoveryCmd {
|
||
Generate { user: String },
|
||
List { user: String },
|
||
}
|
||
|
||
#[tokio::main]
|
||
async fn main() -> Result<()> {
|
||
let cli = Cli::parse();
|
||
commands::dispatch(cli.json, cli.cmd).await
|
||
}
|
||
```
|
||
|
||
**Step 2:** Create `cli/src/commands/mod.rs` with a stub dispatcher:
|
||
|
||
```rust
|
||
use anyhow::Result;
|
||
|
||
pub(crate) async fn dispatch(_json: bool, cmd: super::Cmd) -> Result<()> {
|
||
eprintln!("authforgectl: subcommand not yet wired in this build");
|
||
let _ = cmd;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
**Step 3:** `cargo build -p authforgectl`. Expected: clean.
|
||
|
||
**Step 4:** Add a basic clap-parser test in `cli/src/main.rs`:
|
||
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use clap::CommandFactory;
|
||
|
||
#[test]
|
||
fn cli_definition_is_valid() {
|
||
Cli::command().debug_assert();
|
||
}
|
||
|
||
#[test]
|
||
fn parse_status_subcommand() {
|
||
let c = Cli::try_parse_from(["authforgectl", "status"]).unwrap();
|
||
assert!(matches!(c.cmd, Cmd::Status));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_policy_set_with_methods() {
|
||
let c = Cli::try_parse_from([
|
||
"authforgectl", "policy", "set", "sudo", "required",
|
||
"--methods", "fido2,totp",
|
||
])
|
||
.unwrap();
|
||
match c.cmd {
|
||
Cmd::Policy { cmd: PolicyCmd::Set { stack, mode, methods } } => {
|
||
assert_eq!(stack, "sudo");
|
||
assert_eq!(mode, "required");
|
||
assert_eq!(methods, vec!["fido2".to_string(), "totp".to_string()]);
|
||
}
|
||
_ => panic!("wrong subcommand"),
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 5:** `cargo test -p authforgectl`. Expected: 3 PASS.
|
||
|
||
**Step 6:** Commit.
|
||
|
||
```bash
|
||
git add cli/src/main.rs cli/src/commands/mod.rs
|
||
git commit --no-gpg-sign -m "feat(cli): authforgectl subcommand structure with clap derive"
|
||
```
|
||
|
||
---
|
||
|
||
### Task C2: D-Bus client wrapper
|
||
|
||
**Files:**
|
||
- Create: `cli/src/bus.rs`
|
||
- Modify: `cli/Cargo.toml` — add `authforge-common`, `serde`, `serde_json` deps if not already present.
|
||
|
||
**Step 1:** Update `cli/Cargo.toml` `[dependencies]`:
|
||
|
||
```toml
|
||
authforge-common = { path = "../common" }
|
||
zbus = { workspace = true }
|
||
serde = { workspace = true }
|
||
serde_json = { workspace = true }
|
||
```
|
||
|
||
(zbus is already there from Phase 0.)
|
||
|
||
**Step 2:** Create `cli/src/bus.rs`:
|
||
|
||
```rust
|
||
use anyhow::{Context, Result};
|
||
use authforge_common::policy::Policy;
|
||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult};
|
||
|
||
pub(crate) struct Daemon {
|
||
proxy: zbus::Proxy<'static>,
|
||
}
|
||
|
||
impl Daemon {
|
||
pub async fn connect() -> Result<Self> {
|
||
let conn = zbus::Connection::system()
|
||
.await
|
||
.context("connecting to system D-Bus")?;
|
||
let proxy = zbus::Proxy::new(
|
||
&conn,
|
||
"io.dangerousthings.AuthForge",
|
||
"/io/dangerousthings/AuthForge",
|
||
"io.dangerousthings.AuthForge1",
|
||
)
|
||
.await?;
|
||
// The proxy holds a borrowed connection; we need 'static. Re-build
|
||
// an owned proxy.
|
||
let proxy: zbus::Proxy<'static> =
|
||
zbus::Proxy::new_owned(conn, /* dest */ "io.dangerousthings.AuthForge".into(),
|
||
/* path */ "/io/dangerousthings/AuthForge".into(),
|
||
/* iface */ "io.dangerousthings.AuthForge1".into())
|
||
.await?;
|
||
let _ = proxy; // see note below
|
||
// zbus 4 actually exposes ProxyBuilder for the owned case; check
|
||
// ~/.cargo/registry/src/index.crates.io-*/zbus-4.4.0/src/proxy.rs at
|
||
// implementation time and adapt.
|
||
Ok(Self { proxy: todo!("replace with real owned-proxy ctor") })
|
||
}
|
||
```
|
||
|
||
> **Note on the Proxy lifetime:** the snippet above is wrong-shaped; zbus 4 has `Proxy::new` returning a `Proxy<'_>` borrowing the connection. To keep the connection alive alongside the proxy, hold both:
|
||
|
||
```rust
|
||
pub(crate) struct Daemon {
|
||
_conn: zbus::Connection,
|
||
proxy: zbus::Proxy<'static>, // 'static via Proxy::into_owned()
|
||
}
|
||
|
||
impl Daemon {
|
||
pub async fn connect() -> Result<Self> {
|
||
let conn = zbus::Connection::system().await?;
|
||
let proxy = zbus::Proxy::new(
|
||
&conn,
|
||
"io.dangerousthings.AuthForge",
|
||
"/io/dangerousthings/AuthForge",
|
||
"io.dangerousthings.AuthForge1",
|
||
)
|
||
.await?
|
||
.into_owned();
|
||
Ok(Self { _conn: conn, proxy })
|
||
}
|
||
}
|
||
```
|
||
|
||
(`Proxy::into_owned()` exists in zbus 4 — confirm against the registry source.)
|
||
|
||
**Step 3:** Add the method calls:
|
||
|
||
```rust
|
||
impl Daemon {
|
||
pub async fn list_credentials(&self, user: &str) -> Result<Vec<Credential>> {
|
||
Ok(self.proxy.call("ListCredentials", &(user,)).await?)
|
||
}
|
||
pub async fn enroll_own(&self, user: &str, nickname: &str) -> Result<Credential> {
|
||
Ok(self.proxy.call("EnrollOwn", &(user, nickname)).await?)
|
||
}
|
||
pub async fn remove_own(&self, user: &str, cred_id: &str) -> Result<()> {
|
||
Ok(self.proxy.call("RemoveOwn", &(user, cred_id)).await?)
|
||
}
|
||
pub async fn get_policy(&self) -> Result<Policy> {
|
||
Ok(self.proxy.call("GetPolicy", &()).await?)
|
||
}
|
||
pub async fn set_policy(&self, p: &Policy) -> Result<PolicyApplyResult> {
|
||
Ok(self.proxy.call("SetPolicy", &(p,)).await?)
|
||
}
|
||
pub async fn set_pending_flag(&self, user: &str, flag: &PendingFlag) -> Result<()> {
|
||
Ok(self.proxy.call("SetPendingFlag", &(user, flag)).await?)
|
||
}
|
||
pub async fn clear_pending_flag(&self, user: &str) -> Result<()> {
|
||
Ok(self.proxy.call("ClearPendingFlag", &(user,)).await?)
|
||
}
|
||
pub async fn generate_recovery_code(&self, user: &str) -> Result<String> {
|
||
Ok(self.proxy.call("GenerateRecoveryCode", &(user,)).await?)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4:** Add `mod bus;` to `cli/src/main.rs`.
|
||
|
||
**Step 5:** `cargo build -p authforgectl`. Expected: clean.
|
||
|
||
**Step 6:** Commit.
|
||
|
||
```bash
|
||
git add cli/src/bus.rs cli/src/main.rs cli/Cargo.toml
|
||
git commit --no-gpg-sign -m "feat(cli): D-Bus client wrapper around AuthForge interface"
|
||
```
|
||
|
||
---
|
||
|
||
### Task C3: Implement read-side subcommands (`status`, `list`, `policy show`)
|
||
|
||
**Files:**
|
||
- Modify: `cli/src/commands/mod.rs`
|
||
|
||
**Step 1:** Replace the dispatcher to handle three read commands:
|
||
|
||
```rust
|
||
use anyhow::Result;
|
||
use crate::bus::Daemon;
|
||
|
||
pub(crate) async fn dispatch(json: bool, cmd: crate::Cmd) -> Result<()> {
|
||
let d = Daemon::connect().await?;
|
||
|
||
match cmd {
|
||
crate::Cmd::Status => {
|
||
let pol = d.get_policy().await?;
|
||
if json {
|
||
println!("{}", serde_json::to_string_pretty(&pol)?);
|
||
} else {
|
||
println!("authforge daemon: connected");
|
||
println!("policy stacks configured: {}", pol.stacks.len());
|
||
for (name, sp) in &pol.stacks {
|
||
println!(" {}: {:?} ({:?})", name, sp.mode, sp.methods);
|
||
}
|
||
}
|
||
}
|
||
crate::Cmd::List { user } => {
|
||
let user = user.unwrap_or_else(|| whoami_or_user());
|
||
let creds = d.list_credentials(&user).await?;
|
||
if json {
|
||
println!("{}", serde_json::to_string_pretty(&creds)?);
|
||
} else {
|
||
if creds.is_empty() {
|
||
println!("no credentials for {user}");
|
||
} else {
|
||
for c in creds {
|
||
println!("{}\t{}\t{:?}\t{:?}", c.id, c.nickname, c.method, c.transport);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
crate::Cmd::Policy { cmd: crate::PolicyCmd::Show } => {
|
||
let pol = d.get_policy().await?;
|
||
if json {
|
||
println!("{}", serde_json::to_string_pretty(&pol)?);
|
||
} else {
|
||
println!("{}", toml::to_string_pretty(&pol).unwrap_or_default());
|
||
}
|
||
}
|
||
// Other branches added in Tasks C4 and C5.
|
||
_ => anyhow::bail!("subcommand not yet implemented"),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn whoami_or_user() -> String {
|
||
std::env::var("USER").or_else(|_| std::env::var("LOGNAME")).unwrap_or_else(|_| "unknown".into())
|
||
}
|
||
```
|
||
|
||
Add `toml` to `cli/Cargo.toml` `[dependencies]`:
|
||
|
||
```toml
|
||
toml = { workspace = true }
|
||
```
|
||
|
||
**Step 2:** `cargo build -p authforgectl && cargo test -p authforgectl`. Expected: clean + 3 prior parser tests still pass.
|
||
|
||
**Step 3:** Commit.
|
||
|
||
```bash
|
||
git add cli/src/commands/mod.rs cli/Cargo.toml
|
||
git commit --no-gpg-sign -m "feat(cli): status/list/policy-show subcommands wired to D-Bus"
|
||
```
|
||
|
||
---
|
||
|
||
### Task C4: Write-side subcommands (`enroll`, `remove`, `policy set/apply`)
|
||
|
||
**Files:**
|
||
- Modify: `cli/src/commands/mod.rs`
|
||
|
||
**Step 1:** Add to the match arms:
|
||
|
||
```rust
|
||
crate::Cmd::Enroll { user, nickname } => {
|
||
let user = user.unwrap_or_else(whoami_or_user);
|
||
let nickname = nickname.unwrap_or_else(|| "Security Key".to_string());
|
||
let cred = d.enroll_own(&user, &nickname).await?;
|
||
if json {
|
||
println!("{}", serde_json::to_string_pretty(&cred)?);
|
||
} else {
|
||
println!("enrolled credential {} ({}) for {}", cred.id, cred.nickname, user);
|
||
}
|
||
}
|
||
crate::Cmd::Remove { user, cred_id } => {
|
||
let user = user.unwrap_or_else(whoami_or_user);
|
||
d.remove_own(&user, &cred_id).await?;
|
||
if !json {
|
||
println!("removed credential {cred_id} for {user}");
|
||
}
|
||
}
|
||
crate::Cmd::Policy { cmd: crate::PolicyCmd::Set { stack, mode, methods } } => {
|
||
use authforge_common::policy::{Policy, StackPolicy};
|
||
use authforge_common::types::{Method, Mode};
|
||
let m = match mode.as_str() {
|
||
"disabled" => Mode::Disabled,
|
||
"optional" => Mode::Optional,
|
||
"required" => Mode::Required,
|
||
other => anyhow::bail!("unknown mode: {other}"),
|
||
};
|
||
let methods: Result<Vec<Method>> = methods
|
||
.into_iter()
|
||
.map(|s| match s.as_str() {
|
||
"fido2" => Ok(Method::Fido2),
|
||
"totp" => Ok(Method::Totp),
|
||
other => Err(anyhow::anyhow!("unknown method: {other}")),
|
||
})
|
||
.collect();
|
||
let mut pol = d.get_policy().await?;
|
||
pol.stacks.insert(stack.clone(), StackPolicy { mode: m, methods: methods? });
|
||
let _ = d.set_policy(&pol).await?;
|
||
if !json {
|
||
println!("policy updated for stack {stack}");
|
||
}
|
||
}
|
||
crate::Cmd::Policy { cmd: crate::PolicyCmd::Apply { force: _ } } => {
|
||
// Phase 4 wires the apply call; until then this is a no-op that
|
||
// re-saves the current policy so the daemon emits PolicyChanged.
|
||
let pol = d.get_policy().await?;
|
||
let _ = d.set_policy(&pol).await?;
|
||
if !json {
|
||
println!("policy re-applied (Phase 4 wires real pam-auth-update)");
|
||
}
|
||
}
|
||
crate::Cmd::Policy { cmd: crate::PolicyCmd::Validate } => {
|
||
// Phase 5 wires the lockout simulator; until then validate is a
|
||
// syntax-only round-trip via TOML.
|
||
let pol = d.get_policy().await?;
|
||
let _ = toml::to_string_pretty(&pol)?;
|
||
if !json {
|
||
println!("policy is syntactically valid");
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2:** `cargo build -p authforgectl`. Clean.
|
||
|
||
**Step 3:** Commit.
|
||
|
||
```bash
|
||
git add cli/src/commands/mod.rs
|
||
git commit --no-gpg-sign -m "feat(cli): enroll/remove/policy-set/apply/validate subcommands"
|
||
```
|
||
|
||
---
|
||
|
||
### Task C5: Pending and Recovery subcommands
|
||
|
||
**Files:**
|
||
- Modify: `cli/src/commands/mod.rs`
|
||
|
||
**Step 1:** Add the remaining match arms:
|
||
|
||
```rust
|
||
crate::Cmd::Pending { cmd: crate::PendingCmd::Set { user, methods } } => {
|
||
use authforge_common::types::{Method, PendingFlag};
|
||
let methods: Result<Vec<Method>> = methods
|
||
.into_iter()
|
||
.map(|s| match s.as_str() {
|
||
"fido2" => Ok(Method::Fido2),
|
||
"totp" => Ok(Method::Totp),
|
||
other => Err(anyhow::anyhow!("unknown method: {other}")),
|
||
})
|
||
.collect();
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
let flag = PendingFlag {
|
||
required_methods: methods?,
|
||
created_unix: now,
|
||
deadline_unix: 0,
|
||
re_enroll: false,
|
||
};
|
||
d.set_pending_flag(&user, &flag).await?;
|
||
if !json {
|
||
println!("pending flag set for {user}");
|
||
}
|
||
}
|
||
crate::Cmd::Pending { cmd: crate::PendingCmd::Clear { user } } => {
|
||
d.clear_pending_flag(&user).await?;
|
||
if !json {
|
||
println!("pending flag cleared for {user}");
|
||
}
|
||
}
|
||
crate::Cmd::Pending { cmd: crate::PendingCmd::List } => {
|
||
// Phase 7 doesn't have a ListPending D-Bus method yet; print a
|
||
// hint and exit cleanly. Adding the method is a Phase 5/12
|
||
// adjustment when the GUI's "who has a pending flag?" view ships.
|
||
if !json {
|
||
println!("(ListPending D-Bus method lands when GUI needs it)");
|
||
}
|
||
}
|
||
crate::Cmd::Recovery { cmd: crate::RecoveryCmd::Generate { user } } => {
|
||
let code = d.generate_recovery_code(&user).await?;
|
||
if json {
|
||
println!("{}", serde_json::json!({ "user": user, "code": code }));
|
||
} else {
|
||
println!("{code}");
|
||
}
|
||
}
|
||
crate::Cmd::Recovery { cmd: crate::RecoveryCmd::List { user: _ } } => {
|
||
if !json {
|
||
println!("(ListRecovery lands in Phase 12)");
|
||
}
|
||
}
|
||
```
|
||
|
||
Drop the trailing `_ => anyhow::bail!(...)` arm since all arms are now covered.
|
||
|
||
**Step 2:** `cargo build -p authforgectl && cargo test -p authforgectl && cargo clippy -p authforgectl --all-targets -- -D warnings`. Clean.
|
||
|
||
**Step 3:** Commit.
|
||
|
||
```bash
|
||
git add cli/src/commands/mod.rs
|
||
git commit --no-gpg-sign -m "feat(cli): pending and recovery subcommands"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3+6+7 Acceptance Gate
|
||
|
||
Verify before tagging:
|
||
|
||
- [ ] `cargo build --workspace --release` succeeds (excluding gui).
|
||
- [ ] `cargo test -p authforge-common` — 13 PASS.
|
||
- [ ] `cargo test -p authforge-daemon` — original 26 + new ones from A2/A4/A6/A7 = ~32 PASS.
|
||
- [ ] `cargo test -p authforgectl` — 3 clap parser tests PASS.
|
||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean.
|
||
- [ ] `make -C pam` — pass on a box with libpam0g-dev; deferred otherwise (record in BUILDING.md).
|
||
- [ ] Manual smoke (deferred): on a Yubikey-equipped Ubuntu VM, `authforgectl enroll --user alice` produces a real pam_u2f line in `~alice/.config/Yubico/u2f_keys`.
|
||
- [ ] Manual smoke (deferred): `pamtester authforge "$USER" authenticate` denies when pending flag exists.
|
||
|
||
Tag the milestone:
|
||
|
||
```bash
|
||
git tag -a v0.3.0-multi-lane -m "Phase 3+6+7: FIDO2 backend + PAM module + CLI"
|
||
```
|
||
|
||
---
|
||
|
||
## Risks / known unknowns
|
||
|
||
| Risk | Mitigation |
|
||
|---|---|
|
||
| `ctap-hid-fido2` API surface drift between 3.5.x patch versions. | Plan tested against 3.5.9; if minor fields rename, adapt at Task A5 against `~/.cargo/registry`. |
|
||
| `LibCfg::init` may not be the right constructor — could be `LibCfg::default()` or builder-style. | Verify at A5; small adjustment, no architectural change. |
|
||
| pam_u2f format minor variants (some distros use `+pin` instead of `+presence`). | We emit `+presence` which is the dominant default. If the CI VM disagrees, parametrize PamU2fCred::to_pam_segment to take the attribute string. |
|
||
| `Proxy::into_owned()` is the zbus 4 API; if the installed minor is 4.0–4.3, the method may differ slightly (`Proxy::clone_into_owned()` in some versions). | Adapt at C2; the lifetime-juggle is local. |
|
||
| ctap-hid-fido2 returns `Result<_, String>` from some calls; matching error strings for PinRequired/Cancelled is fragile. | We keyword-match the error string. If the GUI needs structured error codes, Phase 8 can pump back via the `EnrollmentFailed` signal payload — not a Phase 3 change. |
|
||
| PAM `pam_get_user` requires conv response on some stacks; for password-prompt stacks it's a pass-through. | We ignore the conv side; the prompt is whatever the next stack module emits. |
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
Plan complete and saved to `docs/plans/2026-04-27-phase-3-6-7-multi-lane.md`. Bundle is **C → B → A** (CLI first, PAM next, FIDO2 last). Each lane's tasks can stand alone if execution is interrupted.
|
||
|
||
Two execution options:
|
||
|
||
**1. Subagent-Driven (this session)** — fresh subagent per task, two-stage review. Phase 1 worktree-cwd quirk may resurface; if so, fall back to sequential.
|
||
|
||
**2. Parallel Session (separate)** — open three new sessions in worktrees, one per lane, using `superpowers:executing-plans`.
|
||
|
||
For this work cycle, the controller is staying in main and executing Lane C → B → A sequentially with file-tree isolation as the safety mechanism (no two lanes touch the same file).
|