46 KiB
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 viapam/Makefile(already exists). - Phase 7:
clap = "4"withderive(workspace dep),zbus = "4"(workspace dep),serde_jsonfor--jsonoutput.
Reference:
- Master plan & lane diagram: 2026-04-26-authforge-implementation.md § Parallel Execution Lanes.
- Design doc: 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 viamake -C pam(deferred iflibpam0g-devnot 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) — addhex = "0.4". - Modify:
daemon/Cargo.toml— addctap-hid-fido2 = { workspace = true }andhex = { 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(addmod fido;)
Background: pam_u2f file format per its source:
username:keyHandle,publicKey,COSEType,Attributes[:keyHandle,publicKey,COSEType,Attributes...]
keyHandleandpublicKeyare lowercase hex.COSETypeises256(COSE alg -7) oreddsa(-8).Attributesis one of+presence,+verification,+pinjoined with,if multiple. We always emit+presencefor 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 → returnAuthnError::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 anArc<dyn Authenticator>at open-time; switchadd_credentialto use real format. - Modify:
daemon/src/dbus.rs—enroll_own/enroll_othercall the new path. - Modify:
daemon/src/main.rs— pickCtapAuthenticatorfor 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— addDeviceFound,TouchRequired,EnrollmentSucceeded,EnrollmentFailedsignals.
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:
- Read
PAM_USERfrom the conv stack. - Reject usernames containing
/,\0,.., or that are./empty. stat("/var/lib/authforge/pending/<user>"):- File present →
pam_error()with the user-facing message + returnPAM_AUTH_ERR. ENOENT→ returnPAM_IGNORE(let the rest of the stack decide).- Other errors →
pam_syslog(LOG_ERR, ...)+ returnPAM_AUTH_ERR(fail closed).
- File present →
pam_sm_setcredalways returnsPAM_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— addauthforge-common,serde,serde_jsondeps 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::newreturning aProxy<'_>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 --releasesucceeds (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 warningsclean.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 aliceproduces a real pam_u2f line in~alice/.config/Yubico/u2f_keys. - Manual smoke (deferred):
pamtester authforge "$USER" authenticatedenies 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.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).