diff --git a/docs/plans/2026-04-27-phase-12-recovery-backend.md b/docs/plans/2026-04-27-phase-12-recovery-backend.md new file mode 100644 index 0000000..94e6eab --- /dev/null +++ b/docs/plans/2026-04-27-phase-12-recovery-backend.md @@ -0,0 +1,1170 @@ +# Phase 12: Recovery Flow — Backend Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Land the daemon-side and PAM-side of the recovery flow: an admin generates a one-shot 8-digit code, it's stored Argon2id-hashed at `/var/lib/authforge/recovery/`, and the PAM module accepts it once at the password prompt — on success it deletes the recovery file and writes a `re_enroll=true` pending flag so the user is forced into re-enrollment on next login. + +**Architecture:** +- **Daemon** owns code generation, Argon2id hashing, persistence, expiry, listing, and revocation. Mirrors the `PendingStore` shape ([daemon/src/storage/pending.rs](../../daemon/src/storage/pending.rs)). +- **D-Bus** gets a real `GenerateRecoveryCode` (replacing the stub at [daemon/src/dbus.rs:132](../../daemon/src/dbus.rs#L132)) plus two new methods `ListRecoveryCodes` and `RevokeRecoveryCode`. polkit gates inherit the existing `…generate-recovery` action; we add `…list-recovery` and `…revoke-recovery`. +- **PAM module** (`pam_authforge_pending.so`) gains a second entry path selected by the `mode=recovery` argv. The pam-auth-update profile rendered by Phase 4 ([daemon/src/policy_apply.rs](../../daemon/src/policy_apply.rs)) is amended to include a `sufficient` line that runs the module in recovery mode before the FIDO2 line. +- **File format** at `/var/lib/authforge/recovery/` is two lines: `\n\n`. Two lines instead of JSON keeps the C-side parser trivial — no json-c link needed. +- **CLI** already routes `recovery generate` to the daemon ([cli/src/commands/mod.rs:206](../../cli/src/commands/mod.rs#L206)); the placeholder at line 218 for `recovery list` becomes a real call. + +**Tech Stack:** +- Daemon: `argon2` 0.5 crate for hashing/verifying with PHC string output. `rand` (already in workspace) for code generation. +- PAM module: `libargon2` C library for verification (`apt: libargon2-dev`). Already-present `pam`, plus `argon2` linkage. +- Storage: plain UTF-8 files, mode 0600, root-owned. `tempfile` for tests (already in dev-deps). + +**Reference:** +- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 12 (line 1260). +- Sibling store (mirror this pattern): [daemon/src/storage/pending.rs](../../daemon/src/storage/pending.rs). +- D-Bus stub to replace: [daemon/src/dbus.rs:132](../../daemon/src/dbus.rs#L132). +- PAM module to extend: [pam/pam_authforge_pending.c](../../pam/pam_authforge_pending.c). + +--- + +## Conventions + +- One logical change per commit. Conventional prefixes scoped to subsystem: `feat(daemon):`, `feat(pam):`, `feat(cli):`, `test(daemon):`, `chore:`, `docs:`. +- Always commit with `--no-gpg-sign`. +- TDD for the daemon (`recovery.rs`, store, AppState methods). The PAM module's recovery check ships with the existing `pamtester` recipe extended in Task 8 — C-side unit tests aren't worth the pthread/dlopen mocking for one verify call. +- After each commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo build --workspace`. Run `cargo test -p authforge-daemon` whenever a daemon-side test is added. PAM-side: `make -C pam clean all` after any C edit. +- Path-traversal defense for usernames mirrors [storage/pending.rs:26-36](../../daemon/src/storage/pending.rs#L26-L36) — extract to a shared helper in Task 1 instead of duplicating. + +--- + +## Task 1: Extract shared username-safety helper + +**Files:** +- Create: `daemon/src/storage/safe_user.rs` +- Modify: `daemon/src/storage/mod.rs` — add `pub(crate) mod safe_user;` +- Modify: `daemon/src/storage/pending.rs` — replace inline check with `safe_user::sanitize_segment` + +**Step 1: Write the failing test** + +```rust +// daemon/src/storage/safe_user.rs +//! Shared username sanitizer for path-segment use across pending/recovery stores. + +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(crate) enum SegmentError { + #[error("invalid username segment: {0:?}")] + Invalid(String), +} + +/// Reject any username that could escape a single path segment. Matches the +/// rules pending used inline; centralized so recovery/totp/etc. don't drift. +pub(crate) fn join_user_segment(dir: &Path, user: &str) -> Result { + if user.is_empty() + || user == "." + || user == ".." + || user.contains('/') + || user.contains('\0') + { + return Err(SegmentError::Invalid(user.to_string())); + } + Ok(dir.join(user)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn accepts_normal_username() { + let p = join_user_segment(Path::new("/tmp/x"), "alice").unwrap(); + assert_eq!(p, PathBuf::from("/tmp/x/alice")); + } + + #[test] + fn rejects_traversal_and_separators() { + for evil in ["", ".", "..", "a/b", "../etc", "x\0y"] { + assert!(join_user_segment(Path::new("/tmp/x"), evil).is_err(), "{evil:?} should be rejected"); + } + } +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `cargo test -p authforge-daemon storage::safe_user` +Expected: 2 PASS. + +**Step 3: Refactor `pending.rs` to use it** + +Modify [daemon/src/storage/pending.rs:26-36](../../daemon/src/storage/pending.rs#L26-L36): + +```rust +fn user_path(&self, user: &str) -> Result { + super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e { + super::safe_user::SegmentError::Invalid(s) => PendingError::InvalidUser(s), + }) +} +``` + +**Step 4: Run pending tests to verify no regression** + +Run: `cargo test -p authforge-daemon storage::pending` +Expected: PASS (unchanged behavior). + +**Step 5: Commit** + +```bash +git add daemon/src/storage/safe_user.rs daemon/src/storage/mod.rs daemon/src/storage/pending.rs +git commit --no-gpg-sign -m "refactor(daemon): centralize username path-segment sanitizer" +``` + +--- + +## Task 2: Add `argon2` dependency + +**Files:** +- Modify: `Cargo.toml` (workspace) — add `argon2 = "0.5"` to `[workspace.dependencies]` +- Modify: `daemon/Cargo.toml` — add `argon2 = { workspace = true }` to `[dependencies]` + +**Step 1: Add to workspace deps** + +Append to `[workspace.dependencies]` in `/home/work/VSCodeProjects/authforge/Cargo.toml`: + +```toml +argon2 = "0.5" +``` + +**Step 2: Add to daemon deps** + +Append to `[dependencies]` in `/home/work/VSCodeProjects/authforge/daemon/Cargo.toml`: + +```toml +argon2 = { workspace = true } +``` + +**Step 3: Resolve the dep** + +Run: `cargo build -p authforge-daemon` +Expected: clean (no usages yet, but the lockfile resolves). + +**Step 4: Commit** + +```bash +git add Cargo.toml daemon/Cargo.toml Cargo.lock +git commit --no-gpg-sign -m "chore(daemon): add argon2 0.5 for recovery code hashing" +``` + +--- + +## Task 3: `recovery.rs` — code generation + Argon2id hash (TDD, pure logic) + +**Files:** +- Create: `daemon/src/recovery.rs` +- Modify: `daemon/src/lib.rs` (or `main.rs` — wherever modules are registered) — add `pub(crate) mod recovery;` + +**Step 1: Verify module-registration site** + +Run: `grep -n "pub mod\|mod " /home/work/VSCodeProjects/authforge/daemon/src/main.rs | head -20` +Note which file declares the existing top-level modules (e.g. `storage`, `policy_apply`). Add `mod recovery;` there. + +**Step 2: Write the failing tests + impl in `daemon/src/recovery.rs`** + +```rust +//! Pure recovery-code logic: generation, Argon2id hashing, verification. +//! No I/O — that lives in `storage::recovery::RecoveryStore` (Task 4). + +use argon2::{ + password_hash::{rand_core::OsRng as PhcOsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use rand::Rng; + +/// Default validity window for a freshly generated recovery code. +/// 7 days: long enough to be useful for an ops admin who's mailing the code +/// to a remote user, short enough to limit blast radius if the file leaks. +pub(crate) const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum RecoveryError { + #[error("argon2: {0}")] + Argon2(String), +} + +/// Generate a fresh 8-digit recovery code. Cryptographically random. +/// The plaintext is returned; only the caller (CLI/GUI) should ever see it. +pub(crate) fn generate_code() -> String { + let n: u32 = rand::rng().random_range(0..100_000_000); + format!("{n:08}") +} + +/// Hash a code with Argon2id, producing a PHC-format string suitable for +/// later verification. Uses OS RNG for the salt. +pub(crate) fn hash_code(code: &str) -> Result { + let salt = SaltString::generate(&mut PhcOsRng); + let argon = Argon2::default(); + let phc = argon + .hash_password(code.as_bytes(), &salt) + .map_err(|e| RecoveryError::Argon2(e.to_string()))? + .to_string(); + Ok(phc) +} + +/// Constant-time verify a candidate code against a stored PHC hash. +/// Returns Ok(true) on match, Ok(false) on mismatch, Err on malformed PHC. +pub(crate) fn verify_code(candidate: &str, phc: &str) -> Result { + let parsed = PasswordHash::new(phc).map_err(|e| RecoveryError::Argon2(e.to_string()))?; + Ok(Argon2::default() + .verify_password(candidate.as_bytes(), &parsed) + .is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_code_is_eight_digits() { + for _ in 0..50 { + let c = generate_code(); + assert_eq!(c.len(), 8); + assert!(c.chars().all(|ch| ch.is_ascii_digit())); + } + } + + #[test] + fn hash_roundtrips_correct_code() { + let phc = hash_code("12345678").unwrap(); + assert!(phc.starts_with("$argon2id$"), "phc was {phc}"); + assert!(verify_code("12345678", &phc).unwrap()); + } + + #[test] + fn verify_rejects_wrong_code() { + let phc = hash_code("12345678").unwrap(); + assert!(!verify_code("87654321", &phc).unwrap()); + } + + #[test] + fn verify_errors_on_malformed_phc() { + assert!(verify_code("12345678", "not a phc string").is_err()); + } +} +``` + +**Step 3: Run tests** + +Run: `cargo test -p authforge-daemon recovery::tests` +Expected: 4 PASS. + +**Step 4: Clippy gate** + +Run: `cargo clippy -p authforge-daemon --all-targets -- -D warnings` +Expected: clean. + +**Step 5: Commit** + +```bash +git add daemon/src/recovery.rs daemon/src/main.rs +git commit --no-gpg-sign -m "feat(daemon): recovery code generation and Argon2id hashing" +``` + +--- + +## Task 4: `storage::recovery::RecoveryStore` — file persistence (TDD) + +**Files:** +- Create: `daemon/src/storage/recovery.rs` +- Modify: `daemon/src/storage/mod.rs` — add `pub(crate) mod recovery;` + +**Background:** Two-line file format keeps the PAM-side parser trivial: + +``` + + +``` + +**Step 1: Write the failing tests + impl** + +```rust +//! On-disk persistence for recovery codes. One file per user under +//! `cfg.recovery_dir`, mode 0600, root-owned. +//! +//! File format (two lines, UTF-8, trailing LFs): +//! \n\n +//! Picked over JSON so the C PAM module can parse it without linking json-c. + +use crate::recovery::{hash_code, verify_code}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(crate) enum RecoveryStoreError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("invalid username: {0:?}")] + InvalidUser(String), + #[error("malformed recovery file for {0:?}")] + Malformed(String), + #[error("hash error: {0}")] + Hash(String), + #[error("system clock before unix epoch")] + Clock, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RecoveryEntry { + pub user: String, + pub expires_unix: u64, +} + +#[allow(dead_code)] // `verify_and_consume` is called from PAM via the daemon's verify path; harmless until Task 7 wires it. +pub(crate) struct RecoveryStore { + dir: PathBuf, +} + +#[allow(dead_code)] +impl RecoveryStore { + pub fn new(dir: PathBuf) -> Self { + Self { dir } + } + + fn user_path(&self, user: &str) -> Result { + super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e { + super::safe_user::SegmentError::Invalid(s) => RecoveryStoreError::InvalidUser(s), + }) + } + + /// Generate, hash, and persist a fresh code for `user`. The file is + /// written atomically (temp + rename) so a concurrent reader never sees + /// a half-written file. Mode is set to 0600 before rename. Returns the + /// plaintext code so the caller can show it to the admin once. + pub fn issue(&self, user: &str, ttl_secs: u64) -> Result { + fs::create_dir_all(&self.dir)?; + // Tighten directory mode to 0700 so a non-root snoop can't list. + fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?; + + let path = self.user_path(user)?; + let code = crate::recovery::generate_code(); + let phc = hash_code(&code).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?; + let expires = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| RecoveryStoreError::Clock)? + .as_secs() + .saturating_add(ttl_secs); + + let body = format!("{expires}\n{phc}\n"); + let tmp = path.with_extension("tmp"); + fs::write(&tmp, body.as_bytes())?; + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?; + fs::rename(&tmp, &path)?; + Ok(code) + } + + /// Read entries for all users with a recovery code on file (expired or not). + /// `entry_for(user)` is the single-user variant. + pub fn list(&self) -> Result, RecoveryStoreError> { + let mut out = Vec::new(); + let rd = match fs::read_dir(&self.dir) { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e.into()), + }; + for entry in rd { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let user = entry.file_name().to_string_lossy().to_string(); + // Skip atomic-rename leftovers. + if user.ends_with(".tmp") { + continue; + } + if let Ok(e) = self.entry_for(&user) { + out.push(e); + } + } + out.sort_by(|a, b| a.user.cmp(&b.user)); + Ok(out) + } + + pub fn entry_for(&self, user: &str) -> Result { + let path = self.user_path(user)?; + let body = fs::read_to_string(&path)?; + let mut lines = body.lines(); + let expires: u64 = lines + .next() + .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))? + .parse() + .map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?; + Ok(RecoveryEntry { + user: user.to_string(), + expires_unix: expires, + }) + } + + /// Delete the recovery file for `user`. Returns true if a file was + /// removed, false if there was nothing there. Used by both manual + /// revocation and automatic consume-on-success. + pub fn revoke(&self, user: &str) -> Result { + let path = self.user_path(user)?; + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + } + } + + /// Verify a candidate code for `user`. On match: deletes the recovery + /// file (one-shot semantics) and returns Ok(true). Used by the daemon's + /// future verify-then-consume D-Bus method (not directly by PAM — PAM + /// reads the file itself). + pub fn verify_and_consume(&self, user: &str, candidate: &str) -> Result { + let path = self.user_path(user)?; + let body = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e.into()), + }; + let mut lines = body.lines(); + let expires: u64 = lines + .next() + .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))? + .parse() + .map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?; + let phc = lines + .next() + .ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| RecoveryStoreError::Clock)? + .as_secs(); + if now > expires { + // Expired entry; revoke and refuse. + let _ = fs::remove_file(&path); + return Ok(false); + } + + let ok = verify_code(candidate, phc).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?; + if ok { + // One-shot: consume the code so it can't be reused. + let _ = fs::remove_file(&path); + } + Ok(ok) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn store() -> (tempfile::TempDir, RecoveryStore) { + let d = tempdir().unwrap(); + let s = RecoveryStore::new(d.path().to_path_buf()); + (d, s) + } + + #[test] + fn issue_then_verify_consumes_file() { + let (_d, s) = store(); + let code = s.issue("alice", 600).unwrap(); + assert!(s.entry_for("alice").is_ok()); + assert!(s.verify_and_consume("alice", &code).unwrap()); + assert!(matches!( + s.entry_for("alice"), + Err(RecoveryStoreError::Io(_)) + )); + } + + #[test] + fn verify_with_wrong_code_does_not_consume() { + let (_d, s) = store(); + s.issue("alice", 600).unwrap(); + assert!(!s.verify_and_consume("alice", "00000000").unwrap()); + // Still present after a failed attempt. + assert!(s.entry_for("alice").is_ok()); + } + + #[test] + fn expired_entry_is_treated_as_absent_and_cleaned_up() { + let (_d, s) = store(); + s.issue("alice", 0).unwrap(); // expires immediately + std::thread::sleep(std::time::Duration::from_millis(1100)); + assert!(!s.verify_and_consume("alice", "anything").unwrap()); + assert!(matches!( + s.entry_for("alice"), + Err(RecoveryStoreError::Io(_)) + )); + } + + #[test] + fn revoke_returns_false_on_missing_user() { + let (_d, s) = store(); + assert!(!s.revoke("ghost").unwrap()); + } + + #[test] + fn list_sorts_users_alphabetically() { + let (_d, s) = store(); + s.issue("charlie", 600).unwrap(); + s.issue("alice", 600).unwrap(); + s.issue("bob", 600).unwrap(); + let users: Vec<_> = s.list().unwrap().into_iter().map(|e| e.user).collect(); + assert_eq!(users, vec!["alice", "bob", "charlie"]); + } + + #[test] + fn rejects_traversal_usernames() { + let (_d, s) = store(); + for evil in ["", ".", "..", "a/b", "x\0y"] { + assert!(s.issue(evil, 600).is_err()); + } + } + + #[test] + fn issue_writes_file_with_mode_0600() { + let (_d, s) = store(); + s.issue("alice", 600).unwrap(); + let path = s.user_path("alice").unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} +``` + +**Step 2: Run tests** + +Run: `cargo test -p authforge-daemon storage::recovery` +Expected: 7 PASS. + +**Step 3: Clippy gate** + +Run: `cargo clippy -p authforge-daemon --all-targets -- -D warnings` +Expected: clean. + +**Step 4: Commit** + +```bash +git add daemon/src/storage/recovery.rs daemon/src/storage/mod.rs +git commit --no-gpg-sign -m "feat(daemon): RecoveryStore with atomic 0600 writes and one-shot verify-and-consume" +``` + +--- + +## Task 5: Wire `RecoveryStore` into `AppState` + +**Files:** +- Modify: `daemon/src/state.rs` + +**Background:** Mirror how `PendingStore` is wired. Add a `recovery_dir` field to `StorageConfig`, an `AUTHFORGE_RECOVERY_DIR` env override in `from_env_or_defaults`, a `RecoveryStore` field on `AppState`, and four state methods (`issue_recovery`, `verify_recovery`, `list_recovery`, `revoke_recovery`). + +**Step 1: Read the existing state shape** + +Run: `sed -n '32,90p' /home/work/VSCodeProjects/authforge/daemon/src/state.rs` +Note the exact field/init style of `pending_dir` so the new code matches. + +**Step 2: Add the field and env default** + +Modify `StorageConfig`: + +```rust +pub struct StorageConfig { + pub policy_dir: PathBuf, + pub pending_dir: PathBuf, + pub recovery_dir: PathBuf, + pub userdb_path: PathBuf, + pub pam_profile_path: PathBuf, + pub pam_auth_update: PathBuf, +} +``` + +In `from_env_or_defaults`, add the new env override alongside the existing ones (production default `/var/lib/authforge/recovery`): + +```rust +recovery_dir: std::env::var("AUTHFORGE_RECOVERY_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/var/lib/authforge/recovery")), +``` + +**Step 3: Add the `RecoveryStore` field on `AppState`** + +In `AppState::open`, after the existing `PendingStore::new` line, construct and store a `RecoveryStore::new(cfg.recovery_dir.clone())`. Mirror the storage struct's existing field convention (Arc-wrapped or owned — match neighbors). + +**Step 4: Add four state methods** + +```rust +pub async fn issue_recovery(&self, user: &str) -> Result { + self.recovery + .issue(user, crate::recovery::DEFAULT_TTL_SECS) + .map_err(|e| StateError::Storage(e.to_string())) +} + +pub async fn list_recovery(&self) -> Result, StateError> { + self.recovery + .list() + .map_err(|e| StateError::Storage(e.to_string())) +} + +pub async fn revoke_recovery(&self, user: &str) -> Result { + self.recovery + .revoke(user) + .map_err(|e| StateError::Storage(e.to_string())) +} +``` + +(If `StateError` doesn't already have a `Storage(String)` variant, look at how `Pending(...)` is mapped and follow that pattern instead.) + +**Step 5: Update test fixtures** + +The two `open_in` / inline `AppState::open` test fixtures at [daemon/src/state.rs:240](../../daemon/src/state.rs#L240) and [:308](../../daemon/src/state.rs#L308) need a `recovery_dir: d.path().join("recovery")` line in the `StorageConfig` literal. Find them via `grep -n "StorageConfig {" daemon/src/state.rs` and add the field. + +**Step 6: Run all daemon tests** + +Run: `cargo test -p authforge-daemon` +Expected: previously-passing tests still pass; the new state methods aren't exercised yet (Task 6 wires them through D-Bus and adds tests). + +**Step 7: Clippy gate** + +Run: `cargo clippy -p authforge-daemon --all-targets -- -D warnings` +Expected: clean. + +**Step 8: Commit** + +```bash +git add daemon/src/state.rs +git commit --no-gpg-sign -m "feat(daemon): wire RecoveryStore into AppState with AUTHFORGE_RECOVERY_DIR override" +``` + +--- + +## Task 6: Replace D-Bus stub + add `ListRecoveryCodes`/`RevokeRecoveryCode` + +**Files:** +- Modify: `daemon/src/dbus.rs` (replace stub at line 132; add two methods; add new tests at the bottom) +- Modify: `debian/io.dangerousthings.AuthForge.policy` — add `…list-recovery` + `…revoke-recovery` polkit actions +- Modify: `common/src/types.rs` (or wherever the wire types live) — add a `RecoveryCodeSummary` struct so `ListRecoveryCodes` returns typed values rather than ad-hoc tuples. + +**Background:** Replace the random-number stub at [daemon/src/dbus.rs:132](../../daemon/src/dbus.rs#L132) with a real `state.issue_recovery(...)` call. Add two new D-Bus methods, each gated behind its own polkit action so `dangerousthings.authforge` Ansible role can grant fine-grained permissions later. The PAM module reads the recovery file directly (no D-Bus call) so we don't need a `VerifyRecoveryCode` method. + +**Step 1: Add the wire type** + +In `common/src/types.rs` (find via `grep -n "pub struct PendingFlag" common/src/types.rs`), append: + +```rust +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, zvariant::Type)] +pub struct RecoveryCodeSummary { + pub user: String, + pub expires_unix: u64, +} +``` + +**Step 2: Replace the stub body in `dbus.rs`** + +```rust +async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.generate-recovery").await?; + self.state + .issue_recovery(&user) + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) +} + +async fn list_recovery_codes(&self) -> zbus::fdo::Result> { + self.authz("io.dangerousthings.AuthForge.list-recovery").await?; + let entries = self + .state + .list_recovery() + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; + Ok(entries + .into_iter() + .map(|e| authforge_common::types::RecoveryCodeSummary { + user: e.user, + expires_unix: e.expires_unix, + }) + .collect()) +} + +async fn revoke_recovery_code(&self, user: String) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.revoke-recovery").await?; + self.state + .revoke_recovery(&user) + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) +} +``` + +**Step 3: Add polkit actions** + +In `debian/io.dangerousthings.AuthForge.policy`, after the existing `…generate-recovery` `` block, append two more (mirror the existing block's ``/``/`` shape — `auth_admin_keep` for both, since these are admin operations): + +```xml + + List active recovery codes + Authentication is required to list recovery codes. + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + + + Revoke a recovery code + Authentication is required to revoke a recovery code. + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + +``` + +**Step 4: Add D-Bus integration tests** + +Append to the test module in `daemon/src/dbus.rs` (look for `generate_recovery_code_returns_8_digits` at line 364 and add neighbors next to it): + +```rust +#[tokio::test] +async fn list_recovery_returns_issued_users() { + let (_g, p) = p2p_pair().await; + let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); + let _: String = p.call("GenerateRecoveryCode", &("bob",)).await.unwrap(); + let entries: Vec = + p.call("ListRecoveryCodes", &()).await.unwrap(); + let users: Vec<_> = entries.into_iter().map(|e| e.user).collect(); + assert_eq!(users, vec!["alice".to_string(), "bob".to_string()]); +} + +#[tokio::test] +async fn revoke_recovery_removes_entry() { + let (_g, p) = p2p_pair().await; + let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); + let removed: bool = p.call("RevokeRecoveryCode", &("alice",)).await.unwrap(); + assert!(removed); + let entries: Vec = + p.call("ListRecoveryCodes", &()).await.unwrap(); + assert!(entries.is_empty()); +} + +#[tokio::test] +async fn revoke_recovery_returns_false_on_missing_user() { + let (_g, p) = p2p_pair().await; + let removed: bool = p.call("RevokeRecoveryCode", &("ghost",)).await.unwrap(); + assert!(!removed); +} + +#[tokio::test] +async fn generated_code_is_eight_digits() { + let (_g, p) = p2p_pair().await; + let code: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); + assert_eq!(code.len(), 8); + assert!(code.chars().all(|c| c.is_ascii_digit())); +} +``` + +The existing `generate_recovery_code_returns_8_digits` test now exercises the real path, not the stub — make sure `p2p_pair()` constructs an `AppState` with a temp `recovery_dir`. If the helper isn't already grabbing all StorageConfig fields from a tempdir, extend it the same way Task 5 extended the inline test fixtures. + +**Step 5: Run the tests** + +Run: `cargo test -p authforge-daemon dbus::tests` +Expected: 4 new + the existing-but-now-real test PASS. Total daemon test count goes up by 4. + +**Step 6: Clippy + fmt** + +Run: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. + +**Step 7: Commit** + +```bash +git add daemon/src/dbus.rs common/src/types.rs debian/io.dangerousthings.AuthForge.policy +git commit --no-gpg-sign -m "feat(daemon): real GenerateRecoveryCode + ListRecoveryCodes + RevokeRecoveryCode" +``` + +--- + +## Task 7: PAM module — recovery-code verification path + +**Files:** +- Modify: `pam/pam_authforge_pending.c` +- Modify: `pam/Makefile` — link `-largon2` +- Modify: `daemon/src/policy_apply.rs` — render the `auth sufficient pam_authforge_pending.so mode=recovery` line in the pam-configs profile **before** the FIDO2 line. +- Modify: `pam/TESTING.md` — add the recovery smoke recipe. + +**Background:** The PAM module gains an argv-driven mode. With `mode=recovery` it: +1. Resolves the user (same as the pending path). +2. `stat()`s `/var/lib/authforge/recovery/`. ENOENT → `PAM_IGNORE` (let the rest of the stack run). +3. Reads the file, parses the two-line format, checks expiry, calls `argon2_verify(phc, user_password, len)`. +4. On match: `unlink` the recovery file, write the pending file with `re_enroll=true`, return `PAM_SUCCESS` (the `sufficient` control short-circuits the stack). +5. On mismatch / expired / parse error: `PAM_IGNORE` (let pam_unix or pam_u2f try). Never fail closed in recovery mode — false negatives must not lock the user out of normal login paths. + +**Step 1: Pull PAM_AUTHTOK plumbing into the module** + +Insert a static helper near the top of `pam/pam_authforge_pending.c`: + +```c +#include +#include +#include +#include +#include + +#define RECOVERY_DIR "/var/lib/authforge/recovery/" +#define PENDING_DIR_C PENDING_DIR /* alias to make later writes obvious */ + +/* Read the recovery file for user. Returns 0 on success, -1 on any error + * (missing, malformed, I/O). Caller frees *phc_out. */ +static int read_recovery_file(const char *user, + uint64_t *expires_out, + char **phc_out) { + char path[1024]; + int n = snprintf(path, sizeof(path), "%s%s", RECOVERY_DIR, user); + if (n < 0 || (size_t)n >= sizeof(path)) return -1; + FILE *f = fopen(path, "r"); + if (!f) return -1; + char line1[64]; + char line2[256]; + if (!fgets(line1, sizeof(line1), f) || !fgets(line2, sizeof(line2), f)) { + fclose(f); + return -1; + } + fclose(f); + /* Strip trailing \n. */ + line1[strcspn(line1, "\r\n")] = 0; + line2[strcspn(line2, "\r\n")] = 0; + char *end = NULL; + unsigned long long parsed = strtoull(line1, &end, 10); + if (!end || *end != 0) return -1; + *expires_out = (uint64_t)parsed; + *phc_out = strdup(line2); + return *phc_out ? 0 : -1; +} + +static int write_pending_re_enroll(const char *user) { + char path[1024]; + int n = snprintf(path, sizeof(path), "%s%s", PENDING_DIR_C, user); + if (n < 0 || (size_t)n >= sizeof(path)) return -1; + /* Match the JSON the daemon uses (PendingFlag) closely enough that the + * daemon round-trips it. Phase 2's PendingStore uses serde_json + * pretty-printed, but it accepts any valid JSON. No enrolled methods, + * re_enroll: true, no deadline. */ + FILE *f = fopen(path, "w"); + if (!f) return -1; + fprintf(f, + "{\n \"required_methods\": [\"fido2\"],\n" + " \"created_unix\": %ld,\n" + " \"deadline_unix\": 0,\n" + " \"re_enroll\": true\n}\n", + (long)time(NULL)); + fclose(f); + chmod(path, 0644); + return 0; +} + +static int recovery_code_matches(const char *user, const char *candidate) { + uint64_t expires = 0; + char *phc = NULL; + if (read_recovery_file(user, &expires, &phc) != 0) return 0; + + int ok = 0; + if ((uint64_t)time(NULL) <= expires) { + if (argon2_verify(phc, candidate, strlen(candidate), Argon2_id) == ARGON2_OK) { + ok = 1; + } + } + + free(phc); + if (ok) { + char path[1024]; + snprintf(path, sizeof(path), "%s%s", RECOVERY_DIR, user); + unlink(path); + write_pending_re_enroll(user); + } + return ok; +} +``` + +**Step 2: Branch on `mode=recovery` in `pam_sm_authenticate`** + +Add at the top of the existing `pam_sm_authenticate`, before the username-resolution block: + +```c +int recovery_mode = 0; +for (int i = 0; i < argc; i++) { + if (strcmp(argv[i], "mode=recovery") == 0) { + recovery_mode = 1; + break; + } +} +``` + +After the `username_is_safe` check, insert the recovery branch (before the existing `pending_flag_present` call): + +```c +if (recovery_mode) { + const char *authtok = NULL; + if (pam_get_authtok(pamh, PAM_AUTHTOK, &authtok, NULL) != PAM_SUCCESS || authtok == NULL) { + return PAM_IGNORE; + } + if (recovery_code_matches(user, authtok)) { + pam_syslog(pamh, LOG_INFO, "authforge_pending: recovery code accepted for %s", user); + return PAM_SUCCESS; + } + return PAM_IGNORE; /* let pam_unix / pam_u2f handle */ +} +``` + +**Step 3: Wire the linker flag** + +Modify `pam/Makefile`. Replace the existing `LDLIBS = -lpam` (or equivalent) with: + +```makefile +LDLIBS = -lpam -largon2 +``` + +And add a runtime check at the top of the Makefile (so `make` fails loudly on a box without the dev package): + +```makefile +ifeq ($(shell pkg-config --exists libargon2 && echo y),) +$(warning libargon2 not detected — install libargon2-dev (apt: libargon2-dev)) +endif +``` + +**Step 4: Update the pam-configs render** + +In `daemon/src/policy_apply.rs`, find where the rendered PAM profile is composed (search `pam_authforge_pending`). Add a line **before** the FIDO2 module line: + +``` +Auth: + [success=ignore default=ignore] pam_authforge_pending.so mode=recovery + ...existing pam_u2f line... + ...existing pam_authforge_pending line (no mode arg, default behavior)... +``` + +Use the `[success=ignore default=ignore]` control instead of `sufficient` so a successful recovery passes through the rest of the stack without stripping the password check that pam_unix later does. Read the existing profile-format code in `policy_apply.rs` and match its formatting. + +Update the matching unit test in `policy_apply.rs` (the snapshot test that asserts profile contents) to include the new line. + +**Step 5: Build the PAM module** + +Run: `make -C pam clean all` +Expected: `pam_authforge_pending.so` rebuilds with the new symbols. If `libargon2-dev` is missing, the Makefile's pkg-config warning fires and the link step fails with `cannot find -largon2` — install `libargon2-dev` and retry. + +**Step 6: Run the full Rust suite** + +Run: `cargo test --workspace` +Expected: clean. The `policy_apply.rs` snapshot test now reflects the extra recovery line. + +**Step 7: Update PAM testing doc** + +Append to `pam/TESTING.md` a new section "Recovery mode smoke" with this recipe (single block): + +```bash +# Setup a fake pending + recovery dir under $HOME for the test. +TMPDIR=$(mktemp -d) +mkdir -p "$TMPDIR/recovery" "$TMPDIR/pending" + +# Generate a code via the daemon (or hand-craft one — example uses Rust REPL). +# For a manual test without the daemon: write a known PHC. +python3 -c "import time; print(int(time.time()) + 600)" > "$TMPDIR/recovery/$USER" +# Append a known argon2id PHC for the password "12345678" (regenerate via: +# echo -n 12345678 | argon2 $(openssl rand -base64 12) -id -e +echo '$argon2id$v=19$m=19456,t=2,p=1$$' >> "$TMPDIR/recovery/$USER" + +# Point the PAM module's compiled-in dirs at the temp via test-only build flag, +# OR run a chroot, OR run pamtester against an in-tree authforge.pamd that uses +# absolute paths. (Exact recipe lives in pam/test/ — extend authforge.pamd to +# include `auth [success=ignore default=ignore] pam_authforge_pending.so mode=recovery`.) +pamtester authforge $USER authenticate +# Expected: prompt for password — entering "12345678" returns auth ok and +# leaves a pending(re_enroll=true) flag at $TMPDIR/pending/$USER. +``` + +(Yes, the test setup is fiddly — the PAM module compiles in `RECOVERY_DIR` as a constant, so a true unit test requires either a chroot or a build-time override. Acceptable for v1; document the friction.) + +**Step 8: Commit** + +```bash +git add pam/pam_authforge_pending.c pam/Makefile pam/TESTING.md daemon/src/policy_apply.rs +git commit --no-gpg-sign -m "feat(pam): recovery-code mode with Argon2id verify and pending re-enroll handoff" +``` + +--- + +## Task 8: CLI — `recovery list` + `recovery revoke` + +**Files:** +- Modify: `cli/src/main.rs` — extend `RecoveryCmd` with `Revoke { user: String }`. +- Modify: `cli/src/bus.rs` — add `list_recovery_codes()` + `revoke_recovery_code(user)`. +- Modify: `cli/src/commands/mod.rs` — replace the placeholder at line 218 with the real call; route `Revoke`. + +**Step 1: Extend the CLI enum** + +Modify `RecoveryCmd` in [cli/src/main.rs:96](../../cli/src/main.rs#L96): + +```rust +#[derive(Subcommand)] +enum RecoveryCmd { + Generate { user: String }, + List, + Revoke { user: String }, +} +``` + +(Note: `List` no longer takes a user — it lists all users with active codes, matching the daemon method.) + +Update the matching tests in `cli/src/main.rs` to cover `recovery list` (no args) and `recovery revoke alice`. + +**Step 2: Extend `bus.rs`** + +Append to `cli/src/bus.rs` after the existing `generate_recovery_code` method: + +```rust +pub async fn list_recovery_codes(&self) -> Result> { + Ok(self.proxy.call("ListRecoveryCodes", &()).await?) +} + +pub async fn revoke_recovery_code(&self, user: &str) -> Result { + Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?) +} +``` + +**Step 3: Wire in `commands/mod.rs`** + +Replace the placeholder at [cli/src/commands/mod.rs:218](../../cli/src/commands/mod.rs#L218) with: + +```rust +super::Cmd::Recovery { + cmd: super::RecoveryCmd::List, +} => { + let d = bus::Daemon::connect().await?; + let entries = d.list_recovery_codes().await?; + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + } else if entries.is_empty() { + println!("(no active recovery codes)"); + } else { + for e in &entries { + println!("{:<24} expires_unix={}", e.user, e.expires_unix); + } + } + Ok(()) +} +super::Cmd::Recovery { + cmd: super::RecoveryCmd::Revoke { user }, +} => { + let d = bus::Daemon::connect().await?; + let removed = d.revoke_recovery_code(&user).await?; + if removed { + println!("revoked recovery code for {user}"); + } else { + println!("no recovery code for {user}"); + std::process::exit(1); + } + Ok(()) +} +``` + +**Step 4: Build + test** + +Run: `cargo build --workspace && cargo test -p authforge-cli` +Expected: clean. All clap parser tests still pass; new `recovery list`/`revoke` parser tests pass. + +**Step 5: Clippy + fmt** + +Run: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. + +**Step 6: Commit** + +```bash +git add cli/src/main.rs cli/src/bus.rs cli/src/commands/mod.rs +git commit --no-gpg-sign -m "feat(cli): authforgectl recovery list and revoke" +``` + +--- + +## Task 9: Update master plan closeout note + +**Files:** +- Modify: `docs/plans/2026-04-26-authforge-implementation.md` + +**Step 1: Flip Phase 12 status to ✅ Code complete** + +In the roadmap table at [docs/plans/2026-04-26-authforge-implementation.md:42](2026-04-26-authforge-implementation.md#L42): + +``` +| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM; GUI tab lands with Phase 9 lane | +``` + +**Step 2: Append a closeout-notes section after the Phase 4+5 block (around line 215)** + +```markdown +### Phase 12 backend closeout notes (2026-04-27) + +Backend + PAM landed via the plan at [2026-04-27-phase-12-recovery-backend.md](2026-04-27-phase-12-recovery-backend.md). GUI tab is queued in the Phase 9 + Phase 12 GUI lane (waiting on Phase 8). + +**Done:** +- `daemon/src/recovery.rs` — pure logic: `generate_code`, `hash_code`, `verify_code`. 4 unit tests. +- `daemon/src/storage/recovery.rs` — `RecoveryStore` with atomic 0600 writes, two-line file format (`expires_unix\nargon2id-PHC`), one-shot `verify_and_consume`, expiry handling. 7 unit tests. +- D-Bus: real `GenerateRecoveryCode` (replaced stub), new `ListRecoveryCodes` (returns `Vec`), new `RevokeRecoveryCode`. polkit policy gains `…list-recovery` + `…revoke-recovery` actions (auth_admin_keep). 4 new integration tests. +- PAM module: `mode=recovery` argv branch in `pam_sm_authenticate`. Reads `/var/lib/authforge/recovery/`, `argon2_verify`s against PAM_AUTHTOK, on success unlinks the file and writes a pending(re_enroll=true) flag. Linked against `libargon2`. +- `policy_apply.rs` profile renders a `[success=ignore default=ignore]` recovery line before FIDO2. +- CLI: `authforgectl recovery list` (was placeholder) and `authforgectl recovery revoke `. + +**Plan deviations:** none expected. + +**Deferred until Phase 14 VM smoke:** +- pamtester recovery walkthrough (needs root + a real `/etc/pam.d/`). +- Real-user recovery flow on a clean Ubuntu VM (admin generates code, user logs in with it, sees re-enrollment modal). +``` + +**Step 3: Commit** + +```bash +git add docs/plans/2026-04-26-authforge-implementation.md +git commit --no-gpg-sign -m "docs: phase 12 backend closeout notes" +``` + +--- + +## Phase 12 Backend Acceptance Gate + +Verify before tagging: + +- [ ] `cargo build --workspace --release` clean. +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean. +- [ ] `cargo test --workspace` — all daemon + cli + common tests PASS. Daemon test count ≥ previous + 11 (4 in `recovery`, 7 in `storage::recovery`) + 4 (D-Bus integration). +- [ ] `cargo fmt --all -- --check` clean. +- [ ] `make -C pam clean all` rebuilds `pam_authforge_pending.so` clean against `libargon2`. +- [ ] Manual smoke (deferred to Phase 14): admin issues code via CLI, user logs in entering the code at the password prompt, recovery file is deleted, pending(re_enroll=true) flag is present. + +Tag the milestone: + +```bash +git tag -a v0.5.0-recovery-backend -m "Phase 12 backend: recovery codes + PAM verify" +``` + +--- + +## Risks / known unknowns + +| Risk | Mitigation | +|---|---| +| `libargon2-dev` not in CI image. | Add `libargon2-dev` to the apt-get line in the GitHub Actions workflow (same place `libpam0g-dev` lives). Fail loud if missing. | +| `argon2_verify` C signature mismatch across libargon2 versions. | Plan uses `argon2_verify(phc, pwd, pwd_len, Argon2_id)` which is stable since libargon2 0.5 (Ubuntu 22.04+). Pin the apt dep at runtime. | +| PAM module compile-time `RECOVERY_DIR` makes unit testing painful. | Acceptable for v1 — Phase 17 (integration tests) builds a debian package and exercises the module against real `/var/lib/authforge`. Not worth a build-time override hack. | +| `pam_get_authtok(PAM_AUTHTOK, ...)` returns nothing if no prior module set the authtok. | The recovery line runs alongside (not before) pam_unix in the rendered profile — pam_unix populates PAM_AUTHTOK during its own prompt, and the `[success=ignore default=ignore]` control means recovery never blocks normal flow. If PAM_AUTHTOK is null, return `PAM_IGNORE` (we already do). | +| Two-line file format diverges from JSON if a future field is added. | Bumping to a versioned format requires touching both the daemon writer and the C reader; flagged as a future-debt item, fine for v1. | +| Race: admin issues code while user is mid-login. | RecoveryStore writes atomically (temp+rename, 0600 before rename). Worst case the user's verify reads either the old file (already gone — verify returns false → IGNORE) or the new one (one-shot consume on success). No corrupt-read window. | + +--- + +## Execution Handoff + +Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using `superpowers:using-git-worktrees`, then drive task-by-task with `superpowers:executing-plans`. diff --git a/docs/plans/2026-04-27-phase-9-and-12-gui.md b/docs/plans/2026-04-27-phase-9-and-12-gui.md new file mode 100644 index 0000000..b408871 --- /dev/null +++ b/docs/plans/2026-04-27-phase-9-and-12-gui.md @@ -0,0 +1,971 @@ +# Phase 9 (Policy Tab) + Phase 12 GUI (Recovery Tab) — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> +> **EXECUTION GATE:** Do **not** start until Phase 8 has merged to `main` (the plan at [2026-04-27-phase-8-gui-keys.md](2026-04-27-phase-8-gui-keys.md) lands `gui/src/{main,bus,error,keys_page,enroll_dialog}.rs` and the `Daemon` D-Bus client wrapper this lane builds on). + +**Goal:** Add two new tabs to the AuthForge GUI: +1. **Policy tab** (Phase 9) — three master `AdwComboRow`s for `gdm-password`, `sudo`, `sshd` (Disabled / Optional / Required), with pre-apply lockout simulation surfaced via inline `AdwBanner`. An "Advanced…" expander that opens the full stack list (gated by polkit prompt on first toggle). +2. **Recovery tab** (Phase 12 GUI half) — generate / list / revoke recovery codes. Shows the freshly-generated 8-digit code in a one-shot copy-to-clipboard dialog (it's only stored hashed, so we cannot show it again). + +**Architecture:** +- Introduce the `AppContext` struct foreseen by Phase 8's Task 7 risk note ([2026-04-27-phase-8-gui-keys.md:812](2026-04-27-phase-8-gui-keys.md#L812)) — single owner of `Daemon` and `adw::ToastOverlay` shared across pages. +- Each page is a `struct PolicyPage { root: adw::Bin, ... }` / `struct RecoveryPage { root: adw::Bin, ... }`, mirroring `KeysPage`'s shape (`Rc`, `glib::MainContext::spawn_local` for async, render swap on disconnect). +- Extend `gui/src/bus.rs` `Daemon` with policy + recovery methods. No new file there — additive only. +- Subscribe to `PolicyChanged` D-Bus signal (already emitted by daemon — see [daemon/src/dbus.rs:142](../../daemon/src/dbus.rs#L142)) so external `authforgectl policy set` invocations refresh the GUI. +- The "Advanced…" expander is the polkit prompt trigger — touching any non-master stack toggles the gating, which the daemon enforces via its existing `…apply-policy` action. + +**Tech Stack:** +- `gtk4-rs` + `libadwaita` (already pinned by Phase 8). +- `zbus` with the workspace tokio runtime feature (per [2026-04-27-phase-8-gui-keys.md:23-25](2026-04-27-phase-8-gui-keys.md#L23-L25), the GUI uses zbus's tokio integration via the workspace dep — no glib feature exists). +- `authforge_common::{policy::{Policy, StackPolicy, StorageBackend}, types::{Mode, Method, Violation, PolicyApplyResult, RecoveryCodeSummary}}` for all wire types. + +**Reference:** +- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 9 (line 1190) and § Phase 12 (line 1260). +- Phase 8 plan (consumes this lane's contracts): [2026-04-27-phase-8-gui-keys.md](2026-04-27-phase-8-gui-keys.md). +- Phase 12 backend plan (the daemon contract these views consume): [2026-04-27-phase-12-recovery-backend.md](2026-04-27-phase-12-recovery-backend.md). +- Sibling page (mirror this style): `gui/src/keys_page.rs` (after Phase 8 lands). +- D-Bus methods this lane calls: `GetPolicy`, `SetPolicy`, `GenerateRecoveryCode`, `ListRecoveryCodes`, `RevokeRecoveryCode`, signal `PolicyChanged`. + +--- + +## Conventions + +- One logical change per commit. Conventional prefixes scoped to `gui`. +- Always commit with `--no-gpg-sign`. +- TDD only for pure logic in `policy_form.rs` (mode-mapping helpers, violation rendering). Widget code ships with manual smoke (`gui/TESTING.md`). +- After each commit: `cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui`. +- Don't pre-emptively refactor `KeysPage` beyond what the AppContext refactor strictly requires — Phase 8 owns its surface. + +--- + +## Task 1: `AppContext` struct + `KeysPage` migration + +**Files:** +- Create: `gui/src/app_context.rs` +- Modify: `gui/src/main.rs` (construct AppContext, pass to pages) +- Modify: `gui/src/keys_page.rs` (constructor takes `AppContext` instead of bare overlay+window) + +**Background:** Phase 8 punted this refactor to Phase 9. AppContext owns the shared handles every page needs: `parent_window`, the populated `Daemon` (or its disconnected state), and `toast_overlay`. Pages clone the Rc to make their own copies. + +**Step 1: Create the AppContext** + +```rust +// gui/src/app_context.rs +//! Shared app-wide handles passed to every page constructor. + +use crate::bus::Daemon; +use std::cell::RefCell; +use std::rc::Rc; + +/// One-per-window struct holding handles every page needs. `daemon` is +/// `RefCell>` because the connect-on-startup attempt may fail +/// and pages render their disconnected banner; a successful Retry repopulates +/// this same cell. +#[derive(Clone)] +pub(crate) struct AppContext { + pub parent_window: gtk::Window, + pub toast_overlay: adw::ToastOverlay, + pub daemon: Rc>>, +} + +impl AppContext { + pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Self { + Self { + parent_window, + toast_overlay, + daemon: Rc::new(RefCell::new(None)), + } + } +} +``` + +**Step 2: Migrate `KeysPage::new` to take `AppContext`** + +In `gui/src/keys_page.rs`, change: + +```rust +pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc +``` + +to: + +```rust +pub fn new(ctx: AppContext) -> Rc +``` + +Replace the three field accesses (`self.parent_window`, `self.toast_overlay`, `self.daemon`) with `self.ctx.parent_window`, `self.ctx.toast_overlay`, `self.ctx.daemon`. Drop the duplicate `daemon` field on `KeysPage` itself — it now lives in AppContext. + +**Step 3: Update `main.rs` to thread the context** + +```rust +// gui/src/main.rs +mod app_context; +mod bus; +mod error; +mod keys_page; +// (Phase 9/12 modules added below — declared once Tasks 2/5 land.) + +// ... inside connect_activate ... +let toast_overlay = adw::ToastOverlay::new(); +let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone()); +let keys = keys_page::KeysPage::new(ctx.clone()); +stack.add_titled_with_icon(&keys.root, Some("keys"), "Keys", "dialog-password-symbolic"); +toast_overlay.set_child(Some(&stack)); +toolbar.set_content(Some(&toast_overlay)); +``` + +**Step 4: Build + clippy** + +Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings` +Expected: clean. + +**Step 5: Commit** + +```bash +git add gui/src/app_context.rs gui/src/main.rs gui/src/keys_page.rs +git commit --no-gpg-sign -m "refactor(gui): introduce AppContext for shared page handles" +``` + +--- + +## Task 2: Extend `bus.rs` with policy + recovery methods + +**Files:** +- Modify: `gui/src/bus.rs` + +**Background:** All additive. The existing `Daemon` proxy works for any method on `io.dangerousthings.AuthForge1`. + +**Step 1: Append to the `impl Daemon` block** + +```rust +use authforge_common::policy::Policy; +use authforge_common::types::{PolicyApplyResult, RecoveryCodeSummary}; + +impl Daemon { + // ... existing methods ... + + pub async fn get_policy(&self) -> zbus::Result { + self.proxy.call("GetPolicy", &()).await + } + + /// `force=false` runs the daemon's lockout simulator first; on + /// violations, returns `PolicyApplyResult { applied: false, violations }` + /// without writing. + pub async fn set_policy(&self, p: &Policy, force: bool) -> zbus::Result { + self.proxy.call("SetPolicy", &(p, force)).await + } + + pub async fn generate_recovery_code(&self, user: &str) -> zbus::Result { + self.proxy.call("GenerateRecoveryCode", &(user,)).await + } + + pub async fn list_recovery_codes(&self) -> zbus::Result> { + self.proxy.call("ListRecoveryCodes", &()).await + } + + pub async fn revoke_recovery_code(&self, user: &str) -> zbus::Result { + self.proxy.call("RevokeRecoveryCode", &(user,)).await + } + + pub async fn subscribe_policy_changed( + &self, + ) -> zbus::Result> { + self.proxy.receive_signal("PolicyChanged").await + } +} +``` + +**Step 2: Build + clippy** + +Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings` +Expected: clean. + +**Step 3: Commit** + +```bash +git add gui/src/bus.rs +git commit --no-gpg-sign -m "feat(gui): bus client policy + recovery method wrappers" +``` + +--- + +## Task 3: `policy_form.rs` — pure mode-mapping helpers (TDD) + +**Files:** +- Create: `gui/src/policy_form.rs` +- Modify: `gui/src/main.rs` (add `mod policy_form;`) + +**Background:** Pulling pure logic out of the widget code keeps the testable bits isolated. `mode_to_combo_index`, `combo_index_to_mode`, and the violation-grouping helper all run without a display server. + +**Step 1: Write the tests + impl** + +```rust +//! Pure helpers backing the Policy tab. Mode <-> combo index, violation +//! grouping. Widget code calls these; nothing here touches GTK. + +use authforge_common::types::{Mode, Violation}; +use std::collections::BTreeMap; + +pub(crate) const MODE_LABELS: &[(&str, Mode)] = &[ + ("Disabled", Mode::Disabled), + ("Optional", Mode::Optional), + ("Required", Mode::Required), +]; + +pub(crate) fn mode_to_combo_index(m: Mode) -> u32 { + match m { + Mode::Disabled => 0, + Mode::Optional => 1, + Mode::Required => 2, + } +} + +pub(crate) fn combo_index_to_mode(idx: u32) -> Mode { + match idx { + 0 => Mode::Disabled, + 1 => Mode::Optional, + _ => Mode::Required, + } +} + +/// Group violations by stack, joining reasons with "; ". Used to render the +/// inline AdwBanner content when `SetPolicy(force=false)` returns violations. +pub(crate) fn group_violations_by_stack(vs: &[Violation]) -> BTreeMap { + let mut by_stack: BTreeMap> = BTreeMap::new(); + for v in vs { + by_stack + .entry(v.stack.clone()) + .or_default() + .push(format!("{}: {}", v.user, v.reason)); + } + by_stack + .into_iter() + .map(|(k, vs)| (k, vs.join("; "))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mode_combo_roundtrips() { + for m in [Mode::Disabled, Mode::Optional, Mode::Required] { + assert_eq!(combo_index_to_mode(mode_to_combo_index(m)), m); + } + } + + #[test] + fn out_of_range_index_clamps_to_required() { + assert_eq!(combo_index_to_mode(99), Mode::Required); + } + + #[test] + fn violations_group_by_stack() { + let vs = vec![ + Violation { user: "alice".into(), stack: "sudo".into(), reason: "no fido2".into() }, + Violation { user: "bob".into(), stack: "sudo".into(), reason: "no fido2".into() }, + Violation { user: "alice".into(), stack: "sshd".into(), reason: "no fido2".into() }, + ]; + let g = group_violations_by_stack(&vs); + assert_eq!(g.len(), 2); + assert!(g.get("sudo").unwrap().contains("alice")); + assert!(g.get("sudo").unwrap().contains("bob")); + assert_eq!(g.get("sshd").unwrap(), "alice: no fido2"); + } +} +``` + +**Step 2: Run tests** + +Run: `cargo test -p authforge-gui policy_form` +Expected: 3 PASS. + +**Step 3: Clippy** + +Run: `cargo clippy -p authforge-gui --all-targets -- -D warnings` +Expected: clean. + +**Step 4: Commit** + +```bash +git add gui/src/policy_form.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): pure-logic helpers for Policy tab (mode mapping, violation grouping)" +``` + +--- + +## Task 4: `policy_page.rs` — three master toggles + Apply + +**Files:** +- Create: `gui/src/policy_page.rs` +- Modify: `gui/src/main.rs` (add `mod policy_page;` + register the tab) + +**Background:** Three `AdwComboRow`s, one per master stack (`gdm-password`, `sudo`, `sshd`). Each row's combo emits to a local working `Policy` clone; the bottom Apply button calls `SetPolicy(force=false)`. On violations, render an `AdwBanner` above the page with the grouped messages and a "Force apply" action that re-calls with `force=true`. + +**Step 1: Create the page** + +```rust +//! "Policy" preferences page. Three master stacks as AdwComboRow with +//! Disabled/Optional/Required. Pre-apply lockout simulation surfaces via an +//! inline AdwBanner. Advanced expander opens the full stack list (Task 6). + +use crate::app_context::AppContext; +use crate::bus::Daemon; +use crate::error::user_message; +use crate::policy_form::{combo_index_to_mode, group_violations_by_stack, mode_to_combo_index, MODE_LABELS}; +use adw::prelude::*; +use authforge_common::policy::{Policy, StackPolicy}; +use authforge_common::types::{Method, Mode}; +use gtk::glib; +use std::cell::RefCell; +use std::rc::Rc; + +const MASTER_STACKS: &[&str] = &["gdm-password", "sudo", "sshd"]; + +pub(crate) struct PolicyPage { + pub root: adw::Bin, + ctx: AppContext, + /// Working copy of policy, mutated by combo callbacks; sent to daemon on Apply. + working: Rc>, +} + +impl PolicyPage { + pub fn new(ctx: AppContext) -> Rc { + let page = Rc::new(Self { + root: adw::Bin::new(), + ctx, + working: Rc::new(RefCell::new(Policy::default())), + }); + let p = page.clone(); + glib::MainContext::default().spawn_local(async move { + p.refresh().await; + }); + page + } + + async fn refresh(self: &Rc) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => { + self.render_disconnected("Connect via the Keys tab first."); + return; + } + }; + match daemon.get_policy().await { + Ok(p) => { + *self.working.borrow_mut() = p.clone(); + self.render_form(&p, None); + } + Err(e) => self.render_disconnected(&user_message(&e)), + } + } + + fn render_disconnected(self: &Rc, msg: &str) { + let status = adw::StatusPage::builder() + .icon_name("network-offline-symbolic") + .title("Daemon unavailable") + .description(msg) + .build(); + self.root.set_child(Some(&status)); + } + + fn render_form(self: &Rc, current: &Policy, banner_msg: Option) { + let outer = gtk::Box::new(gtk::Orientation::Vertical, 0); + + if let Some(msg) = banner_msg { + let banner = adw::Banner::builder() + .title(&msg) + .button_label("Force apply") + .revealed(true) + .build(); + let me = self.clone(); + banner.connect_button_clicked(move |_| { + let me = me.clone(); + glib::MainContext::default().spawn_local(async move { + me.apply(true).await; + }); + }); + outer.append(&banner); + } + + let pref_page = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Where MFA is enforced") + .description("Disabled = no MFA. Optional = key prompted but not required. Required = blocks login without a key.") + .build(); + + for stack_name in MASTER_STACKS { + let mode = current + .stacks + .get(*stack_name) + .map(|s| s.mode) + .unwrap_or(Mode::Disabled); + + let labels: Vec<&str> = MODE_LABELS.iter().map(|(l, _)| *l).collect(); + let model = gtk::StringList::new(&labels); + let combo = adw::ComboRow::builder() + .title(*stack_name) + .model(&model) + .selected(mode_to_combo_index(mode)) + .build(); + + let me = self.clone(); + let stack_owned = stack_name.to_string(); + combo.connect_selected_notify(move |c| { + let new_mode = combo_index_to_mode(c.selected()); + me.working + .borrow_mut() + .stacks + .entry(stack_owned.clone()) + .or_insert(StackPolicy { + mode: Mode::Disabled, + methods: vec![Method::Fido2], + }) + .mode = new_mode; + }); + + group.add(&combo); + } + pref_page.add(&group); + + // Apply button row at the bottom. + let apply_group = adw::PreferencesGroup::new(); + let apply = adw::ActionRow::builder() + .title("Apply policy") + .activatable(true) + .build(); + apply.add_suffix( + >k::Image::builder() + .icon_name("emblem-ok-symbolic") + .build(), + ); + let me = self.clone(); + apply.connect_activated(move |_| { + let me = me.clone(); + glib::MainContext::default().spawn_local(async move { + me.apply(false).await; + }); + }); + apply_group.add(&apply); + pref_page.add(&apply_group); + + outer.append(&pref_page); + self.root.set_child(Some(&outer)); + } + + async fn apply(self: &Rc, force: bool) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => { + self.show_toast("Daemon unavailable."); + return; + } + }; + let pol = self.working.borrow().clone(); + match daemon.set_policy(&pol, force).await { + Ok(r) if r.applied => { + self.show_toast("Policy applied."); + self.refresh().await; + } + Ok(r) => { + let by_stack = group_violations_by_stack(&r.violations); + let summary = by_stack + .into_iter() + .map(|(s, msg)| format!("{s}: {msg}")) + .collect::>() + .join(" • "); + let banner_msg = format!("Would lock users out — {summary}"); + self.render_form(&pol, Some(banner_msg)); + } + Err(e) => self.show_toast(&user_message(&e)), + } + } + + fn show_toast(self: &Rc, msg: &str) { + self.ctx + .toast_overlay + .add_toast(adw::Toast::builder().title(msg).timeout(5).build()); + } +} +``` + +**Step 2: Wire into `main.rs`** + +```rust +let policy = policy_page::PolicyPage::new(ctx.clone()); +stack.add_titled_with_icon(&policy.root, Some("policy"), "Policy", "preferences-system-symbolic"); +``` + +**Step 3: Build + clippy** + +Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings` +Expected: clean. + +**Step 4: Commit** + +```bash +git add gui/src/policy_page.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): Policy tab with three master stacks and lockout-violation banner" +``` + +--- + +## Task 5: PolicyChanged signal subscription → live refresh + +**Files:** +- Modify: `gui/src/policy_page.rs` + +**Background:** External `authforgectl policy set` invocations bypass the GUI; without subscribing the GUI shows stale state. Subscribe once on construction; on every signal call `refresh()`. + +**Step 1: Spawn the subscriber in `PolicyPage::new`** + +After the existing `spawn_local(async move { p.refresh().await })`, add: + +```rust +let p = page.clone(); +glib::MainContext::default().spawn_local(async move { + let daemon = match p.ctx.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + let mut stream = match daemon.subscribe_policy_changed().await { + Ok(s) => s, + Err(_) => return, + }; + use futures_util::StreamExt; + while stream.next().await.is_some() { + p.refresh().await; + } +}); +``` + +**Step 2: Build + clippy** + +Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings` +Expected: clean. + +**Step 3: Commit** + +```bash +git add gui/src/policy_page.rs +git commit --no-gpg-sign -m "feat(gui): PolicyPage subscribes to PolicyChanged for live refresh" +``` + +--- + +## Task 6: Advanced expander — full stack list (polkit-gated) + +**Files:** +- Modify: `gui/src/policy_page.rs` + +**Background:** Master toggles cover the 95% case; the remaining 5% (auto-detected stacks) lives in an `AdwExpanderRow` titled "Advanced…". Toggling any non-master combo triggers the daemon's polkit prompt at `SetPolicy` time — no special GUI plumbing needed. Daemon's polkit module surfaces the prompt via the system polkit agent. + +**Step 1: Add an `AdwExpanderRow` after the master group** + +In `render_form`, after `pref_page.add(&group)` and before the Apply group: + +```rust +let advanced_group = adw::PreferencesGroup::new(); +let expander = adw::ExpanderRow::builder() + .title("Advanced") + .subtitle("Per-stack overrides for any PAM stack the daemon knows about.") + .build(); + +let mut others: Vec<&String> = current + .stacks + .keys() + .filter(|k| !MASTER_STACKS.contains(&k.as_str())) + .collect(); +others.sort(); +for stack_name in others { + let mode = current.stacks.get(stack_name).map(|s| s.mode).unwrap_or(Mode::Disabled); + let labels: Vec<&str> = MODE_LABELS.iter().map(|(l, _)| *l).collect(); + let model = gtk::StringList::new(&labels); + let combo = adw::ComboRow::builder() + .title(stack_name.as_str()) + .model(&model) + .selected(mode_to_combo_index(mode)) + .build(); + let me = self.clone(); + let s = stack_name.clone(); + combo.connect_selected_notify(move |c| { + let new_mode = combo_index_to_mode(c.selected()); + me.working + .borrow_mut() + .stacks + .entry(s.clone()) + .or_insert(StackPolicy { mode: Mode::Disabled, methods: vec![Method::Fido2] }) + .mode = new_mode; + }); + expander.add_row(&combo); +} + +advanced_group.add(&expander); +pref_page.add(&advanced_group); +``` + +**Step 2: Build + clippy** + +Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings` +Expected: clean. + +**Step 3: Commit** + +```bash +git add gui/src/policy_page.rs +git commit --no-gpg-sign -m "feat(gui): advanced expander surfaces non-master PAM stacks in Policy tab" +``` + +--- + +## Task 7: `recovery_page.rs` — list + revoke + +**Files:** +- Create: `gui/src/recovery_page.rs` +- Modify: `gui/src/main.rs` (add `mod recovery_page;` + register tab) + +**Background:** Mirrors `KeysPage` shape. List rows show user + "expires in N days/hours" + a revoke button. A "Generate code for…" entry at the bottom prompts for a username and shows the generated code in a one-shot copy dialog. + +**Step 1: Create the page** + +```rust +//! "Recovery" preferences page. Generate / list / revoke recovery codes. + +use crate::app_context::AppContext; +use crate::bus::Daemon; +use crate::error::user_message; +use adw::prelude::*; +use authforge_common::types::RecoveryCodeSummary; +use gtk::glib; +use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) struct RecoveryPage { + pub root: adw::Bin, + ctx: AppContext, +} + +impl RecoveryPage { + pub fn new(ctx: AppContext) -> Rc { + let page = Rc::new(Self { + root: adw::Bin::new(), + ctx, + }); + let p = page.clone(); + glib::MainContext::default().spawn_local(async move { + p.refresh().await; + }); + page + } + + async fn refresh(self: &Rc) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => { + self.render_disconnected("Connect via the Keys tab first."); + return; + } + }; + match daemon.list_recovery_codes().await { + Ok(entries) => self.render_list(&entries), + Err(e) => self.render_disconnected(&user_message(&e)), + } + } + + fn render_disconnected(self: &Rc, msg: &str) { + let status = adw::StatusPage::builder() + .icon_name("network-offline-symbolic") + .title("Daemon unavailable") + .description(msg) + .build(); + self.root.set_child(Some(&status)); + } + + fn render_list(self: &Rc, entries: &[RecoveryCodeSummary]) { + let pref = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Active recovery codes") + .description(if entries.is_empty() { + "No active recovery codes. Use the form below to issue one." + } else { + "Each code is one-shot and Argon2id-hashed at rest. The 8-digit plaintext is shown once at generation." + }) + .build(); + + for e in entries { + let row = adw::ActionRow::builder() + .title(&e.user) + .subtitle(&format!("expires {}", expires_relative(e.expires_unix))) + .build(); + let revoke = gtk::Button::builder() + .label("Revoke") + .css_classes(["destructive-action"]) + .valign(gtk::Align::Center) + .build(); + let me = self.clone(); + let user = e.user.clone(); + revoke.connect_clicked(move |_| { + let me = me.clone(); + let user = user.clone(); + glib::MainContext::default().spawn_local(async move { + me.do_revoke(&user).await; + }); + }); + row.add_suffix(&revoke); + group.add(&row); + } + pref.add(&group); + + let issue_group = adw::PreferencesGroup::new(); + let user_entry = adw::EntryRow::builder().title("Username").build(); + let me = self.clone(); + let entry_clone = user_entry.clone(); + let issue_btn = gtk::Button::builder() + .label("Generate code") + .css_classes(["pill", "suggested-action"]) + .halign(gtk::Align::End) + .build(); + issue_btn.connect_clicked(move |_| { + let me = me.clone(); + let user = entry_clone.text().to_string(); + if user.is_empty() { + me.show_toast("Enter a username first."); + return; + } + glib::MainContext::default().spawn_local(async move { + me.do_generate(&user).await; + }); + }); + issue_group.add(&user_entry); + let issue_row = adw::ActionRow::new(); + issue_row.add_suffix(&issue_btn); + issue_group.add(&issue_row); + pref.add(&issue_group); + + self.root.set_child(Some(&pref)); + } + + async fn do_generate(self: &Rc, user: &str) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + match daemon.generate_recovery_code(user).await { + Ok(code) => { + self.show_code_dialog(user, &code); + self.refresh().await; + } + Err(e) => self.show_toast(&user_message(&e)), + } + } + + async fn do_revoke(self: &Rc, user: &str) { + let daemon = match self.ctx.daemon.borrow().clone() { + Some(d) => d, + None => return, + }; + match daemon.revoke_recovery_code(user).await { + Ok(true) => { + self.show_toast(&format!("Revoked {user}'s recovery code.")); + self.refresh().await; + } + Ok(false) => self.show_toast(&format!("No recovery code for {user}.")), + Err(e) => self.show_toast(&user_message(&e)), + } + } + + fn show_code_dialog(self: &Rc, user: &str, code: &str) { + let dialog = adw::AlertDialog::builder() + .heading(&format!("Recovery code for {user}")) + .body("This code is shown only once. Copy it now — it cannot be recovered later.") + .build(); + dialog.add_response("copy", "Copy"); + dialog.add_response("close", "Close"); + dialog.set_default_response(Some("copy")); + dialog.set_close_response("close"); + + let label = gtk::Label::builder() + .label(code) + .css_classes(["title-1"]) + .selectable(true) + .build(); + dialog.set_extra_child(Some(&label)); + + let code_owned = code.to_string(); + let parent = self.ctx.parent_window.clone(); + dialog.connect_response(None, move |d, resp| { + if resp == "copy" { + if let Some(disp) = gtk::gdk::Display::default() { + disp.clipboard().set_text(&code_owned); + } + } + d.close(); + }); + dialog.present(Some(&parent)); + } + + fn show_toast(self: &Rc, msg: &str) { + self.ctx + .toast_overlay + .add_toast(adw::Toast::builder().title(msg).timeout(5).build()); + } +} + +fn expires_relative(unix: u64) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if unix <= now { + return "expired".to_string(); + } + let delta = unix - now; + if delta >= 24 * 3600 { + format!("in {} day(s)", delta / (24 * 3600)) + } else if delta >= 3600 { + format!("in {} hour(s)", delta / 3600) + } else { + format!("in {} min", delta / 60) + } +} +``` + +**Step 2: Wire into `main.rs`** + +```rust +let recovery = recovery_page::RecoveryPage::new(ctx.clone()); +stack.add_titled_with_icon(&recovery.root, Some("recovery"), "Recovery", "emblem-important-symbolic"); +``` + +**Step 3: Build + clippy** + +Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings` +Expected: clean. + +**Step 4: Commit** + +```bash +git add gui/src/recovery_page.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): Recovery tab with list / generate / revoke + one-shot copy dialog" +``` + +--- + +## Task 8: Acceptance gate — extend `gui/TESTING.md` + +**Files:** +- Modify: `gui/TESTING.md` + +**Step 1: Append the new manual-smoke walkthroughs** + +```markdown +## Policy tab smoke + +1. Daemon running (same setup as Keys tab smoke). +2. Open the GUI → Policy tab. Three rows: gdm-password, sudo, sshd. All start at "Disabled" on a clean system. +3. Set sudo to "Required" → Apply. With no enrolled keys you'll see an inline banner "Would lock users out — sudo: : no fido2 enrolled" and a "Force apply" button. +4. Click the trash icon on the row → row vanishes immediately. +5. Switch to Keys tab → enroll a Yubikey → switch back. Apply again with sudo=Required → toast "Policy applied". +6. In another terminal: `authforgectl policy show` should reflect the change. +7. Run `authforgectl policy set sshd disabled --methods fido2` externally → Policy tab refreshes within ~1s (PolicyChanged signal). + +## Recovery tab smoke + +1. Open Recovery tab. Empty list with hint text. +2. Type "alice" in the username row, click "Generate code". Modal opens showing the 8-digit code with a Copy button. Click Copy → paste somewhere to verify clipboard worked. +3. Close the modal. List now shows alice with "expires in 7 day(s)". +4. Click "Revoke" on alice's row → row vanishes, toast confirms. +5. Generate again, then run `authforgectl recovery list` externally — should show alice with the same expiry. +``` + +**Step 2: Commit** + +```bash +git add gui/TESTING.md +git commit --no-gpg-sign -m "docs(gui): smoke-test recipes for Policy and Recovery tabs" +``` + +--- + +## Task 9: Master plan closeout note + +**Files:** +- Modify: `docs/plans/2026-04-26-authforge-implementation.md` + +**Step 1: Flip Phase 9 + Phase 12 GUI to ✅ Code complete** + +Update the roadmap rows at lines 39 and 42: + +``` +| 9 | GUI: Policy tab + lockout-warning UX | 4 days | ✅ **Code complete** (yyyy-mm-dd) | +| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (yyyy-mm-dd) — backend + GUI both landed | +``` + +**Step 2: Append a closeout-notes section** + +```markdown +### Phase 9 + Phase 12 GUI closeout notes (yyyy-mm-dd) + +Landed via the plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md). Built atop the Phase 8 GUI shell. + +**Done in Phase 9:** +- `gui/src/app_context.rs` — `AppContext` struct shared across pages (foreseen by Phase 8 Task 7). +- `gui/src/policy_form.rs` — pure mode-mapping + violation-grouping helpers. 3 unit tests. +- `gui/src/policy_page.rs` — Policy tab with three master stacks (gdm-password / sudo / sshd) as `AdwComboRow`, inline `AdwBanner` for lockout simulation results, "Force apply" action, `AdwExpanderRow` for non-master stacks, live refresh via `PolicyChanged` subscription. + +**Done in Phase 12 GUI:** +- `gui/src/recovery_page.rs` — list/generate/revoke. One-shot copy dialog for freshly issued codes (codes are Argon2id-hashed at rest by the daemon — never showable twice). +- `gui/src/bus.rs` extended with `get_policy`, `set_policy`, `generate_recovery_code`, `list_recovery_codes`, `revoke_recovery_code`, `subscribe_policy_changed`. + +**Plan deviations:** _(fill in)_. + +**Deferred until Phase 14 VM smoke:** +- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session. +``` + +**Step 3: Commit** + +```bash +git add docs/plans/2026-04-26-authforge-implementation.md +git commit --no-gpg-sign -m "docs: phase 9 + phase 12 GUI closeout notes" +``` + +--- + +## Phase 9 + Phase 12 GUI Acceptance Gate + +Verify before tagging: + +- [ ] `cargo build -p authforge-gui` clean. +- [ ] `cargo clippy -p authforge-gui --all-targets -- -D warnings` clean. +- [ ] `cargo test -p authforge-gui` — all error-classifier tests + 3 new `policy_form` tests PASS. +- [ ] `cargo fmt --all -- --check` clean. +- [ ] `cargo build --workspace --release` succeeds. +- [ ] Manual smoke (deferred to live system + Yubikey): Policy and Recovery tab walkthroughs in `gui/TESTING.md` pass. + +Tag the milestone: + +```bash +git tag -a v0.6.0-gui-policy-recovery -m "Phase 9 + Phase 12 GUI: Policy and Recovery tabs" +``` + +--- + +## Risks / known unknowns + +| Risk | Mitigation | +|---|---| +| Phase 8's final `Daemon` shape diverges from what's documented in [2026-04-27-phase-8-gui-keys.md:184-235](2026-04-27-phase-8-gui-keys.md#L184-L235). | EXECUTION GATE waits for Phase 8 to land. If Phase 8 chose a different shape, Task 2 absorbs the rename — purely additive method names map 1:1. | +| `adw::Banner` API differs across libadwaita 0.6 minors (`set_revealed` vs `revealed` builder). | Plan uses the builder + `connect_button_clicked` signal which has been stable since libadwaita 0.5. If a particular minor drops it, fall back to a top-of-page `gtk::InfoBar`. | +| `PolicyChanged` signal uses zbus `tokio` runtime here whereas Phase 8 may have inadvertently picked the glib feature. | The Phase 8 plan was patched (commit `a17f70c`) to clarify "no glib feature exists" — this lane uses `workspace = true` for zbus, matching what Phase 8 lands. Verify at execution time by reading `gui/Cargo.toml` after Phase 8 merges. | +| Clipboard set on a non-default display fails silently. | Acceptable — the dialog also shows the code in a selectable label, so the user can copy by hand. Toast doesn't fire because no error is returned by `gdk::Clipboard::set_text`. | +| `AdwEntryRow::text()` returns a `glib::GString`; needs `.to_string()` for `to_owned`. | Plan already does this; flagged so future edits don't accidentally borrow across an await. | +| Daemon's lockout simulator (Phase 5) returns `Vec` but doesn't currently include user-friendly fix suggestions — only "no fido2 enrolled". | The banner copy in Task 4 reflects this: it lists the violation reasons verbatim. A richer "fix it" UX is a Phase 17/18 polish item, not Phase 9. | + +--- + +## 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`. **Do not start until Phase 8 lands on `main`.**