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

46 KiB
Raw Blame History

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:


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]:

hex = "0.4"

Step 2: Append to daemon/Cargo.toml [dependencies]:

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.

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:

pub(crate) mod format;

Step 2: Failing test in daemon/src/fido/format.rs:

//! 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.

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:

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.

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:

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.

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:

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.

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.rsenroll_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:

// 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:

    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:

    #[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:

    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:

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.

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:

    #[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:

    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:

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

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:

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:

#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:

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.

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:

# 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:

# 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:

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:

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:

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

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]:

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:

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:

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:

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.

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:

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 = { workspace = true }

Step 2: cargo build -p authforgectl && cargo test -p authforgectl. Expected: clean + 3 prior parser tests still pass.

Step 3: Commit.

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:

        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.

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:

        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.

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:

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