From 6351041a6cc98caa42b8e8722e3937eb5864d4db Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 08:15:20 -0700 Subject: [PATCH] docs: expand Phases 3+6+7 to step granularity (multi-lane bundle) --- .../2026-04-27-phase-3-6-7-multi-lane.md | 1416 +++++++++++++++++ 1 file changed, 1416 insertions(+) create mode 100644 docs/plans/2026-04-27-phase-3-6-7-multi-lane.md diff --git a/docs/plans/2026-04-27-phase-3-6-7-multi-lane.md b/docs/plans/2026-04-27-phase-3-6-7-multi-lane.md new file mode 100644 index 0000000..f2e7364 --- /dev/null +++ b/docs/plans/2026-04-27-phase-3-6-7-multi-lane.md @@ -0,0 +1,1416 @@ +# 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/` 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 `` and ``. 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, + pub public_key_der: Vec, + 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, 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; +} +``` + +**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, + pub devices: Vec, +} + +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, AuthnError> { + Ok(self.devices.clone()) + } + + fn make_credential( + &self, + _rp_id: &str, + _user: &str, + _pin: Option<&str>, + ) -> Result { + 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` +- `FidoKeyHidFactory::create(cfg: &LibCfg) -> Result` +- `FidoKeyHid::make_credential(rpid, challenge, pin) -> Result` +- `Attestation.credential_descriptor.id: Vec` is the keyHandle. +- `Attestation.credential_publickey.der: Vec` 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 { + // 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, 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 { + 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` 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, + authn: Arc, +} + +impl AppState { + pub fn open(cfg: StorageConfig, authn: Arc) -> Result { + 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 { + 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 { + 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 = + 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 { + 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/")`: + - 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 +#include + +#include +#include +#include +#include +#include +#include + +#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 `: 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 [--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, + #[arg(long)] + nickname: Option, + }, + /// List enrolled credentials for a user. + List { + #[arg(long)] + user: Option, + }, + /// Remove a credential by ID. + Remove { + #[arg(long)] + user: Option, + 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, + }, + 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, + }, + 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 { + 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 { + 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> { + Ok(self.proxy.call("ListCredentials", &(user,)).await?) + } + pub async fn enroll_own(&self, user: &str, nickname: &str) -> Result { + 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 { + Ok(self.proxy.call("GetPolicy", &()).await?) + } + pub async fn set_policy(&self, p: &Policy) -> Result { + 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 { + 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> = 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> = 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).