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>
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
PendingStoreshape (daemon/src/storage/pending.rs). - D-Bus gets a real
GenerateRecoveryCode(replacing the stub at daemon/src/dbus.rs:132) plus two new methodsListRecoveryCodesandRevokeRecoveryCode. polkit gates inherit the existing…generate-recoveryaction; we add…list-recoveryand…revoke-recovery. - PAM module (
pam_authforge_pending.so) gains a second entry path selected by themode=recoveryargv. The pam-auth-update profile rendered by Phase 4 (daemon/src/policy_apply.rs) is amended to include asufficientline 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 generateto the daemon (cli/src/commands/mod.rs:206); the placeholder at line 218 forrecovery listbecomes a real call.
Tech Stack:
- Daemon:
argon20.5 crate for hashing/verifying with PHC string output.rand(already in workspace) for code generation. - PAM module:
libargon2C library for verification (apt: libargon2-dev). Already-presentpam, plusargon2linkage. - Storage: plain UTF-8 files, mode 0600, root-owned.
tempfilefor tests (already in dev-deps).
Reference:
- Master plan: 2026-04-26-authforge-implementation.md § Phase 12 (line 1260).
- Sibling store (mirror this pattern): daemon/src/storage/pending.rs.
- D-Bus stub to replace: daemon/src/dbus.rs:132.
- PAM module to extend: 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 existingpamtesterrecipe 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. Runcargo test -p authforge-daemonwhenever a daemon-side test is added. PAM-side:make -C pam clean allafter 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— addpub(crate) mod safe_user; - Modify:
daemon/src/storage/pending.rs— replace inline check withsafe_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) — addargon2 = "0.5"to[workspace.dependencies] - Modify:
daemon/Cargo.toml— addargon2 = { 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(ormain.rs— wherever modules are registered) — addpub(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— addpub(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-recoverypolkit actions - Modify:
common/src/types.rs(or wherever the wire types live) — add aRecoveryCodeSummarystruct soListRecoveryCodesreturns 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 theauth sufficient pam_authforge_pending.so mode=recoveryline 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:
- Resolves the user (same as the pending path).
stat()s/var/lib/authforge/recovery/<user>. ENOENT →PAM_IGNORE(let the rest of the stack run).- Reads the file, parses the two-line format, checks expiry, calls
argon2_verify(phc, user_password, len). - On match:
unlinkthe recovery file, write the pending file withre_enroll=true, returnPAM_SUCCESS(thesufficientcontrol short-circuits the stack). - 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— extendRecoveryCmdwithRevoke { user: String }. - Modify:
cli/src/bus.rs— addlist_recovery_codes()+revoke_recovery_code(user). - Modify:
cli/src/commands/mod.rs— replace the placeholder at line 218 with the real call; routeRevoke.
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 --releaseclean.cargo clippy --workspace --all-targets -- -D warningsclean.cargo test --workspace— all daemon + cli + common tests PASS. Daemon test count ≥ previous + 11 (4 inrecovery, 7 instorage::recovery) + 4 (D-Bus integration).cargo fmt --all -- --checkclean.make -C pam clean allrebuildspam_authforge_pending.soclean againstlibargon2.- 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.