# Phase 12: Recovery Flow — Backend Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Land the daemon-side and PAM-side of the recovery flow: an admin generates a one-shot 8-digit code, it's stored Argon2id-hashed at `/var/lib/authforge/recovery/`, and the PAM module accepts it once at the password prompt — on success it deletes the recovery file and writes a `re_enroll=true` pending flag so the user is forced into re-enrollment on next login. **Architecture:** - **Daemon** owns code generation, Argon2id hashing, persistence, expiry, listing, and revocation. Mirrors the `PendingStore` shape ([daemon/src/storage/pending.rs](../../daemon/src/storage/pending.rs)). - **D-Bus** gets a real `GenerateRecoveryCode` (replacing the stub at [daemon/src/dbus.rs:132](../../daemon/src/dbus.rs#L132)) plus two new methods `ListRecoveryCodes` and `RevokeRecoveryCode`. polkit gates inherit the existing `…generate-recovery` action; we add `…list-recovery` and `…revoke-recovery`. - **PAM module** (`pam_authforge_pending.so`) gains a second entry path selected by the `mode=recovery` argv. The pam-auth-update profile rendered by Phase 4 ([daemon/src/policy_apply.rs](../../daemon/src/policy_apply.rs)) is amended to include a `sufficient` line that runs the module in recovery mode before the FIDO2 line. - **File format** at `/var/lib/authforge/recovery/` is two lines: `\n\n`. Two lines instead of JSON keeps the C-side parser trivial — no json-c link needed. - **CLI** already routes `recovery generate` to the daemon ([cli/src/commands/mod.rs:206](../../cli/src/commands/mod.rs#L206)); the placeholder at line 218 for `recovery list` becomes a real call. **Tech Stack:** - Daemon: `argon2` 0.5 crate for hashing/verifying with PHC string output. `rand` (already in workspace) for code generation. - PAM module: `libargon2` C library for verification (`apt: libargon2-dev`). Already-present `pam`, plus `argon2` linkage. - Storage: plain UTF-8 files, mode 0600, root-owned. `tempfile` for tests (already in dev-deps). **Reference:** - Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 12 (line 1260). - Sibling store (mirror this pattern): [daemon/src/storage/pending.rs](../../daemon/src/storage/pending.rs). - D-Bus stub to replace: [daemon/src/dbus.rs:132](../../daemon/src/dbus.rs#L132). - PAM module to extend: [pam/pam_authforge_pending.c](../../pam/pam_authforge_pending.c). --- ## Conventions - One logical change per commit. Conventional prefixes scoped to subsystem: `feat(daemon):`, `feat(pam):`, `feat(cli):`, `test(daemon):`, `chore:`, `docs:`. - Always commit with `--no-gpg-sign`. - TDD for the daemon (`recovery.rs`, store, AppState methods). The PAM module's recovery check ships with the existing `pamtester` recipe extended in Task 8 — C-side unit tests aren't worth the pthread/dlopen mocking for one verify call. - After each commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo build --workspace`. Run `cargo test -p authforge-daemon` whenever a daemon-side test is added. PAM-side: `make -C pam clean all` after any C edit. - Path-traversal defense for usernames mirrors [storage/pending.rs:26-36](../../daemon/src/storage/pending.rs#L26-L36) — extract to a shared helper in Task 1 instead of duplicating. --- ## Task 1: Extract shared username-safety helper **Files:** - Create: `daemon/src/storage/safe_user.rs` - Modify: `daemon/src/storage/mod.rs` — add `pub(crate) mod safe_user;` - Modify: `daemon/src/storage/pending.rs` — replace inline check with `safe_user::sanitize_segment` **Step 1: Write the failing test** ```rust // daemon/src/storage/safe_user.rs //! Shared username sanitizer for path-segment use across pending/recovery stores. use std::path::{Path, PathBuf}; use thiserror::Error; #[derive(Debug, Error)] pub(crate) enum SegmentError { #[error("invalid username segment: {0:?}")] Invalid(String), } /// Reject any username that could escape a single path segment. Matches the /// rules pending used inline; centralized so recovery/totp/etc. don't drift. pub(crate) fn join_user_segment(dir: &Path, user: &str) -> Result { if user.is_empty() || user == "." || user == ".." || user.contains('/') || user.contains('\0') { return Err(SegmentError::Invalid(user.to_string())); } Ok(dir.join(user)) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; #[test] fn accepts_normal_username() { let p = join_user_segment(Path::new("/tmp/x"), "alice").unwrap(); assert_eq!(p, PathBuf::from("/tmp/x/alice")); } #[test] fn rejects_traversal_and_separators() { for evil in ["", ".", "..", "a/b", "../etc", "x\0y"] { assert!(join_user_segment(Path::new("/tmp/x"), evil).is_err(), "{evil:?} should be rejected"); } } } ``` **Step 2: Run tests to verify they pass** Run: `cargo test -p authforge-daemon storage::safe_user` Expected: 2 PASS. **Step 3: Refactor `pending.rs` to use it** Modify [daemon/src/storage/pending.rs:26-36](../../daemon/src/storage/pending.rs#L26-L36): ```rust fn user_path(&self, user: &str) -> Result { super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e { super::safe_user::SegmentError::Invalid(s) => PendingError::InvalidUser(s), }) } ``` **Step 4: Run pending tests to verify no regression** Run: `cargo test -p authforge-daemon storage::pending` Expected: PASS (unchanged behavior). **Step 5: Commit** ```bash git add daemon/src/storage/safe_user.rs daemon/src/storage/mod.rs daemon/src/storage/pending.rs git commit --no-gpg-sign -m "refactor(daemon): centralize username path-segment sanitizer" ``` --- ## Task 2: Add `argon2` dependency **Files:** - Modify: `Cargo.toml` (workspace) — add `argon2 = "0.5"` to `[workspace.dependencies]` - Modify: `daemon/Cargo.toml` — add `argon2 = { workspace = true }` to `[dependencies]` **Step 1: Add to workspace deps** Append to `[workspace.dependencies]` in `/home/work/VSCodeProjects/authforge/Cargo.toml`: ```toml argon2 = "0.5" ``` **Step 2: Add to daemon deps** Append to `[dependencies]` in `/home/work/VSCodeProjects/authforge/daemon/Cargo.toml`: ```toml argon2 = { workspace = true } ``` **Step 3: Resolve the dep** Run: `cargo build -p authforge-daemon` Expected: clean (no usages yet, but the lockfile resolves). **Step 4: Commit** ```bash git add Cargo.toml daemon/Cargo.toml Cargo.lock git commit --no-gpg-sign -m "chore(daemon): add argon2 0.5 for recovery code hashing" ``` --- ## Task 3: `recovery.rs` — code generation + Argon2id hash (TDD, pure logic) **Files:** - Create: `daemon/src/recovery.rs` - Modify: `daemon/src/lib.rs` (or `main.rs` — wherever modules are registered) — add `pub(crate) mod recovery;` **Step 1: Verify module-registration site** Run: `grep -n "pub mod\|mod " /home/work/VSCodeProjects/authforge/daemon/src/main.rs | head -20` Note which file declares the existing top-level modules (e.g. `storage`, `policy_apply`). Add `mod recovery;` there. **Step 2: Write the failing tests + impl in `daemon/src/recovery.rs`** ```rust //! Pure recovery-code logic: generation, Argon2id hashing, verification. //! No I/O — that lives in `storage::recovery::RecoveryStore` (Task 4). use argon2::{ password_hash::{rand_core::OsRng as PhcOsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; use rand::Rng; /// Default validity window for a freshly generated recovery code. /// 7 days: long enough to be useful for an ops admin who's mailing the code /// to a remote user, short enough to limit blast radius if the file leaks. pub(crate) const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600; #[derive(Debug, thiserror::Error)] pub(crate) enum RecoveryError { #[error("argon2: {0}")] Argon2(String), } /// Generate a fresh 8-digit recovery code. Cryptographically random. /// The plaintext is returned; only the caller (CLI/GUI) should ever see it. pub(crate) fn generate_code() -> String { let n: u32 = rand::rng().random_range(0..100_000_000); format!("{n:08}") } /// Hash a code with Argon2id, producing a PHC-format string suitable for /// later verification. Uses OS RNG for the salt. pub(crate) fn hash_code(code: &str) -> Result { let salt = SaltString::generate(&mut PhcOsRng); let argon = Argon2::default(); let phc = argon .hash_password(code.as_bytes(), &salt) .map_err(|e| RecoveryError::Argon2(e.to_string()))? .to_string(); Ok(phc) } /// Constant-time verify a candidate code against a stored PHC hash. /// Returns Ok(true) on match, Ok(false) on mismatch, Err on malformed PHC. pub(crate) fn verify_code(candidate: &str, phc: &str) -> Result { let parsed = PasswordHash::new(phc).map_err(|e| RecoveryError::Argon2(e.to_string()))?; Ok(Argon2::default() .verify_password(candidate.as_bytes(), &parsed) .is_ok()) } #[cfg(test)] mod tests { use super::*; #[test] fn generated_code_is_eight_digits() { for _ in 0..50 { let c = generate_code(); assert_eq!(c.len(), 8); assert!(c.chars().all(|ch| ch.is_ascii_digit())); } } #[test] fn hash_roundtrips_correct_code() { let phc = hash_code("12345678").unwrap(); assert!(phc.starts_with("$argon2id$"), "phc was {phc}"); assert!(verify_code("12345678", &phc).unwrap()); } #[test] fn verify_rejects_wrong_code() { let phc = hash_code("12345678").unwrap(); assert!(!verify_code("87654321", &phc).unwrap()); } #[test] fn verify_errors_on_malformed_phc() { assert!(verify_code("12345678", "not a phc string").is_err()); } } ``` **Step 3: Run tests** Run: `cargo test -p authforge-daemon recovery::tests` Expected: 4 PASS. **Step 4: Clippy gate** Run: `cargo clippy -p authforge-daemon --all-targets -- -D warnings` Expected: clean. **Step 5: Commit** ```bash git add daemon/src/recovery.rs daemon/src/main.rs git commit --no-gpg-sign -m "feat(daemon): recovery code generation and Argon2id hashing" ``` --- ## Task 4: `storage::recovery::RecoveryStore` — file persistence (TDD) **Files:** - Create: `daemon/src/storage/recovery.rs` - Modify: `daemon/src/storage/mod.rs` — add `pub(crate) mod recovery;` **Background:** Two-line file format keeps the PAM-side parser trivial: ``` ``` **Step 1: Write the failing tests + impl** ```rust //! On-disk persistence for recovery codes. One file per user under //! `cfg.recovery_dir`, mode 0600, root-owned. //! //! File format (two lines, UTF-8, trailing LFs): //! \n\n //! Picked over JSON so the C PAM module can parse it without linking json-c. use crate::recovery::{hash_code, verify_code}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; #[derive(Debug, Error)] pub(crate) enum RecoveryStoreError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("invalid username: {0:?}")] InvalidUser(String), #[error("malformed recovery file for {0:?}")] Malformed(String), #[error("hash error: {0}")] Hash(String), #[error("system clock before unix epoch")] Clock, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RecoveryEntry { pub user: String, pub expires_unix: u64, } #[allow(dead_code)] // `verify_and_consume` is called from PAM via the daemon's verify path; harmless until Task 7 wires it. pub(crate) struct RecoveryStore { dir: PathBuf, } #[allow(dead_code)] impl RecoveryStore { pub fn new(dir: PathBuf) -> Self { Self { dir } } fn user_path(&self, user: &str) -> Result { super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e { super::safe_user::SegmentError::Invalid(s) => RecoveryStoreError::InvalidUser(s), }) } /// Generate, hash, and persist a fresh code for `user`. The file is /// written atomically (temp + rename) so a concurrent reader never sees /// a half-written file. Mode is set to 0600 before rename. Returns the /// plaintext code so the caller can show it to the admin once. pub fn issue(&self, user: &str, ttl_secs: u64) -> Result { fs::create_dir_all(&self.dir)?; // Tighten directory mode to 0700 so a non-root snoop can't list. fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?; let path = self.user_path(user)?; let code = crate::recovery::generate_code(); let phc = hash_code(&code).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?; let expires = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| RecoveryStoreError::Clock)? .as_secs() .saturating_add(ttl_secs); let body = format!("{expires}\n{phc}\n"); let tmp = path.with_extension("tmp"); fs::write(&tmp, body.as_bytes())?; fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?; fs::rename(&tmp, &path)?; Ok(code) } /// Read entries for all users with a recovery code on file (expired or not). /// `entry_for(user)` is the single-user variant. pub fn list(&self) -> Result, RecoveryStoreError> { let mut out = Vec::new(); let rd = match fs::read_dir(&self.dir) { Ok(r) => r, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), Err(e) => return Err(e.into()), }; for entry in rd { let entry = entry?; if !entry.file_type()?.is_file() { continue; } let user = entry.file_name().to_string_lossy().to_string(); // Skip atomic-rename leftovers. if user.ends_with(".tmp") { continue; } if let Ok(e) = self.entry_for(&user) { out.push(e); } } out.sort_by(|a, b| a.user.cmp(&b.user)); Ok(out) } pub fn entry_for(&self, user: &str) -> Result { let path = self.user_path(user)?; let body = fs::read_to_string(&path)?; let mut lines = body.lines(); let expires: u64 = lines .next() .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))? .parse() .map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?; Ok(RecoveryEntry { user: user.to_string(), expires_unix: expires, }) } /// Delete the recovery file for `user`. Returns true if a file was /// removed, false if there was nothing there. Used by both manual /// revocation and automatic consume-on-success. pub fn revoke(&self, user: &str) -> Result { let path = self.user_path(user)?; match fs::remove_file(path) { Ok(()) => Ok(true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(e.into()), } } /// Verify a candidate code for `user`. On match: deletes the recovery /// file (one-shot semantics) and returns Ok(true). Used by the daemon's /// future verify-then-consume D-Bus method (not directly by PAM — PAM /// reads the file itself). pub fn verify_and_consume(&self, user: &str, candidate: &str) -> Result { let path = self.user_path(user)?; let body = match fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(e) => return Err(e.into()), }; let mut lines = body.lines(); let expires: u64 = lines .next() .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))? .parse() .map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?; let phc = lines .next() .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| RecoveryStoreError::Clock)? .as_secs(); if now > expires { // Expired entry; revoke and refuse. let _ = fs::remove_file(&path); return Ok(false); } let ok = verify_code(candidate, phc).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?; if ok { // One-shot: consume the code so it can't be reused. let _ = fs::remove_file(&path); } Ok(ok) } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn store() -> (tempfile::TempDir, RecoveryStore) { let d = tempdir().unwrap(); let s = RecoveryStore::new(d.path().to_path_buf()); (d, s) } #[test] fn issue_then_verify_consumes_file() { let (_d, s) = store(); let code = s.issue("alice", 600).unwrap(); assert!(s.entry_for("alice").is_ok()); assert!(s.verify_and_consume("alice", &code).unwrap()); assert!(matches!( s.entry_for("alice"), Err(RecoveryStoreError::Io(_)) )); } #[test] fn verify_with_wrong_code_does_not_consume() { let (_d, s) = store(); s.issue("alice", 600).unwrap(); assert!(!s.verify_and_consume("alice", "00000000").unwrap()); // Still present after a failed attempt. assert!(s.entry_for("alice").is_ok()); } #[test] fn expired_entry_is_treated_as_absent_and_cleaned_up() { let (_d, s) = store(); s.issue("alice", 0).unwrap(); // expires immediately std::thread::sleep(std::time::Duration::from_millis(1100)); assert!(!s.verify_and_consume("alice", "anything").unwrap()); assert!(matches!( s.entry_for("alice"), Err(RecoveryStoreError::Io(_)) )); } #[test] fn revoke_returns_false_on_missing_user() { let (_d, s) = store(); assert!(!s.revoke("ghost").unwrap()); } #[test] fn list_sorts_users_alphabetically() { let (_d, s) = store(); s.issue("charlie", 600).unwrap(); s.issue("alice", 600).unwrap(); s.issue("bob", 600).unwrap(); let users: Vec<_> = s.list().unwrap().into_iter().map(|e| e.user).collect(); assert_eq!(users, vec!["alice", "bob", "charlie"]); } #[test] fn rejects_traversal_usernames() { let (_d, s) = store(); for evil in ["", ".", "..", "a/b", "x\0y"] { assert!(s.issue(evil, 600).is_err()); } } #[test] fn issue_writes_file_with_mode_0600() { let (_d, s) = store(); s.issue("alice", 600).unwrap(); let path = s.user_path("alice").unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); } } ``` **Step 2: Run tests** Run: `cargo test -p authforge-daemon storage::recovery` Expected: 7 PASS. **Step 3: Clippy gate** Run: `cargo clippy -p authforge-daemon --all-targets -- -D warnings` Expected: clean. **Step 4: Commit** ```bash git add daemon/src/storage/recovery.rs daemon/src/storage/mod.rs git commit --no-gpg-sign -m "feat(daemon): RecoveryStore with atomic 0600 writes and one-shot verify-and-consume" ``` --- ## Task 5: Wire `RecoveryStore` into `AppState` **Files:** - Modify: `daemon/src/state.rs` **Background:** Mirror how `PendingStore` is wired. Add a `recovery_dir` field to `StorageConfig`, an `AUTHFORGE_RECOVERY_DIR` env override in `from_env_or_defaults`, a `RecoveryStore` field on `AppState`, and four state methods (`issue_recovery`, `verify_recovery`, `list_recovery`, `revoke_recovery`). **Step 1: Read the existing state shape** Run: `sed -n '32,90p' /home/work/VSCodeProjects/authforge/daemon/src/state.rs` Note the exact field/init style of `pending_dir` so the new code matches. **Step 2: Add the field and env default** Modify `StorageConfig`: ```rust pub struct StorageConfig { pub policy_dir: PathBuf, pub pending_dir: PathBuf, pub recovery_dir: PathBuf, pub userdb_path: PathBuf, pub pam_profile_path: PathBuf, pub pam_auth_update: PathBuf, } ``` In `from_env_or_defaults`, add the new env override alongside the existing ones (production default `/var/lib/authforge/recovery`): ```rust recovery_dir: std::env::var("AUTHFORGE_RECOVERY_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/var/lib/authforge/recovery")), ``` **Step 3: Add the `RecoveryStore` field on `AppState`** In `AppState::open`, after the existing `PendingStore::new` line, construct and store a `RecoveryStore::new(cfg.recovery_dir.clone())`. Mirror the storage struct's existing field convention (Arc-wrapped or owned — match neighbors). **Step 4: Add four state methods** ```rust pub async fn issue_recovery(&self, user: &str) -> Result { self.recovery .issue(user, crate::recovery::DEFAULT_TTL_SECS) .map_err(|e| StateError::Storage(e.to_string())) } pub async fn list_recovery(&self) -> Result, StateError> { self.recovery .list() .map_err(|e| StateError::Storage(e.to_string())) } pub async fn revoke_recovery(&self, user: &str) -> Result { self.recovery .revoke(user) .map_err(|e| StateError::Storage(e.to_string())) } ``` (If `StateError` doesn't already have a `Storage(String)` variant, look at how `Pending(...)` is mapped and follow that pattern instead.) **Step 5: Update test fixtures** The two `open_in` / inline `AppState::open` test fixtures at [daemon/src/state.rs:240](../../daemon/src/state.rs#L240) and [:308](../../daemon/src/state.rs#L308) need a `recovery_dir: d.path().join("recovery")` line in the `StorageConfig` literal. Find them via `grep -n "StorageConfig {" daemon/src/state.rs` and add the field. **Step 6: Run all daemon tests** Run: `cargo test -p authforge-daemon` Expected: previously-passing tests still pass; the new state methods aren't exercised yet (Task 6 wires them through D-Bus and adds tests). **Step 7: Clippy gate** Run: `cargo clippy -p authforge-daemon --all-targets -- -D warnings` Expected: clean. **Step 8: Commit** ```bash git add daemon/src/state.rs git commit --no-gpg-sign -m "feat(daemon): wire RecoveryStore into AppState with AUTHFORGE_RECOVERY_DIR override" ``` --- ## Task 6: Replace D-Bus stub + add `ListRecoveryCodes`/`RevokeRecoveryCode` **Files:** - Modify: `daemon/src/dbus.rs` (replace stub at line 132; add two methods; add new tests at the bottom) - Modify: `debian/io.dangerousthings.AuthForge.policy` — add `…list-recovery` + `…revoke-recovery` polkit actions - Modify: `common/src/types.rs` (or wherever the wire types live) — add a `RecoveryCodeSummary` struct so `ListRecoveryCodes` returns typed values rather than ad-hoc tuples. **Background:** Replace the random-number stub at [daemon/src/dbus.rs:132](../../daemon/src/dbus.rs#L132) with a real `state.issue_recovery(...)` call. Add two new D-Bus methods, each gated behind its own polkit action so `dangerousthings.authforge` Ansible role can grant fine-grained permissions later. The PAM module reads the recovery file directly (no D-Bus call) so we don't need a `VerifyRecoveryCode` method. **Step 1: Add the wire type** In `common/src/types.rs` (find via `grep -n "pub struct PendingFlag" common/src/types.rs`), append: ```rust #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, zvariant::Type)] pub struct RecoveryCodeSummary { pub user: String, pub expires_unix: u64, } ``` **Step 2: Replace the stub body in `dbus.rs`** ```rust async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.generate-recovery").await?; self.state .issue_recovery(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } async fn list_recovery_codes(&self) -> zbus::fdo::Result> { self.authz("io.dangerousthings.AuthForge.list-recovery").await?; let entries = self .state .list_recovery() .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; Ok(entries .into_iter() .map(|e| authforge_common::types::RecoveryCodeSummary { user: e.user, expires_unix: e.expires_unix, }) .collect()) } async fn revoke_recovery_code(&self, user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.revoke-recovery").await?; self.state .revoke_recovery(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } ``` **Step 3: Add polkit actions** In `debian/io.dangerousthings.AuthForge.policy`, after the existing `…generate-recovery` `` block, append two more (mirror the existing block's ``/``/`` shape — `auth_admin_keep` for both, since these are admin operations): ```xml List active recovery codes Authentication is required to list recovery codes. auth_admin_keep auth_admin_keep auth_admin_keep Revoke a recovery code Authentication is required to revoke a recovery code. auth_admin_keep auth_admin_keep auth_admin_keep ``` **Step 4: Add D-Bus integration tests** Append to the test module in `daemon/src/dbus.rs` (look for `generate_recovery_code_returns_8_digits` at line 364 and add neighbors next to it): ```rust #[tokio::test] async fn list_recovery_returns_issued_users() { let (_g, p) = p2p_pair().await; let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); let _: String = p.call("GenerateRecoveryCode", &("bob",)).await.unwrap(); let entries: Vec = p.call("ListRecoveryCodes", &()).await.unwrap(); let users: Vec<_> = entries.into_iter().map(|e| e.user).collect(); assert_eq!(users, vec!["alice".to_string(), "bob".to_string()]); } #[tokio::test] async fn revoke_recovery_removes_entry() { let (_g, p) = p2p_pair().await; let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); let removed: bool = p.call("RevokeRecoveryCode", &("alice",)).await.unwrap(); assert!(removed); let entries: Vec = p.call("ListRecoveryCodes", &()).await.unwrap(); assert!(entries.is_empty()); } #[tokio::test] async fn revoke_recovery_returns_false_on_missing_user() { let (_g, p) = p2p_pair().await; let removed: bool = p.call("RevokeRecoveryCode", &("ghost",)).await.unwrap(); assert!(!removed); } #[tokio::test] async fn generated_code_is_eight_digits() { let (_g, p) = p2p_pair().await; let code: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); assert_eq!(code.len(), 8); assert!(code.chars().all(|c| c.is_ascii_digit())); } ``` The existing `generate_recovery_code_returns_8_digits` test now exercises the real path, not the stub — make sure `p2p_pair()` constructs an `AppState` with a temp `recovery_dir`. If the helper isn't already grabbing all StorageConfig fields from a tempdir, extend it the same way Task 5 extended the inline test fixtures. **Step 5: Run the tests** Run: `cargo test -p authforge-daemon dbus::tests` Expected: 4 new + the existing-but-now-real test PASS. Total daemon test count goes up by 4. **Step 6: Clippy + fmt** Run: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. **Step 7: Commit** ```bash git add daemon/src/dbus.rs common/src/types.rs debian/io.dangerousthings.AuthForge.policy git commit --no-gpg-sign -m "feat(daemon): real GenerateRecoveryCode + ListRecoveryCodes + RevokeRecoveryCode" ``` --- ## Task 7: PAM module — recovery-code verification path **Files:** - Modify: `pam/pam_authforge_pending.c` - Modify: `pam/Makefile` — link `-largon2` - Modify: `daemon/src/policy_apply.rs` — render the `auth sufficient pam_authforge_pending.so mode=recovery` line in the pam-configs profile **before** the FIDO2 line. - Modify: `pam/TESTING.md` — add the recovery smoke recipe. **Background:** The PAM module gains an argv-driven mode. With `mode=recovery` it: 1. Resolves the user (same as the pending path). 2. `stat()`s `/var/lib/authforge/recovery/`. ENOENT → `PAM_IGNORE` (let the rest of the stack run). 3. Reads the file, parses the two-line format, checks expiry, calls `argon2_verify(phc, user_password, len)`. 4. On match: `unlink` the recovery file, write the pending file with `re_enroll=true`, return `PAM_SUCCESS` (the `sufficient` control short-circuits the stack). 5. On mismatch / expired / parse error: `PAM_IGNORE` (let pam_unix or pam_u2f try). Never fail closed in recovery mode — false negatives must not lock the user out of normal login paths. **Step 1: Pull PAM_AUTHTOK plumbing into the module** Insert a static helper near the top of `pam/pam_authforge_pending.c`: ```c #include #include #include #include #include #define RECOVERY_DIR "/var/lib/authforge/recovery/" #define PENDING_DIR_C PENDING_DIR /* alias to make later writes obvious */ /* Read the recovery file for user. Returns 0 on success, -1 on any error * (missing, malformed, I/O). Caller frees *phc_out. */ static int read_recovery_file(const char *user, uint64_t *expires_out, char **phc_out) { char path[1024]; int n = snprintf(path, sizeof(path), "%s%s", RECOVERY_DIR, user); if (n < 0 || (size_t)n >= sizeof(path)) return -1; FILE *f = fopen(path, "r"); if (!f) return -1; char line1[64]; char line2[256]; if (!fgets(line1, sizeof(line1), f) || !fgets(line2, sizeof(line2), f)) { fclose(f); return -1; } fclose(f); /* Strip trailing \n. */ line1[strcspn(line1, "\r\n")] = 0; line2[strcspn(line2, "\r\n")] = 0; char *end = NULL; unsigned long long parsed = strtoull(line1, &end, 10); if (!end || *end != 0) return -1; *expires_out = (uint64_t)parsed; *phc_out = strdup(line2); return *phc_out ? 0 : -1; } static int write_pending_re_enroll(const char *user) { char path[1024]; int n = snprintf(path, sizeof(path), "%s%s", PENDING_DIR_C, user); if (n < 0 || (size_t)n >= sizeof(path)) return -1; /* Match the JSON the daemon uses (PendingFlag) closely enough that the * daemon round-trips it. Phase 2's PendingStore uses serde_json * pretty-printed, but it accepts any valid JSON. No enrolled methods, * re_enroll: true, no deadline. */ FILE *f = fopen(path, "w"); if (!f) return -1; fprintf(f, "{\n \"required_methods\": [\"fido2\"],\n" " \"created_unix\": %ld,\n" " \"deadline_unix\": 0,\n" " \"re_enroll\": true\n}\n", (long)time(NULL)); fclose(f); chmod(path, 0644); return 0; } static int recovery_code_matches(const char *user, const char *candidate) { uint64_t expires = 0; char *phc = NULL; if (read_recovery_file(user, &expires, &phc) != 0) return 0; int ok = 0; if ((uint64_t)time(NULL) <= expires) { if (argon2_verify(phc, candidate, strlen(candidate), Argon2_id) == ARGON2_OK) { ok = 1; } } free(phc); if (ok) { char path[1024]; snprintf(path, sizeof(path), "%s%s", RECOVERY_DIR, user); unlink(path); write_pending_re_enroll(user); } return ok; } ``` **Step 2: Branch on `mode=recovery` in `pam_sm_authenticate`** Add at the top of the existing `pam_sm_authenticate`, before the username-resolution block: ```c int recovery_mode = 0; for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "mode=recovery") == 0) { recovery_mode = 1; break; } } ``` After the `username_is_safe` check, insert the recovery branch (before the existing `pending_flag_present` call): ```c if (recovery_mode) { const char *authtok = NULL; if (pam_get_authtok(pamh, PAM_AUTHTOK, &authtok, NULL) != PAM_SUCCESS || authtok == NULL) { return PAM_IGNORE; } if (recovery_code_matches(user, authtok)) { pam_syslog(pamh, LOG_INFO, "authforge_pending: recovery code accepted for %s", user); return PAM_SUCCESS; } return PAM_IGNORE; /* let pam_unix / pam_u2f handle */ } ``` **Step 3: Wire the linker flag** Modify `pam/Makefile`. Replace the existing `LDLIBS = -lpam` (or equivalent) with: ```makefile LDLIBS = -lpam -largon2 ``` And add a runtime check at the top of the Makefile (so `make` fails loudly on a box without the dev package): ```makefile ifeq ($(shell pkg-config --exists libargon2 && echo y),) $(warning libargon2 not detected — install libargon2-dev (apt: libargon2-dev)) endif ``` **Step 4: Update the pam-configs render** In `daemon/src/policy_apply.rs`, find where the rendered PAM profile is composed (search `pam_authforge_pending`). Add a line **before** the FIDO2 module line: ``` Auth: [success=ignore default=ignore] pam_authforge_pending.so mode=recovery ...existing pam_u2f line... ...existing pam_authforge_pending line (no mode arg, default behavior)... ``` Use the `[success=ignore default=ignore]` control instead of `sufficient` so a successful recovery passes through the rest of the stack without stripping the password check that pam_unix later does. Read the existing profile-format code in `policy_apply.rs` and match its formatting. Update the matching unit test in `policy_apply.rs` (the snapshot test that asserts profile contents) to include the new line. **Step 5: Build the PAM module** Run: `make -C pam clean all` Expected: `pam_authforge_pending.so` rebuilds with the new symbols. If `libargon2-dev` is missing, the Makefile's pkg-config warning fires and the link step fails with `cannot find -largon2` — install `libargon2-dev` and retry. **Step 6: Run the full Rust suite** Run: `cargo test --workspace` Expected: clean. The `policy_apply.rs` snapshot test now reflects the extra recovery line. **Step 7: Update PAM testing doc** Append to `pam/TESTING.md` a new section "Recovery mode smoke" with this recipe (single block): ```bash # Setup a fake pending + recovery dir under $HOME for the test. TMPDIR=$(mktemp -d) mkdir -p "$TMPDIR/recovery" "$TMPDIR/pending" # Generate a code via the daemon (or hand-craft one — example uses Rust REPL). # For a manual test without the daemon: write a known PHC. python3 -c "import time; print(int(time.time()) + 600)" > "$TMPDIR/recovery/$USER" # Append a known argon2id PHC for the password "12345678" (regenerate via: # echo -n 12345678 | argon2 $(openssl rand -base64 12) -id -e echo '$argon2id$v=19$m=19456,t=2,p=1$$' >> "$TMPDIR/recovery/$USER" # Point the PAM module's compiled-in dirs at the temp via test-only build flag, # OR run a chroot, OR run pamtester against an in-tree authforge.pamd that uses # absolute paths. (Exact recipe lives in pam/test/ — extend authforge.pamd to # include `auth [success=ignore default=ignore] pam_authforge_pending.so mode=recovery`.) pamtester authforge $USER authenticate # Expected: prompt for password — entering "12345678" returns auth ok and # leaves a pending(re_enroll=true) flag at $TMPDIR/pending/$USER. ``` (Yes, the test setup is fiddly — the PAM module compiles in `RECOVERY_DIR` as a constant, so a true unit test requires either a chroot or a build-time override. Acceptable for v1; document the friction.) **Step 8: Commit** ```bash git add pam/pam_authforge_pending.c pam/Makefile pam/TESTING.md daemon/src/policy_apply.rs git commit --no-gpg-sign -m "feat(pam): recovery-code mode with Argon2id verify and pending re-enroll handoff" ``` --- ## Task 8: CLI — `recovery list` + `recovery revoke` **Files:** - Modify: `cli/src/main.rs` — extend `RecoveryCmd` with `Revoke { user: String }`. - Modify: `cli/src/bus.rs` — add `list_recovery_codes()` + `revoke_recovery_code(user)`. - Modify: `cli/src/commands/mod.rs` — replace the placeholder at line 218 with the real call; route `Revoke`. **Step 1: Extend the CLI enum** Modify `RecoveryCmd` in [cli/src/main.rs:96](../../cli/src/main.rs#L96): ```rust #[derive(Subcommand)] enum RecoveryCmd { Generate { user: String }, List, Revoke { user: String }, } ``` (Note: `List` no longer takes a user — it lists all users with active codes, matching the daemon method.) Update the matching tests in `cli/src/main.rs` to cover `recovery list` (no args) and `recovery revoke alice`. **Step 2: Extend `bus.rs`** Append to `cli/src/bus.rs` after the existing `generate_recovery_code` method: ```rust pub async fn list_recovery_codes(&self) -> Result> { Ok(self.proxy.call("ListRecoveryCodes", &()).await?) } pub async fn revoke_recovery_code(&self, user: &str) -> Result { Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?) } ``` **Step 3: Wire in `commands/mod.rs`** Replace the placeholder at [cli/src/commands/mod.rs:218](../../cli/src/commands/mod.rs#L218) with: ```rust super::Cmd::Recovery { cmd: super::RecoveryCmd::List, } => { let d = bus::Daemon::connect().await?; let entries = d.list_recovery_codes().await?; if json { println!("{}", serde_json::to_string_pretty(&entries)?); } else if entries.is_empty() { println!("(no active recovery codes)"); } else { for e in &entries { println!("{:<24} expires_unix={}", e.user, e.expires_unix); } } Ok(()) } super::Cmd::Recovery { cmd: super::RecoveryCmd::Revoke { user }, } => { let d = bus::Daemon::connect().await?; let removed = d.revoke_recovery_code(&user).await?; if removed { println!("revoked recovery code for {user}"); } else { println!("no recovery code for {user}"); std::process::exit(1); } Ok(()) } ``` **Step 4: Build + test** Run: `cargo build --workspace && cargo test -p authforge-cli` Expected: clean. All clap parser tests still pass; new `recovery list`/`revoke` parser tests pass. **Step 5: Clippy + fmt** Run: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. **Step 6: Commit** ```bash git add cli/src/main.rs cli/src/bus.rs cli/src/commands/mod.rs git commit --no-gpg-sign -m "feat(cli): authforgectl recovery list and revoke" ``` --- ## Task 9: Update master plan closeout note **Files:** - Modify: `docs/plans/2026-04-26-authforge-implementation.md` **Step 1: Flip Phase 12 status to ✅ Code complete** In the roadmap table at [docs/plans/2026-04-26-authforge-implementation.md:42](2026-04-26-authforge-implementation.md#L42): ``` | 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM; GUI tab lands with Phase 9 lane | ``` **Step 2: Append a closeout-notes section after the Phase 4+5 block (around line 215)** ```markdown ### Phase 12 backend closeout notes (2026-04-27) Backend + PAM landed via the plan at [2026-04-27-phase-12-recovery-backend.md](2026-04-27-phase-12-recovery-backend.md). GUI tab is queued in the Phase 9 + Phase 12 GUI lane (waiting on Phase 8). **Done:** - `daemon/src/recovery.rs` — pure logic: `generate_code`, `hash_code`, `verify_code`. 4 unit tests. - `daemon/src/storage/recovery.rs` — `RecoveryStore` with atomic 0600 writes, two-line file format (`expires_unix\nargon2id-PHC`), one-shot `verify_and_consume`, expiry handling. 7 unit tests. - D-Bus: real `GenerateRecoveryCode` (replaced stub), new `ListRecoveryCodes` (returns `Vec`), new `RevokeRecoveryCode`. polkit policy gains `…list-recovery` + `…revoke-recovery` actions (auth_admin_keep). 4 new integration tests. - PAM module: `mode=recovery` argv branch in `pam_sm_authenticate`. Reads `/var/lib/authforge/recovery/`, `argon2_verify`s against PAM_AUTHTOK, on success unlinks the file and writes a pending(re_enroll=true) flag. Linked against `libargon2`. - `policy_apply.rs` profile renders a `[success=ignore default=ignore]` recovery line before FIDO2. - CLI: `authforgectl recovery list` (was placeholder) and `authforgectl recovery revoke `. **Plan deviations:** none expected. **Deferred until Phase 14 VM smoke:** - pamtester recovery walkthrough (needs root + a real `/etc/pam.d/`). - Real-user recovery flow on a clean Ubuntu VM (admin generates code, user logs in with it, sees re-enrollment modal). ``` **Step 3: Commit** ```bash git add docs/plans/2026-04-26-authforge-implementation.md git commit --no-gpg-sign -m "docs: phase 12 backend closeout notes" ``` --- ## Phase 12 Backend Acceptance Gate Verify before tagging: - [ ] `cargo build --workspace --release` clean. - [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean. - [ ] `cargo test --workspace` — all daemon + cli + common tests PASS. Daemon test count ≥ previous + 11 (4 in `recovery`, 7 in `storage::recovery`) + 4 (D-Bus integration). - [ ] `cargo fmt --all -- --check` clean. - [ ] `make -C pam clean all` rebuilds `pam_authforge_pending.so` clean against `libargon2`. - [ ] Manual smoke (deferred to Phase 14): admin issues code via CLI, user logs in entering the code at the password prompt, recovery file is deleted, pending(re_enroll=true) flag is present. Tag the milestone: ```bash git tag -a v0.5.0-recovery-backend -m "Phase 12 backend: recovery codes + PAM verify" ``` --- ## Risks / known unknowns | Risk | Mitigation | |---|---| | `libargon2-dev` not in CI image. | Add `libargon2-dev` to the apt-get line in the GitHub Actions workflow (same place `libpam0g-dev` lives). Fail loud if missing. | | `argon2_verify` C signature mismatch across libargon2 versions. | Plan uses `argon2_verify(phc, pwd, pwd_len, Argon2_id)` which is stable since libargon2 0.5 (Ubuntu 22.04+). Pin the apt dep at runtime. | | PAM module compile-time `RECOVERY_DIR` makes unit testing painful. | Acceptable for v1 — Phase 17 (integration tests) builds a debian package and exercises the module against real `/var/lib/authforge`. Not worth a build-time override hack. | | `pam_get_authtok(PAM_AUTHTOK, ...)` returns nothing if no prior module set the authtok. | The recovery line runs alongside (not before) pam_unix in the rendered profile — pam_unix populates PAM_AUTHTOK during its own prompt, and the `[success=ignore default=ignore]` control means recovery never blocks normal flow. If PAM_AUTHTOK is null, return `PAM_IGNORE` (we already do). | | Two-line file format diverges from JSON if a future field is added. | Bumping to a versioned format requires touching both the daemon writer and the C reader; flagged as a future-debt item, fine for v1. | | Race: admin issues code while user is mid-login. | RecoveryStore writes atomically (temp+rename, 0600 before rename). Worst case the user's verify reads either the old file (already gone — verify returns false → IGNORE) or the new one (one-shot consume on success). No corrupt-read window. | --- ## Execution Handoff Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using `superpowers:using-git-worktrees`, then drive task-by-task with `superpowers:executing-plans`.