# 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).