Files
authforge/docs/plans/2026-04-27-phase-12-recovery-backend.md
michael de369ef390 docs(plan): step-level plans for Phase 12 backend and Phase 9 + Phase 12 GUI
Phase 12 backend lane is unblocked by Phase 6+7 and runs independently of
Phase 8. Phase 9 + Phase 12 GUI lane gates on Phase 8 landing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:14:57 -07:00

44 KiB

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/<user>, 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).
  • D-Bus gets a real GenerateRecoveryCode (replacing the stub at daemon/src/dbus.rs:132) 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) 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/<user> is two lines: <expires_unix>\n<argon2id-PHC-string>\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); 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:


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 — 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

// 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<PathBuf, SegmentError> {
    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:

fn user_path(&self, user: &str) -> Result<PathBuf, PendingError> {
    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

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:

argon2 = "0.5"

Step 2: Add to daemon deps

Append to [dependencies] in /home/work/VSCodeProjects/authforge/daemon/Cargo.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

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

//! 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<String, RecoveryError> {
    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<bool, RecoveryError> {
    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

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:

<expires_unix>
<argon2id-PHC-string>

Step 1: Write the failing tests + impl

//! 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):
//!     <expires_unix>\n<argon2id-PHC-string>\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<PathBuf, RecoveryStoreError> {
        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<String, RecoveryStoreError> {
        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<Vec<RecoveryEntry>, 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<RecoveryEntry, RecoveryStoreError> {
        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<bool, RecoveryStoreError> {
        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<bool, RecoveryStoreError> {
        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

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:

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

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

pub async fn issue_recovery(&self, user: &str) -> Result<String, StateError> {
    self.recovery
        .issue(user, crate::recovery::DEFAULT_TTL_SECS)
        .map_err(|e| StateError::Storage(e.to_string()))
}

pub async fn list_recovery(&self) -> Result<Vec<crate::storage::recovery::RecoveryEntry>, StateError> {
    self.recovery
        .list()
        .map_err(|e| StateError::Storage(e.to_string()))
}

pub async fn revoke_recovery(&self, user: &str) -> Result<bool, StateError> {
    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 and :308 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

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

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

async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result<String> {
    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<Vec<authforge_common::types::RecoveryCodeSummary>> {
    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<bool> {
    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 <action> block, append two more (mirror the existing block's <description>/<message>/<defaults> shape — auth_admin_keep for both, since these are admin operations):

<action id="io.dangerousthings.AuthForge.list-recovery">
  <description>List active recovery codes</description>
  <message>Authentication is required to list recovery codes.</message>
  <defaults>
    <allow_any>auth_admin_keep</allow_any>
    <allow_inactive>auth_admin_keep</allow_inactive>
    <allow_active>auth_admin_keep</allow_active>
  </defaults>
</action>
<action id="io.dangerousthings.AuthForge.revoke-recovery">
  <description>Revoke a recovery code</description>
  <message>Authentication is required to revoke a recovery code.</message>
  <defaults>
    <allow_any>auth_admin_keep</allow_any>
    <allow_inactive>auth_admin_keep</allow_inactive>
    <allow_active>auth_admin_keep</allow_active>
  </defaults>
</action>

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

#[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<authforge_common::types::RecoveryCodeSummary> =
        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<authforge_common::types::RecoveryCodeSummary> =
        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

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

#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <argon2.h>

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

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

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:

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

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

# 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$<salt>$<hash>' >> "$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

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:

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

pub async fn list_recovery_codes(&self) -> Result<Vec<authforge_common::types::RecoveryCodeSummary>> {
    Ok(self.proxy.call("ListRecoveryCodes", &()).await?)
}

pub async fn revoke_recovery_code(&self, user: &str) -> Result<bool> {
    Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?)
}

Step 3: Wire in commands/mod.rs

Replace the placeholder at cli/src/commands/mod.rs:218 with:

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

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:

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

### 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<RecoveryCodeSummary>`), 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/<user>`, `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 <user>`.

**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

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:

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.