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

1171 lines
44 KiB
Markdown

# 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](../../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/<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](../../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<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](../../daemon/src/storage/pending.rs#L26-L36):
```rust
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**
```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<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**
```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:
```
<expires_unix>
<argon2id-PHC-string>
```
**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):
//! <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**
```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<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](../../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<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):
```xml
<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):
```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<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**
```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/<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`:
```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:
```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$<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**
```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<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](../../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<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**
```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`.