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>
1171 lines
44 KiB
Markdown
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`.
|