Files
authforge/docs/plans/2026-04-27-phase-3-6-7-multi-lane.md

1417 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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