From d20643cde56bf75c0b0aad4f76da567ca6991f0a Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 10:28:23 -0700 Subject: [PATCH] docs(plan): coordinator + 3 step-level plans for parallel Phase 9/10/11 fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships four documents: * 2026-04-27-parallel-fanout-9-10-11.md — coordinator. File-conflict map, prep-lane scope rationale, post-prep parallel-safety guarantee, merge order rule, and risks. * 2026-04-27-prep-shell-and-pending.md — small ~1hr prep lane: extract AppContext, add --first-run flag scaffold (with stub firstrun::run), add daemon GetPendingStatus D-Bus method + GUI client wrapper, new PendingStatus wire type. 6 tasks, single agent. * 2026-04-27-phase-10-firstrun.md — first-login flow. Fullscreen modal, 60s idle watchdog (TDD'd as pure WatchdogState), enrollment via Phase 8 enroll_dialog, ClearPendingFlag on success, gnome-session-quit on idle, autostart .desktop entry, debian install. 6 tasks. * 2026-04-27-phase-11-totp.md — TOTP support behind default-on Cargo feature. Daemon-side: 160-bit secret + base32 + otpauth URI + atomic 0600 writes in pam_google_authenticator format. PAM profile renderer extension. D-Bus surface. CLI subcommand. GUI tab with QR modal. Deviation flagged: TOTP recovery codes reuse the Phase 12 recovery flow rather than introducing a parallel hashed-recovery file format. 9 tasks. Execution model: prep lane first (single agent ~1hr), then dispatch three subagents in parallel worktrees for the three follow-on lanes. Per the conflict map, the lanes are file-disjoint after prep merges; merge order is any-order. If all four lanes land cleanly, the roadmap jumps from 13/19 to 17/19 phases code-complete in a single session, leaving Phase 13 (deb finalization), 14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0 release tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-27-parallel-fanout-9-10-11.md | 141 ++ docs/plans/2026-04-27-phase-10-firstrun.md | 588 ++++++++ docs/plans/2026-04-27-phase-11-totp.md | 1293 +++++++++++++++++ .../2026-04-27-prep-shell-and-pending.md | 487 +++++++ 4 files changed, 2509 insertions(+) create mode 100644 docs/plans/2026-04-27-parallel-fanout-9-10-11.md create mode 100644 docs/plans/2026-04-27-phase-10-firstrun.md create mode 100644 docs/plans/2026-04-27-phase-11-totp.md create mode 100644 docs/plans/2026-04-27-prep-shell-and-pending.md diff --git a/docs/plans/2026-04-27-parallel-fanout-9-10-11.md b/docs/plans/2026-04-27-parallel-fanout-9-10-11.md new file mode 100644 index 0000000..944e132 --- /dev/null +++ b/docs/plans/2026-04-27-parallel-fanout-9-10-11.md @@ -0,0 +1,141 @@ +# Parallel-Fan-out Strategy: Phases 9 + 10 + 11 (and the Phase 12 GUI tab) + +> **For Claude:** This is a coordinator document, not an implementation plan. Each lane it points to has its own step-level plan that uses `superpowers:executing-plans`. Run the prep lane first; then the three follow-on lanes can execute concurrently in their own worktrees. + +**Goal:** Land Phases 9, 10, 11, and the Phase 12 GUI tab as quickly as possible by maximizing concurrent work across worktree-isolated agents. + +**Reality:** The naive "three worktrees, all branched off main" approach hits a hard wall — every lane wants to edit `gui/src/main.rs`, two of them want to edit `daemon/src/{dbus,state}.rs`, and at least one tab will need plumbing through `gui/src/bus.rs`. A direct fan-out merges sequentially in practice because every lane has a `main.rs` conflict to resolve. + +**Strategy:** A small **prep lane** establishes shared scaffolding — `AppContext`, the `--first-run` flag branch in `main.rs`, and the daemon's pending-flag query D-Bus methods — *before* the fan-out. After prep merges, the three follow-on lanes are file-disjoint and merge in any order. + +--- + +## Conflict map (no prep) + +| File | Phase 9 + 12-GUI | Phase 10 (first-login) | Phase 11 (TOTP) | +|---|:---:|:---:|:---:| +| `gui/src/main.rs` | adds `Policy` + `Recovery` ViewStack tabs | adds `--first-run` flag + alternate startup branch | adds `TOTP` ViewStack tab | +| `gui/src/bus.rs` | adds 5 policy/recovery methods | adds 1 pending-status method | adds ~3 TOTP methods | +| `gui/src/keys_page.rs` | constructor migrates to `AppContext` | — | — | +| `gui/src/app_context.rs` | **creates** | reads | reads | +| `daemon/src/dbus.rs` | — | adds `GetPendingStatus` | adds 3 TOTP methods | +| `daemon/src/state.rs` | — | promotes `has_pending` out of `#[cfg(test)]` | adds TOTP enrollment surface | +| `daemon/src/policy_apply.rs` | — | — | renders `pam_google_authenticator.so` when TOTP required | +| `common/src/types.rs` | — | adds `PendingStatus` wire type | adds `TotpEnrollment` + `TotpRecoveryCodes` wire types | +| `daemon/Cargo.toml` | — | — | adds `totp` feature, `totp-rs`, `data-encoding` deps | +| `Cargo.toml` workspace | — | — | adds `totp-rs`, `data-encoding`, `qrcode` to `[workspace.dependencies]` | +| `gui/Cargo.toml` | — | — | adds `qrcode` (feature-gated) | +| `debian/control` | — | maybe (Depends) | adds `Recommends: libpam-google-authenticator` | +| `gui/src/firstrun.rs` | — | **creates** | — | +| `gui/src/totp_page.rs` | — | — | **creates** | +| `gui/src/policy_page.rs` + `recovery_page.rs` | **creates** | — | — | +| `daemon/src/totp/mod.rs` | — | — | **creates** | + +**Read:** four files (`gui/src/main.rs`, `gui/src/bus.rs`, `daemon/src/dbus.rs`, `daemon/src/state.rs`) collide across two or three lanes. Without prep, every fan-out merge resolves them by hand. + +## Conflict map (with prep lane in place) + +After the prep lane lands the shared scaffolding: + +| File | Phase 9 + 12-GUI | Phase 10 | Phase 11 | +|---|:---:|:---:|:---:| +| `gui/src/main.rs` | adds 2 lines (tab registrations) | **untouched** — uses prep's `--first-run` branch | adds 1 line (tab registration) | +| `gui/src/bus.rs` | adds 5 methods (additive) | **untouched** — prep has the pending methods | adds 3 methods (additive) | +| `daemon/src/dbus.rs` | — | **untouched** — prep has `GetPendingStatus` | adds 3 methods | +| `daemon/src/state.rs` | — | **untouched** — prep promoted `has_pending` | adds TOTP methods | +| `gui/src/app_context.rs` | reads | reads | reads | + +**Read:** the only remaining `main.rs` collisions are tab-registration lines from Lane A and Lane C — those slot into the same ViewStack but on disjoint lines and resolve trivially. All three lanes can land in any order. + +--- + +## The four lanes + +### Prep lane (single-agent, ~1 hr wall-clock) + +**Plan:** [2026-04-27-prep-shell-and-pending.md](2026-04-27-prep-shell-and-pending.md) + +Establishes the cross-cutting infrastructure. Drops Task 1 from the existing Phase 9 plan (AppContext refactor moves here). Adds the daemon's `GetPendingStatus` D-Bus method that Phase 10 needs. Adds `--first-run` flag parsing + a stub `firstrun::run()` so Phase 10 fills it without re-touching `main.rs`. + +**Acceptance gate:** workspace tests pass, the `--first-run` flag is parseable but no-ops with a placeholder, the new D-Bus methods round-trip through the p2p test fixture. + +**MUST land first.** All three follow-on lanes branch off the merge of prep into main. + +### Lane A — GUI policy + recovery (Phase 9 + Phase 12 GUI tab) + +**Plan:** [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md) (already written — pre-merge of prep, drop Task 1). + +**File footprint:** `gui/src/{policy_form,policy_page,recovery_page}.rs` (new), additive `gui/src/bus.rs` policy + recovery methods, two `add_titled_with_icon` lines in `main.rs`, optional `keys_page.rs` ToastOverlay tweak. + +### Lane B — First-login flow (Phase 10) + +**Plan:** [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md) + +**File footprint:** `gui/src/firstrun.rs` (new — fills the prep stub), `gui/data/authforge-firstrun.desktop` (new autostart), `debian/authforge-gui.install` (install autostart entry). + +### Lane C — TOTP support (Phase 11) + +**Plan:** [2026-04-27-phase-11-totp.md](2026-04-27-phase-11-totp.md) + +**File footprint:** `daemon/src/totp/mod.rs` (new), additions to `daemon/src/{state,dbus,policy_apply}.rs`, `daemon/Cargo.toml` feature flag, `gui/src/totp_page.rs` (new), one `main.rs` tab line, `common/src/types.rs` (two new wire types), `debian/control`, workspace + GUI `Cargo.toml`. + +--- + +## Execution model + +### Prep lane + +```bash +git worktree add .claude/worktrees/prep-shell-and-pending -b feature/prep-shell-and-pending +cd .claude/worktrees/prep-shell-and-pending +# follow 2026-04-27-prep-shell-and-pending.md task-by-task +# tests + clippy + fmt + release build green +# merge --no-ff back to main +git worktree remove .claude/worktrees/prep-shell-and-pending +git branch -d feature/prep-shell-and-pending +``` + +### Three concurrent follow-on lanes + +After prep merges, dispatch three subagents in parallel via `superpowers:dispatching-parallel-agents`: + +```text +Subagent 1 (worktree feature/phase-9-and-12-gui): + → executes 2026-04-27-phase-9-and-12-gui.md (skip Task 1, prep handled it) +Subagent 2 (worktree feature/phase-10-firstrun): + → executes 2026-04-27-phase-10-firstrun.md +Subagent 3 (worktree feature/phase-11-totp): + → executes 2026-04-27-phase-11-totp.md +``` + +Each lane verifies tests + clippy in its own worktree before reporting. Once all three return green, merge them serially into main in any order. The `gui/src/main.rs` ViewStack-tab additions from Lane A and Lane C may produce a 1-line conflict per merge — resolve by including both tab lines. + +### Merge order rule + +**Any order works.** The conflict on `gui/src/main.rs` is bounded to "both lanes added a tab line in the same area." Resolve by keeping both. No semantic interaction. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| Prep lane mis-defines the `AppContext` shape and Lane A's plan needs revision. | Lane A's plan is already in this repo and was written assuming the AppContext shape that prep will land. Prep mirrors the structure verbatim — see prep Task 1. If prep's Task 1 deviates, update Lane A's plan in the same commit. | +| Concurrent `cargo` runs across three worktrees race on `~/.cargo/registry` lock. | Brief synchronization (per Phase 4+5 closeout note), not a hang. Acceptable. | +| Phase 11 introduces a `totp` Cargo feature that's default-on; CI must build with feature off too. | Add a `cargo build --workspace --no-default-features` line to the CI workflow as part of Lane C's Task 1. | +| Lane B's `--first-run` runtime behavior depends on a daemon that's running. | Watchdog keeps the modal alive 60s; if no daemon, the modal logs and exits gracefully (mirrors Phase 8's disconnect-banner pattern). Spelled out in Phase 10 Task 4. | +| Lane C's `pam_google_authenticator.so` must already be installed for the rendered profile to validate. | `debian/control` lists it as `Recommends:`, not `Depends:` — installing the daemon doesn't pull it in unless the user opts in. Daemon's `policy_apply::apply` uses the existing rollback path if `pam-auth-update` rejects the profile. | +| Three subagents producing simultaneous output saturates the main session's context. | Subagents run with `report in under 200 words` instructions in their dispatch prompts; details live in commit messages. | + +--- + +## Once-fully-landed roll-up + +After all four lanes merge: + +- Phase 9: ✅ Code complete (GUI smoke deferred to Phase 14) +- Phase 10: ✅ Code complete (full-flow VM smoke deferred to Phase 14) +- Phase 11: ✅ Code complete (TOTP enrollment + recovery codes; `pamtester` smoke deferred to Phase 14) +- Phase 12: ✅ Done (backend, GUI tab, end-to-end CLI all in) + +**Roadmap progress** jumps from 13/19 to **17/19** in the same session, leaving only Phase 13 (deb finalization), 14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0 release tag. diff --git a/docs/plans/2026-04-27-phase-10-firstrun.md b/docs/plans/2026-04-27-phase-10-firstrun.md new file mode 100644 index 0000000..507b3ea --- /dev/null +++ b/docs/plans/2026-04-27-phase-10-firstrun.md @@ -0,0 +1,588 @@ +# Phase 10: First-Login Flow — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> +> **EXECUTION GATE:** Do **not** start until the prep lane ([2026-04-27-prep-shell-and-pending.md](2026-04-27-prep-shell-and-pending.md)) has merged to `main`. This lane fills the `firstrun::run()` stub the prep lane established and consumes the `GetPendingStatus` D-Bus method the prep lane added. + +**Goal:** Land Flow C from the design doc end-to-end. When a user with `chage -d 0` (forced password change) logs in for the first time, the GTK GUI launches in fullscreen modal mode, queries the daemon's pending flag for the current user, prompts them through enrollment, clears the pending flag on success, and exits cleanly. A 60-second watchdog falls back to `gnome-session-quit --logout` if the user goes idle. + +**Architecture:** +- `gui/src/firstrun.rs` is the modal. Replaces the prep lane's stub (`fn run(_ctx) { exit(0) }`) with the real flow. +- The modal is an `adw::ApplicationWindow` configured `fullscreened(true)`, with `decorated(false)` and a sticky-on-top hint. No `HeaderBar`, no ViewSwitcher. +- Flow: connect to daemon → `GetPendingStatus(current_user)` → if absent, exit; if present, present an `AdwStatusPage` with a "Get started" button → on click, run the same `enroll_dialog` Phase 8 wired up → on success, call `ClearPendingFlag` → close window + exit. On user inactivity (no clicks/keypresses for 60s) → `gnome-session-quit --logout --no-prompt`. +- Autostart: `gui/data/authforge-firstrun.desktop` is a `.desktop` entry installed to `/etc/xdg/autostart/` with `OnlyShowIn=GNOME` and `X-GNOME-Autostart-Phase=Initialization`. It runs `authforge --first-run` at session start; the binary returns immediately if the daemon reports no pending flag, so the cost on every session start is one D-Bus call (~ms). + +**Tech Stack:** +- `gtk4-rs` + `libadwaita` (already pinned). +- `zbus` (workspace tokio config). +- `gtk::EventControllerKey` + `gtk::GestureClick` for activity tracking the watchdog. +- `glib::source::timeout_add_local` for the watchdog tick. +- `std::process::Command::new("gnome-session-quit")` for the fallback logout. + +**Reference:** +- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 10 (line 1209). +- Design doc: [2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) § Flow C. +- Sibling page (mirror this style): `gui/src/keys_page.rs` (post-Phase 8, post-prep AppContext refactor). +- Enrollment dialog (reuse): `gui/src/enroll_dialog.rs` (Phase 8). +- Daemon contract: `GetPendingStatus(user) -> PendingStatus` (added by prep lane), `ClearPendingFlag(user)` (Phase 1). +- Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md). + +--- + +## Conventions + +- One logical change per commit. Conventional prefixes scoped to `gui` / `pkg` / `docs`. +- Always commit with `--no-gpg-sign`. +- TDD only for the watchdog timer logic in `firstrun::watchdog::WatchdogState` (pure logic — testable without a display server). +- After every commit: `cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui`. +- Manual smoke (the only realistic acceptance gate for the modal UX) is in [Task 6](#task-6-acceptance-gate--vm-smoke-recipe). CI does not run it. + +--- + +## Task 1: `WatchdogState` pure-logic helper (TDD) + +**Files:** +- Create: `gui/src/firstrun/watchdog.rs` +- Modify: `gui/src/firstrun.rs` (currently a one-line stub from prep — convert to a module dir or add `mod watchdog;`) + +**Background:** Watchdog state is "did anything happen in the last N seconds?" — that's testable without GTK. Pull it out so the widget code in [Task 2](#task-2-fullscreen-modal-window--enrollment-flow) is just a glue layer. + +**Step 1: Convert `firstrun.rs` to a module dir** + +If `gui/src/firstrun.rs` exists as a single file (from prep lane Task 2), turn it into: + +``` +gui/src/firstrun/mod.rs <- the existing run(ctx) stub +gui/src/firstrun/watchdog.rs <- new +``` + +Move the prep-lane stub from `firstrun.rs` to `firstrun/mod.rs`, then delete `firstrun.rs`. + +(If the prep lane already kept it as a single file and `mod firstrun;` resolves to `firstrun.rs`, deleting the file + creating `firstrun/mod.rs` is the same module from `main.rs`'s perspective — no `main.rs` edit needed.) + +**Step 2: Write the test + impl** + +```rust +// gui/src/firstrun/watchdog.rs +//! Tracks the last-activity timestamp; the GTK timer queries this each tick +//! to decide whether 60s of idle has elapsed. + +use std::time::{Duration, Instant}; + +pub(crate) struct WatchdogState { + last_activity: Instant, + timeout: Duration, +} + +impl WatchdogState { + pub fn new(timeout: Duration) -> Self { + Self { + last_activity: Instant::now(), + timeout, + } + } + + pub fn poke(&mut self) { + self.last_activity = Instant::now(); + } + + /// True iff `timeout` has passed since the last poke. Once expired, + /// stays expired until the caller drops + recreates. + pub fn expired(&self) -> bool { + self.last_activity.elapsed() >= self.timeout + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_state_is_not_expired() { + let w = WatchdogState::new(Duration::from_secs(60)); + assert!(!w.expired()); + } + + #[test] + fn zero_timeout_is_immediately_expired() { + let w = WatchdogState::new(Duration::from_millis(0)); + std::thread::sleep(Duration::from_millis(2)); + assert!(w.expired()); + } + + #[test] + fn poke_resets_the_clock() { + let mut w = WatchdogState::new(Duration::from_millis(100)); + std::thread::sleep(Duration::from_millis(150)); + assert!(w.expired()); + w.poke(); + assert!(!w.expired()); + } +} +``` + +**Step 3: Wire `mod watchdog;` into `firstrun/mod.rs`** + +```rust +// gui/src/firstrun/mod.rs +mod watchdog; +pub(crate) use watchdog::WatchdogState; // re-export for the widget code + +#![allow(dead_code)] // run() body still stub; Task 2 fills it. + +use crate::app_context::AppContext; + +pub(crate) fn run(_ctx: AppContext) { + eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting."); + std::process::exit(0); +} +``` + +**Step 4: Run tests** + +Run: `cargo test -p authforge-gui firstrun::watchdog` +Expected: 3 PASS. + +**Step 5: Commit** + +```bash +git add gui/src/firstrun/mod.rs gui/src/firstrun/watchdog.rs +git rm gui/src/firstrun.rs # if it existed as a single file +git commit --no-gpg-sign -m "feat(gui): WatchdogState pure-logic helper for first-run modal" +``` + +--- + +## Task 2: Fullscreen modal window + enrollment flow + +**Files:** +- Modify: `gui/src/firstrun/mod.rs` — replace the stub `run()` with the real flow. + +**Background:** The window is fullscreen, undecorated, and shows an `AdwStatusPage` with three states: connecting / pending-flag-found / pending-flag-absent. On a present pending flag, the user clicks "Get started" → `enroll_dialog::present` runs against the daemon → on success, `ClearPendingFlag` → window closes → process exits. + +**Step 1: Replace `run()`** + +```rust +// gui/src/firstrun/mod.rs +mod watchdog; +pub(crate) use watchdog::WatchdogState; + +use crate::app_context::AppContext; +use crate::bus::{current_user, Daemon}; +use crate::error::user_message; +use adw::prelude::*; +use gtk::glib; +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60); + +pub(crate) fn run(ctx: AppContext) { + let win = adw::ApplicationWindow::builder() + .application(>k::gio::Application::default() + .expect("application set on connect_activate") + .downcast::() + .expect("adw::Application")) + .title("Authentication setup") + .decorated(false) + .fullscreened(true) + .build(); + let _ = ctx; // window owns its own context for the modal; not strictly needed + + let status = adw::StatusPage::builder() + .icon_name("dialog-password-symbolic") + .title("Setting up authentication") + .description("Connecting to the authentication daemon…") + .build(); + win.set_content(Some(&status)); + + // Watchdog state shared across the activity-poke handlers and the timer. + let watchdog = Rc::new(RefCell::new(WatchdogState::new(WATCHDOG_TIMEOUT))); + + install_activity_pokes(&win, watchdog.clone()); + install_watchdog_timer(&win, watchdog.clone()); + + // Async startup: GetPendingStatus → render flow. + let win_for_async = win.clone(); + let status_for_async = status.clone(); + glib::MainContext::default().spawn_local(async move { + let daemon = match Daemon::connect().await { + Ok(d) => d, + Err(e) => { + status_for_async.set_description(Some(&user_message(&e))); + // Stay open so the user sees the error; watchdog will + // logout in 60s if they don't take action. + return; + } + }; + let user = current_user(); + let s = match daemon.get_pending_status(&user).await { + Ok(s) => s, + Err(e) => { + status_for_async.set_description(Some(&user_message(&e))); + return; + } + }; + if !s.present { + // Nothing for us to do — exit cleanly. Most session starts + // hit this path; cost is one D-Bus call. + win_for_async.close(); + std::process::exit(0); + } + render_get_started(&win_for_async, &status_for_async, daemon, user); + }); + + win.present(); +} + +fn render_get_started( + win: &adw::ApplicationWindow, + status: &adw::StatusPage, + daemon: Daemon, + user: String, +) { + status.set_title("Welcome"); + status.set_description(Some("Enroll a security key to finish setting up your account.")); + let go = gtk::Button::builder() + .label("Get started") + .css_classes(["pill", "suggested-action"]) + .halign(gtk::Align::Center) + .build(); + let win_for_click = win.clone(); + let daemon_for_click = daemon.clone(); + let user_for_click = user.clone(); + go.connect_clicked(move |btn| { + // Disable the button so a second click doesn't double-prompt. + btn.set_sensitive(false); + let win_inner = win_for_click.clone(); + let daemon_inner = daemon_for_click.clone(); + let user_inner = user_for_click.clone(); + crate::enroll_dialog::present( + &win_inner.clone().upcast::(), + daemon_inner.clone(), + move || { + // On success: clear the pending flag, then exit. + let win_done = win_inner.clone(); + let daemon_done = daemon_inner.clone(); + let user_done = user_inner.clone(); + glib::MainContext::default().spawn_local(async move { + if let Err(e) = daemon_done.clear_pending_flag(&user_done).await { + // Even if clearing fails, exit; the user enrolled + // a key and the next login will succeed via pam_u2f + // even if pending is still set. + eprintln!("authforge: ClearPendingFlag failed: {}", user_message(&e)); + } + win_done.close(); + std::process::exit(0); + }); + }, + ); + }); + status.set_child(Some(&go)); +} + +fn install_activity_pokes(win: &adw::ApplicationWindow, watchdog: Rc>) { + // Any keypress or click counts as activity. + let key = gtk::EventControllerKey::new(); + let w = watchdog.clone(); + key.connect_key_pressed(move |_, _, _, _| { + w.borrow_mut().poke(); + glib::Propagation::Proceed + }); + win.add_controller(key); + + let click = gtk::GestureClick::new(); + let w = watchdog.clone(); + click.connect_pressed(move |_, _, _, _| w.borrow_mut().poke()); + win.add_controller(click); +} + +fn install_watchdog_timer(win: &adw::ApplicationWindow, watchdog: Rc>) { + let win_for_timer = win.clone(); + glib::source::timeout_add_local(Duration::from_secs(5), move || { + if watchdog.borrow().expired() { + // 60s idle — kick the user back out. gnome-session-quit + // is the documented path for "log this user out cleanly." + let _ = std::process::Command::new("gnome-session-quit") + .arg("--logout") + .arg("--no-prompt") + .spawn(); + win_for_timer.close(); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); +} + +pub(crate) fn run_or_stub(_ctx: AppContext) { + // Old prep stub kept as a fallback name during PR review; remove this fn + // once the real `run()` is reviewed. +} +``` + +(Drop the `run_or_stub` function before commit — it's only there to ease review of the diff against the prep stub.) + +**Step 2: Build + clippy** + +```bash +cargo build -p authforge-gui +cargo clippy -p authforge-gui --all-targets -- -D warnings +``` + +Expected: clean. The first compile may surface API-shape mismatches against the installed gtk4-rs minor (fullscreened builder, StatusPage::set_child, EventControllerKey return type); fix locally — these are stable surface in gtk4-rs 0.8 + libadwaita 0.6. + +**Step 3: Smoke test (deferred to manual; CI cannot run a fullscreen window)** + +Skip: covered in [Task 6](#task-6-acceptance-gate--vm-smoke-recipe). + +**Step 4: Commit** + +```bash +git add gui/src/firstrun/mod.rs +git commit --no-gpg-sign -m "feat(gui): first-run modal with enrollment + 60s idle watchdog" +``` + +--- + +## Task 3: Autostart `.desktop` entry + +**Files:** +- Create: `gui/data/authforge-firstrun.desktop` +- Modify: `debian/authforge-gui.install` — install the autostart entry to `/etc/xdg/autostart/` + +**Step 1: Create the .desktop file** + +```ini +# gui/data/authforge-firstrun.desktop +[Desktop Entry] +Type=Application +Name=AuthForge First-Run Setup +Comment=Prompts new users to enroll a security key. +Exec=authforge --first-run +NoDisplay=true +OnlyShowIn=GNOME; +X-GNOME-Autostart-Phase=Initialization +X-GNOME-Autostart-enabled=true +``` + +`NoDisplay=true` keeps it out of the apps menu — it's an autostart-only entry. `OnlyShowIn=GNOME;` matches the rest of the project's GNOME-first stance (other DEs can ship their own entries later). `X-GNOME-Autostart-Phase=Initialization` runs it before the user's normal autostarts. + +**Step 2: Install via debian/authforge-gui.install** + +Read the existing `debian/authforge-gui.install` — it likely has a single line installing the binary. Append: + +``` +gui/data/authforge-firstrun.desktop /etc/xdg/autostart +``` + +**Step 3: Verify the file syntax** + +`desktop-file-validate` is the canonical check, but the harness sandbox may not have it. As a fallback, verify the file is parseable as INI: + +```bash +python3 -c "import configparser; c = configparser.ConfigParser(); c.read('gui/data/authforge-firstrun.desktop'); print(dict(c['Desktop Entry']))" +``` + +Expected: dict prints with `Type`, `Name`, `Exec`, `OnlyShowIn`, etc. + +**Step 4: Commit** + +```bash +git add gui/data/authforge-firstrun.desktop debian/authforge-gui.install +git commit --no-gpg-sign -m "feat(pkg): autostart .desktop entry running authforge --first-run on GNOME login" +``` + +--- + +## Task 4: Daemon-disconnect graceful exit + +**Files:** +- Modify: `gui/src/firstrun/mod.rs` + +**Background:** Task 2 leaves a daemon-connect failure visible-but-stuck (the watchdog eventually fires, but the user sees no feedback for 60s). Tighten the UX: a connect failure shows a "Daemon unavailable" status with a manual "Skip" button that just exits the modal. + +**Step 1: Add the skip button on connect failure** + +Replace the `Err(e) => { status_for_async.set_description(...); return; }` arm in `run()`: + +```rust +Err(e) => { + status_for_async.set_title("Setup unavailable"); + status_for_async.set_description(Some(&user_message(&e))); + let skip = gtk::Button::builder() + .label("Skip") + .css_classes(["pill"]) + .halign(gtk::Align::Center) + .build(); + let win_skip = win_for_async.clone(); + skip.connect_clicked(move |_| { + win_skip.close(); + std::process::exit(0); + }); + status_for_async.set_child(Some(&skip)); + return; +} +``` + +Also adjust the `GetPendingStatus`-error arm to render the same Skip button. + +**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/firstrun/mod.rs +git commit --no-gpg-sign -m "feat(gui): firstrun modal shows Skip button on daemon-unreachable" +``` + +--- + +## Task 5: Master plan closeout note + +**Files:** +- Modify: `docs/plans/2026-04-26-authforge-implementation.md` + +**Step 1: Flip Phase 10 to ✅ Code complete** + +In the roadmap row at line 40 (post-prep, post-Phase-12-merge): + +``` +| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (yyyy-mm-dd) — VM smoke deferred | +``` + +**Step 2: Append a closeout note** + +After the Phase 12 backend closeout block: + +```markdown +### Phase 10 closeout notes (yyyy-mm-dd) + +Landed via [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md). Built atop the prep lane's `--first-run` flag scaffold and the `GetPendingStatus` D-Bus method. + +**Done:** +- `gui/src/firstrun/{mod,watchdog}.rs` — fullscreen `adw::ApplicationWindow` modal, 60s idle watchdog (3 unit tests on `WatchdogState`), enrollment via the Phase 8 `enroll_dialog`, `ClearPendingFlag` on success, `gnome-session-quit --logout` on idle. +- `gui/data/authforge-firstrun.desktop` — autostart entry installed to `/etc/xdg/autostart/`. `OnlyShowIn=GNOME`, `X-GNOME-Autostart-Phase=Initialization`. Cost on every session start: one D-Bus call to `GetPendingStatus`; no pending flag → immediate exit 0. +- `debian/authforge-gui.install` — installs the autostart entry alongside the binary. + +**Plan deviations:** _(fill in)_. + +**Deferred until Phase 14 VM smoke:** the full Flow C walkthrough (`useradd -m testuser; chage -d 0 testuser; sudo authforgectl pending set testuser --methods fido2; logout; login; see modal; enroll; see desktop`). +``` + +**Step 3: Commit** + +```bash +git add docs/plans/2026-04-26-authforge-implementation.md +git commit --no-gpg-sign -m "docs: phase 10 closeout notes" +``` + +--- + +## Task 6: Acceptance gate + VM smoke recipe + +**Files:** +- Create: `gui/FIRSTRUN-TESTING.md` + +**Step 1: Document the manual recipe** + +```markdown +# Testing the First-Run Modal (Phase 10) + +Phase 10's user-visible flow can only be exercised against a real GTK +session. CI verifies that the binary builds and `--first-run` parses; +this recipe is the manual gate. + +## Prerequisites + +- Ubuntu 24.04 VM with GNOME on Wayland (matches the project's target). +- `authforge-daemon` + `authforge-gui` debs installed (Phase 14 PPA build). +- A USB FIDO2 device (Yubikey, Solo, etc.) attached to the VM. + +## Walk-through + +```bash +# As root in the VM: +sudo useradd -m testuser +sudo passwd testuser # set a temporary password +sudo chage -d 0 testuser # force password change at next login +sudo authforgectl pending set testuser --methods fido2 + +# Log out of the current session, log in as testuser. +# Expected sequence: +# 1. GDM forces a password change → set the real password. +# 2. Session starts. +# 3. authforge --first-run launches via the autostart entry. +# 4. Fullscreen modal appears: "Welcome — Enroll a security key…" +# 5. Click "Get started" → enrollment dialog appears. +# 6. Touch the Yubikey → modal closes → desktop usable. +# 7. Verify: authforgectl pending list (run as root in another tty) shows +# no pending flag for testuser. + +# Re-test the auto-logout watchdog: +sudo authforgectl pending set testuser --methods fido2 +# Log out, log in as testuser, do nothing for 60+ seconds. +# Expected: the user is logged out via gnome-session-quit and returned +# to GDM. + +# Re-test the daemon-down case: +sudo systemctl stop authforge-daemon.service +# Log out, log in as testuser. The modal shows "Setup unavailable" with +# a Skip button; clicking it returns to the desktop. +sudo systemctl start authforge-daemon.service +``` + +## Pass criteria + +- [ ] The modal appears within 2s of session start. +- [ ] Successful enrollment clears the pending flag and exits the modal. +- [ ] 60s idle triggers a clean session logout. +- [ ] Daemon-down shows a graceful Skip button rather than hanging. +- [ ] No pending flag → no modal → session start cost is < 100ms (verify + with `journalctl --since=now -f` for the autostart unit). +``` + +**Step 2: Commit** + +```bash +git add gui/FIRSTRUN-TESTING.md +git commit --no-gpg-sign -m "docs(gui): VM smoke recipe for the first-run modal" +``` + +--- + +## Phase 10 Acceptance Gate + +Verify before merging: + +- [ ] `cargo build --workspace --release` clean. +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean. +- [ ] `cargo test --workspace` — gui test count up by 3 (the new `WatchdogState` tests). +- [ ] `cargo fmt --all -- --check` clean. +- [ ] `target/release/authforge --first-run` exits 0 in either of: (a) "no pending flag" path (immediate exit); (b) "daemon down" path (modal opens, Skip available — verified by hand only because no display server in CI). +- [ ] `python3 -c "import configparser; ..."` parses `gui/data/authforge-firstrun.desktop` clean. +- [ ] Manual VM smoke (deferred to Phase 14, recipe in `gui/FIRSTRUN-TESTING.md`): four pass criteria all green. + +--- + +## Risks / known unknowns + +| Risk | Mitigation | +|---|---| +| `gnome-session-quit` is GNOME-only; on KDE / non-GNOME the watchdog logout is a no-op. | The `.desktop` entry is `OnlyShowIn=GNOME;` so on non-GNOME the modal never autostarts. Acceptable v1. KDE port (out of scope) ships its own first-run path. | +| The user closes the modal via Alt-F4 / window manager kill before enrollment finishes. | The modal is `decorated(false)` — no window controls. Alt-F4 still works (Wayland) but the watchdog catches abandonment. If a user kills the modal, the pending flag stays set and they hit it again next login. | +| `--first-run` is invoked manually (e.g. by a developer) on a system where the daemon isn't installed. | Daemon-connect failure path renders the Skip button. Process exits 0. | +| `glib::source::timeout_add_local` requires `!Send` callbacks — the watchdog `Rc>` is fine, but writing tests against the GTK main loop is not. | `WatchdogState` is pure-logic and Send/Sync; the GTK glue is tested manually. | +| The modal grabs focus before GDM's password-change dialog has fully closed — visual glitch / focus race. | The autostart entry uses `X-GNOME-Autostart-Phase=Initialization` which runs *before* the user's normal autostart phase but *after* GDM hands the session to GNOME — that's the right slot. If a race manifests in VM smoke, fall back to phase=`Application` (slightly later). | +| Wayland `decorated(false)` + `fullscreened(true)` is the right combination — older X11 needed `keep_above` too. | We target Ubuntu 24.04 which is Wayland-by-default. X11 fallback is already covered by the autostart entry running per-session-type without explicit X-only targeting. | + +--- + +## 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 the prep lane has merged to `main`. + +After this lane lands, the coordinator doc lists the parallel-safe order: any of (Phase 9 + 12 GUI), Phase 10, Phase 11 can merge in any order. diff --git a/docs/plans/2026-04-27-phase-11-totp.md b/docs/plans/2026-04-27-phase-11-totp.md new file mode 100644 index 0000000..6d5ebe7 --- /dev/null +++ b/docs/plans/2026-04-27-phase-11-totp.md @@ -0,0 +1,1293 @@ +# Phase 11: TOTP Support — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> +> **EXECUTION GATE:** Do **not** start until the prep lane ([2026-04-27-prep-shell-and-pending.md](2026-04-27-prep-shell-and-pending.md)) has merged to `main`. This lane uses the `AppContext` and the existing daemon `policy_apply::render_profile` surface that was extended in Phase 12. + +**Goal:** Land RFC 6238 TOTP as a third authenticator method, gated behind a build-time Cargo feature `totp` (default-on). The daemon owns secret generation + persistence at `/etc/google-authenticator/` (the path libpam-google-authenticator reads). PAM verification is delegated to `pam_google_authenticator.so` (apt: `libpam-google-authenticator`); we don't reimplement RFC 6238 verification. The GUI's TOTP tab shows the QR code + base32 secret on first enrollment and a re-issue / revoke button for already-enrolled users. + +**Architecture:** +- **Secret generation** is daemon-side. `daemon/src/totp/mod.rs` produces a 160-bit cryptographic-random secret, base32-encodes it, and renders an `otpauth://` URI for QR display. +- **Persistence** is `daemon/src/storage/totp.rs`'s `TotpStore`. File at `/etc/google-authenticator/` mode 0600 root — that's where `pam_google_authenticator.so` looks via `secret=/etc/google-authenticator/${USER}`. File format is the upstream pam_google_authenticator format (line 1: base32 secret; line 2: `" TOTP_AUTH"`). Atomic writes via temp+rename. +- **Recovery codes**: deviating from the master plan's "8 codes hashed via Argon2id" — we **reuse the Phase 12 recovery flow** instead. A user who loses their TOTP device asks an admin to run `authforgectl recovery generate alice`, gets a one-shot code, logs in via the existing PAM `mode=recovery` path. Avoids a second hashed-recovery file format and unifies the lost-credential UX. Documented in the closeout note. +- **PAM profile renderer** (`daemon/src/policy_apply.rs`) gains a TOTP branch: when any stack has `Mode::Required` + `Method::Totp` in its method list, the rendered profile includes `auth required pam_google_authenticator.so secret=/etc/google-authenticator/${USER}` before the existing FIDO2 line. +- **D-Bus** gains `EnrollTotp(user) -> TotpEnrollment`, `IsTotpEnrolled(user) -> bool`, `RevokeTotp(user) -> bool`. polkit gates `…enroll-totp`, `…revoke-totp`. +- **GUI tab** (`gui/src/totp_page.rs`) lists enrollment state, offers an enroll button that opens an `adw::AlertDialog` containing the QR code (rendered by the `qrcode` crate) plus the base32 secret string for manual-entry fallback. +- **Cargo feature** `totp` is default-on at the workspace level. Daemon code is `#[cfg(feature = "totp")]`-gated. GUI tab is `#[cfg(feature = "totp")]`-gated. Building with `--no-default-features` produces a usable workspace without TOTP. + +**Tech Stack:** +- `data-encoding` 2.6 — base32 encoding (no padding) for the secret. +- `qrcode` 0.14 — QR generation in the GUI (feature-gated). +- `rand` (already in workspace) — secret RNG. +- `pam_google_authenticator.so` — PAM-side verification, runtime dep installed via `Recommends:` on the `authforge-daemon` package. + +**Reference:** +- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 11 (line 1237). +- Sibling daemon module (mirror this style): [daemon/src/recovery.rs](../../daemon/src/recovery.rs). +- Sibling store (mirror this style): [daemon/src/storage/recovery.rs](../../daemon/src/storage/recovery.rs). +- Profile renderer to extend: [daemon/src/policy_apply.rs](../../daemon/src/policy_apply.rs). +- Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md). +- pam_google_authenticator file format: `man 8 pam_google_authenticator` (Ubuntu manpage). + +--- + +## Conventions + +- One logical change per commit. Conventional prefixes scoped to subsystem. +- Always commit with `--no-gpg-sign`. +- TDD for `daemon/src/totp/mod.rs` (pure logic — secret generation, base32, URI render) and `daemon/src/storage/totp.rs` (file roundtrip + path traversal). GUI tab ships with manual smoke; QR rendering is verified by hand. +- After every commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo build --workspace`. Add a `cargo build --workspace --no-default-features` check after Task 1 to verify the feature gate works. + +--- + +## Task 1: Cargo `totp` feature + workspace deps + +**Files:** +- Modify: `Cargo.toml` (workspace) — add `data-encoding`, `qrcode` to `[workspace.dependencies]`. +- Modify: `daemon/Cargo.toml` — add `[features]` block with `default = ["totp"]`, `totp = ["dep:data-encoding"]`. +- Modify: `gui/Cargo.toml` — add `[features]` with `default = ["totp"]`, `totp = ["dep:qrcode"]`. +- Modify: `debian/control` — add `Recommends: libpam-google-authenticator` to the `authforge-daemon` paragraph. + +**Step 1: Workspace deps** + +Append to `[workspace.dependencies]` in `/home/work/VSCodeProjects/authforge/Cargo.toml`: + +```toml +data-encoding = "2.6" +qrcode = { version = "0.14", default-features = false, features = ["svg"] } +``` + +(`svg` feature lets the GUI render to an `gtk::Image` via `gdk::Texture::from_bytes`; the default `image` feature pulls in heavy raster deps we don't need.) + +**Step 2: Daemon feature gate** + +In `daemon/Cargo.toml`, append: + +```toml +[features] +default = ["totp"] +totp = ["dep:data-encoding"] + +[dependencies] +# ... existing ... +data-encoding = { workspace = true, optional = true } +``` + +**Step 3: GUI feature gate** + +In `gui/Cargo.toml`, append: + +```toml +[features] +default = ["totp"] +totp = ["dep:qrcode"] + +[dependencies] +# ... existing ... +qrcode = { workspace = true, optional = true } +``` + +**Step 4: debian/control** + +Open `debian/control`, find the `authforge-daemon` Package paragraph, append to its `Recommends:` line (creating it if absent): + +``` +Recommends: libpam-google-authenticator +``` + +**Step 5: Verify both build modes** + +```bash +cargo build --workspace +cargo build --workspace --no-default-features +``` + +Both should compile clean (the second run drops the optional deps; nothing in main code references them yet). + +**Step 6: Commit** + +```bash +git add Cargo.toml daemon/Cargo.toml gui/Cargo.toml debian/control Cargo.lock +git commit --no-gpg-sign -m "chore: add totp feature gate (default-on) + data-encoding + qrcode deps" +``` + +--- + +## Task 2: `daemon/src/totp/mod.rs` — pure logic (TDD) + +**Files:** +- Create: `daemon/src/totp/mod.rs` +- Modify: `daemon/src/main.rs` — `#[cfg(feature = "totp")] mod totp;` + +**Step 1: Stub the module + register** + +Add to `daemon/src/main.rs` next to the other `mod ...` declarations: + +```rust +#[cfg(feature = "totp")] +mod totp; +``` + +**Step 2: Write the test + impl** + +```rust +// daemon/src/totp/mod.rs +//! Pure TOTP logic: generate 160-bit secret, base32-encode, render +//! the otpauth:// URI for QR display. No verification — that's +//! pam_google_authenticator's job at PAM time. + +#![allow(dead_code)] // wired through TotpStore (Task 3) and AppState (Task 4). + +use data_encoding::BASE32_NOPAD; +use rand::RngCore; + +/// 160 bits per RFC 6238 §5.1. +pub(crate) const SECRET_BYTES: usize = 20; + +pub(crate) fn generate_secret() -> [u8; SECRET_BYTES] { + let mut buf = [0u8; SECRET_BYTES]; + rand::rng().fill_bytes(&mut buf); + buf +} + +pub(crate) fn encode_secret(secret: &[u8]) -> String { + BASE32_NOPAD.encode(secret) +} + +/// Build an `otpauth://` URI suitable for QR encoding. +/// Format follows Google Authenticator's de-facto spec: +/// otpauth://totp/:?secret=&issuer= +pub(crate) fn otpauth_uri(secret_b32: &str, account: &str, issuer: &str) -> String { + let acct = url_encode(account); + let iss = url_encode(issuer); + format!( + "otpauth://totp/{iss}:{acct}?secret={secret_b32}&issuer={iss}&algorithm=SHA1&digits=6&period=30" + ) +} + +/// Minimal RFC 3986 url-encoder for the small set of characters that show +/// up in usernames + the literal "AuthForge" issuer. Avoids pulling in a +/// percent-encoding crate just for this one call site. +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c), + _ => { + let mut buf = [0u8; 4]; + for b in c.encode_utf8(&mut buf).bytes() { + out.push_str(&format!("%{b:02X}")); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_secret_is_160_bits() { + let s = generate_secret(); + assert_eq!(s.len(), 20); + // Two consecutive draws should differ with overwhelming probability. + let s2 = generate_secret(); + assert_ne!(s, s2); + } + + #[test] + fn base32_round_trips() { + let s = generate_secret(); + let enc = encode_secret(&s); + let dec = BASE32_NOPAD.decode(enc.as_bytes()).unwrap(); + assert_eq!(dec, s); + } + + #[test] + fn base32_uses_no_padding_uppercase() { + let s = [0u8; 20]; + let enc = encode_secret(&s); + assert!(enc.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); + assert!(!enc.contains('=')); + } + + #[test] + fn otpauth_uri_includes_account_and_issuer() { + let uri = otpauth_uri("ABCDEFGH", "alice", "AuthForge"); + assert!(uri.starts_with("otpauth://totp/AuthForge:alice?")); + assert!(uri.contains("secret=ABCDEFGH")); + assert!(uri.contains("issuer=AuthForge")); + } + + #[test] + fn url_encode_handles_special_chars() { + // Realistic-ish: a username with a dot and a space (rare on Linux, + // but the encoder must not silently drop bytes). + let uri = otpauth_uri("S", "user name", "AuthForge"); + assert!(uri.contains("user%20name")); + } +} +``` + +**Step 3: Run tests** + +```bash +cargo test -p authforge-daemon totp::tests +``` + +Expected: 5 PASS. + +**Step 4: Verify the no-default-features path still builds** + +```bash +cargo build --workspace --no-default-features +``` + +Expected: clean (the `#[cfg(feature = "totp")]` keeps the module out). + +**Step 5: Commit** + +```bash +git add daemon/src/main.rs daemon/src/totp/mod.rs +git commit --no-gpg-sign -m "feat(daemon): TOTP secret generation + base32 + otpauth URI (feature-gated)" +``` + +--- + +## Task 3: `daemon/src/storage/totp.rs` — `TotpStore` (TDD) + +**Files:** +- Create: `daemon/src/storage/totp.rs` +- Modify: `daemon/src/storage/mod.rs` — `#[cfg(feature = "totp")] pub(crate) mod totp;` + +**Background:** Mirror `RecoveryStore`'s shape ([daemon/src/storage/recovery.rs](../../daemon/src/storage/recovery.rs)). File at `/` mode 0600. Format is pam_google_authenticator's expected layout. + +**Step 1: Write the impl** + +```rust +// daemon/src/storage/totp.rs +//! Per-user TOTP secret persistence. File at `/` mode 0600, +//! root-owned. Format is pam_google_authenticator's expected layout: +//! \n " TOTP_AUTH"\n +//! That's the minimum valid file; more options can be appended later. + +#![allow(dead_code)] // wired through AppState in Task 4. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(crate) enum TotpStoreError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("invalid username: {0:?}")] + InvalidUser(String), +} + +pub(crate) struct TotpStore { + dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TotpEnrollment { + pub user: String, + pub secret_b32: String, + pub otpauth_uri: String, +} + +impl TotpStore { + 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) => TotpStoreError::InvalidUser(s), + }) + } + + /// Generate a fresh secret, write the pam_google_authenticator file + /// atomically, return the enrollment payload. + pub fn enroll(&self, user: &str, issuer: &str) -> Result { + fs::create_dir_all(&self.dir)?; + fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o755))?; + + let path = self.user_path(user)?; + let secret = crate::totp::generate_secret(); + let secret_b32 = crate::totp::encode_secret(&secret); + let body = format!("{secret_b32}\n\" TOTP_AUTH\"\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)?; + + let uri = crate::totp::otpauth_uri(&secret_b32, user, issuer); + Ok(TotpEnrollment { + user: user.to_string(), + secret_b32, + otpauth_uri: uri, + }) + } + + pub fn is_enrolled(&self, user: &str) -> Result { + let path = self.user_path(user)?; + match fs::metadata(&path) { + Ok(m) => Ok(m.is_file()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + } + } + + 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()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn store() -> (tempfile::TempDir, TotpStore) { + let d = tempdir().unwrap(); + let s = TotpStore::new(d.path().to_path_buf()); + (d, s) + } + + #[test] + fn enroll_creates_file_with_mode_0600() { + let (_d, s) = store(); + let e = s.enroll("alice", "AuthForge").unwrap(); + assert_eq!(e.user, "alice"); + assert!(e.secret_b32.len() >= 32); // 160 bits → 32 base32 chars + assert!(e.otpauth_uri.starts_with("otpauth://totp/AuthForge:alice?")); + let path = s.user_path("alice").unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn enroll_writes_pam_google_authenticator_format() { + let (_d, s) = store(); + s.enroll("alice", "AuthForge").unwrap(); + let body = fs::read_to_string(s.user_path("alice").unwrap()).unwrap(); + let mut lines = body.lines(); + let secret = lines.next().unwrap(); + let opt = lines.next().unwrap(); + // Line 1: base32 secret only — no leading space, no leading quote. + assert!(secret.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); + assert_eq!(opt, "\" TOTP_AUTH\""); + } + + #[test] + fn is_enrolled_reflects_disk_state() { + let (_d, s) = store(); + assert!(!s.is_enrolled("alice").unwrap()); + s.enroll("alice", "AuthForge").unwrap(); + assert!(s.is_enrolled("alice").unwrap()); + } + + #[test] + fn revoke_returns_false_on_missing_user() { + let (_d, s) = store(); + assert!(!s.revoke("ghost").unwrap()); + } + + #[test] + fn revoke_then_is_enrolled_false() { + let (_d, s) = store(); + s.enroll("alice", "AuthForge").unwrap(); + assert!(s.revoke("alice").unwrap()); + assert!(!s.is_enrolled("alice").unwrap()); + } + + #[test] + fn rejects_traversal_usernames() { + let (_d, s) = store(); + for evil in ["", ".", "..", "a/b", "x\0y"] { + assert!(s.enroll(evil, "AuthForge").is_err()); + } + } +} +``` + +**Step 2: Register the module under feature gate** + +In `daemon/src/storage/mod.rs`: + +```rust +pub(crate) mod credentials; +pub(crate) mod pending; +pub(crate) mod policy; +pub(crate) mod recovery; +pub(crate) mod safe_user; +#[cfg(feature = "totp")] +pub(crate) mod totp; +pub(crate) mod userdb; +``` + +**Step 3: Run tests** + +```bash +cargo test -p authforge-daemon storage::totp +``` + +Expected: 6 PASS. + +**Step 4: Commit** + +```bash +git add daemon/src/storage/totp.rs daemon/src/storage/mod.rs +git commit --no-gpg-sign -m "feat(daemon): TotpStore with atomic 0600 writes in pam_google_authenticator format" +``` + +--- + +## Task 4: Wire into `AppState` + new wire type in `common` + +**Files:** +- Modify: `common/src/types.rs` — add `TotpEnrollment` wire type. +- Modify: `daemon/src/state.rs` — `totp_dir` in `StorageConfig`, `totp` field on `AppState`, three new methods. + +**Step 1: Add the wire type** + +```rust +// common/src/types.rs — append before the existing `#[cfg(test)]` block +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] +pub struct TotpEnrollment { + pub user: String, + /// Base32-encoded shared secret, no padding. ~32 chars for 160 bits. + pub secret_b32: String, + /// Full `otpauth://totp/...` URI suitable for QR encoding. + pub otpauth_uri: String, +} +``` + +(This is the same shape as `daemon/src/storage/totp::TotpEnrollment` — but lives in `common` for the D-Bus surface. The daemon-side struct is purely internal; consumers see this one.) + +**Step 2: `StorageConfig` field + env override** + +In `daemon/src/state.rs`, add to `StorageConfig`: + +```rust +#[cfg(feature = "totp")] +pub totp_dir: PathBuf, +``` + +And in `from_env_or_defaults` (still inside the same impl): + +```rust +#[cfg(feature = "totp")] +totp_dir: env("AUTHFORGE_TOTP_DIR", "/etc/google-authenticator"), +``` + +Add `#[cfg(feature = "totp")]` to the `AppState` `totp` field and `AppState::open` construction. + +**Step 3: AppState methods (feature-gated)** + +```rust +#[cfg(feature = "totp")] +impl AppState { + pub async fn enroll_totp( + &self, + user: &str, + ) -> Result { + let inner = self + .totp + .enroll(user, "AuthForge") + .map_err(|e| StateError::Storage(e.to_string()))?; + Ok(authforge_common::types::TotpEnrollment { + user: inner.user, + secret_b32: inner.secret_b32, + otpauth_uri: inner.otpauth_uri, + }) + } + + pub async fn is_totp_enrolled(&self, user: &str) -> Result { + self.totp + .is_enrolled(user) + .map_err(|e| StateError::Storage(e.to_string())) + } + + pub async fn revoke_totp(&self, user: &str) -> Result { + self.totp + .revoke(user) + .map_err(|e| StateError::Storage(e.to_string())) + } +} +``` + +(If `StateError` doesn't have a `Storage(String)` variant yet from Phase 12, look at how the recovery error is mapped and follow that pattern.) + +**Step 4: Update test fixtures** + +The two `open_in` / inline `AppState::open` test fixtures in `state.rs` and the `p2p_pair` helper in `dbus.rs` need a feature-gated `totp_dir: d.path().join("totp")` line: + +```rust +StorageConfig { + policy_dir: ..., + pending_dir: ..., + recovery_dir: ..., + #[cfg(feature = "totp")] + totp_dir: d.path().join("totp"), + userdb_path: ..., + pam_profile_path: ..., + pam_auth_update: ..., +} +``` + +**Step 5: Run tests + both build modes** + +```bash +cargo test -p authforge-daemon +cargo build --workspace --no-default-features +``` + +Both clean. + +**Step 6: Commit** + +```bash +git add common/src/types.rs daemon/src/state.rs daemon/src/dbus.rs +git commit --no-gpg-sign -m "feat(daemon): wire TotpStore into AppState behind feature gate" +``` + +--- + +## Task 5: D-Bus methods + polkit actions + +**Files:** +- Modify: `daemon/src/dbus.rs` — add `EnrollTotp`, `IsTotpEnrolled`, `RevokeTotp`. +- Modify: `debian/io.dangerousthings.AuthForge.policy` — add two polkit actions. + +**Step 1: Add the methods (feature-gated)** + +In `daemon/src/dbus.rs`, inside the `impl AuthForge` (the `#[zbus::interface]` block): + +```rust +#[cfg(feature = "totp")] +async fn enroll_totp(&self, user: String) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.enroll-totp").await?; + self.state + .enroll_totp(&user) + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) +} + +#[cfg(feature = "totp")] +async fn is_totp_enrolled(&self, user: String) -> zbus::fdo::Result { + // No polkit gate — pure read. + self.state + .is_totp_enrolled(&user) + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) +} + +#[cfg(feature = "totp")] +async fn revoke_totp(&self, user: String) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.revoke-totp").await?; + self.state + .revoke_totp(&user) + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) +} +``` + +Add `TotpEnrollment` to the import line at the top of `dbus.rs`: + +```rust +use authforge_common::types::{Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary}; +#[cfg(feature = "totp")] +use authforge_common::types::TotpEnrollment; +``` + +(Adjust depending on what's already imported.) + +**Step 2: Add polkit actions** + +In `debian/io.dangerousthings.AuthForge.policy`, before the closing ``: + +```xml + + Enroll a TOTP secret + Authentication is required to enroll a TOTP secret. + + auth_self_keep + auth_admin_keep + auth_self_keep + + + + + Revoke a TOTP enrollment + Administrator authentication is required to revoke a TOTP enrollment. + + auth_admin_keep + auth_admin_keep + auth_admin_keep + + +``` + +(`enroll-totp` is `auth_self_keep` because it's a per-user operation; revoke is admin.) + +**Step 3: D-Bus integration tests** + +In `daemon/src/dbus.rs`'s test module: + +```rust +#[cfg(feature = "totp")] +#[tokio::test] +async fn enroll_totp_returns_otpauth_uri() { + use authforge_common::types::TotpEnrollment; + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let e: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap(); + assert_eq!(e.user, "alice"); + assert!(e.otpauth_uri.starts_with("otpauth://totp/AuthForge:alice?")); + assert!(e.secret_b32.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); +} + +#[cfg(feature = "totp")] +#[tokio::test] +async fn is_totp_enrolled_round_trips() { + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let before: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap(); + assert!(!before); + let _: authforge_common::types::TotpEnrollment = + p.call("EnrollTotp", &("alice",)).await.unwrap(); + let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap(); + assert!(after); +} + +#[cfg(feature = "totp")] +#[tokio::test] +async fn revoke_totp_removes_enrollment() { + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let _: authforge_common::types::TotpEnrollment = + p.call("EnrollTotp", &("alice",)).await.unwrap(); + let removed: bool = p.call("RevokeTotp", &("alice",)).await.unwrap(); + assert!(removed); + let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap(); + assert!(!after); +} +``` + +**Step 4: Run tests + clippy** + +```bash +cargo test -p authforge-daemon +cargo clippy --workspace --all-targets -- -D warnings +cargo build --workspace --no-default-features +``` + +All clean. + +**Step 5: Commit** + +```bash +git add daemon/src/dbus.rs debian/io.dangerousthings.AuthForge.policy +git commit --no-gpg-sign -m "feat(daemon): EnrollTotp + IsTotpEnrolled + RevokeTotp D-Bus methods" +``` + +--- + +## Task 6: PAM profile renderer — TOTP line + +**Files:** +- Modify: `daemon/src/policy_apply.rs` + +**Background:** When any stack has `Mode::Required` + `Method::Totp` in its method list, prepend a `pam_google_authenticator.so` line to the auth chain. Place it **after** the recovery line (Phase 12) and **before** the FIDO2 line, so the order is: recovery → TOTP → FIDO2 → pending-backstop. + +**Step 1: Extend `render_profile`** + +Find the existing `let any_required_fido2 = ...` line and add a sibling: + +```rust +let any_required_totp = p.stacks.values().any(|s| { + s.mode == Mode::Required + && s.methods + .iter() + .any(|m| matches!(m, authforge_common::types::Method::Totp)) +}); +``` + +Then build the auth lines incrementally: + +```rust +let mut lines: Vec = Vec::new(); +lines.push( + " [success=done default=ignore] pam_authforge_pending.so mode=recovery" + .to_string(), +); + +#[cfg(feature = "totp")] +if any_required_totp { + lines.push( + " [success=ok default=die] pam_google_authenticator.so secret=/etc/google-authenticator/${USER}" + .to_string(), + ); +} + +// existing FIDO2 + pending-backstop lines +if any_required_fido2 { + lines.push(" [success=ok default=1 ignore=ignore] pam_u2f.so cue authfile=/etc/u2f_mappings".to_string()); + lines.push(" [success=ok default=die] pam_authforge_pending.so".to_string()); +} else { + lines.push(" [success=ok default=die] pam_authforge_pending.so".to_string()); +} + +let auth_lines = lines.join("\n"); +``` + +(Without the `#[cfg(feature = "totp")]`, the TOTP line is suppressed at build time — workspace `--no-default-features` builds an authforged that can't render a TOTP profile.) + +Adjust the `Default:` line: it should be `yes` if any stack requires fido2 OR totp: + +```rust +let default = if any_required_fido2 { + "yes" +} else { + #[cfg(feature = "totp")] + { + if any_required_totp { "yes" } else { "no" } + } + #[cfg(not(feature = "totp"))] + { + "no" + } +}; +``` + +**Step 2: New tests for the renderer** + +In the existing `#[cfg(test)] mod tests`: + +```rust +#[cfg(feature = "totp")] +#[test] +fn render_required_totp_includes_pam_google_authenticator() { + use authforge_common::policy::StackPolicy; + use authforge_common::types::Method; + use std::collections::BTreeMap; + let mut stacks = BTreeMap::new(); + stacks.insert( + "sudo".to_string(), + StackPolicy { + mode: Mode::Required, + methods: vec![Method::Totp], + }, + ); + let p = Policy { stacks, ..Default::default() }; + let body = render_profile(&p); + assert!(body.contains("pam_google_authenticator.so")); + assert!(body.contains("secret=/etc/google-authenticator/${USER}")); + assert!(body.contains("Default: yes")); +} + +#[cfg(feature = "totp")] +#[test] +fn render_no_totp_when_only_fido2_required() { + use authforge_common::policy::StackPolicy; + use authforge_common::types::Method; + use std::collections::BTreeMap; + let mut stacks = BTreeMap::new(); + stacks.insert( + "sudo".to_string(), + StackPolicy { + mode: Mode::Required, + methods: vec![Method::Fido2], + }, + ); + let p = Policy { stacks, ..Default::default() }; + let body = render_profile(&p); + assert!(!body.contains("pam_google_authenticator.so")); + assert!(body.contains("pam_u2f.so")); +} +``` + +**Step 3: Run tests** + +```bash +cargo test -p authforge-daemon policy_apply +cargo build --workspace --no-default-features # TOTP renderer absent in this mode +``` + +Expected: clean. + +**Step 4: Commit** + +```bash +git add daemon/src/policy_apply.rs +git commit --no-gpg-sign -m "feat(daemon): render pam_google_authenticator.so when TOTP-required stack present" +``` + +--- + +## Task 7: GUI tab — `gui/src/totp_page.rs` + +**Files:** +- Create: `gui/src/totp_page.rs` +- Modify: `gui/src/main.rs` — `#[cfg(feature = "totp")] mod totp_page;` + tab registration line. +- Modify: `gui/src/bus.rs` — additive `enroll_totp`, `is_totp_enrolled`, `revoke_totp` methods (feature-gated). + +**Background:** Single-user view (current user via `current_user()`). Shows enrollment state. "Enroll" button → calls `EnrollTotp` → modal dialog with QR code (rendered from `otpauth_uri` via `qrcode` crate's SVG output) + base32 secret displayed as a selectable label. "Revoke" button after enrollment → calls `RevokeTotp` → toast confirms. + +**Step 1: Extend `bus.rs`** + +```rust +// gui/src/bus.rs — append, feature-gated +#[cfg(feature = "totp")] +impl Daemon { + pub async fn enroll_totp( + &self, + user: &str, + ) -> zbus::Result { + self.proxy.call("EnrollTotp", &(user,)).await + } + + pub async fn is_totp_enrolled(&self, user: &str) -> zbus::Result { + self.proxy.call("IsTotpEnrolled", &(user,)).await + } + + pub async fn revoke_totp(&self, user: &str) -> zbus::Result { + self.proxy.call("RevokeTotp", &(user,)).await + } +} +``` + +**Step 2: Create `totp_page.rs`** + +```rust +// gui/src/totp_page.rs +//! "TOTP" preferences page. Single-user (current user via $USER). +//! Feature-gated: only built when --features=totp is on (default). + +use crate::app_context::AppContext; +use crate::bus::{current_user, Daemon}; +use crate::error::user_message; +use adw::prelude::*; +use gtk::glib; +use std::rc::Rc; + +pub(crate) struct TotpPage { + pub root: adw::Bin, + ctx: AppContext, +} + +impl TotpPage { + 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; + } + }; + let user = current_user(); + let enrolled = match daemon.is_totp_enrolled(&user).await { + Ok(b) => b, + Err(e) => { + self.render_disconnected(&user_message(&e)); + return; + } + }; + self.render(daemon, &user, enrolled); + } + + 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(self: &Rc, daemon: Daemon, user: &str, enrolled: bool) { + let pref = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::builder() + .title("Authenticator app (TOTP)") + .description(if enrolled { + "Your account is enrolled. Use your authenticator app's 6-digit code at the password prompt." + } else { + "Enroll a TOTP secret to use a phone authenticator app (Aegis, Authenticator, etc.) as a second factor." + }) + .build(); + + let row = adw::ActionRow::builder() + .title(if enrolled { "Enrolled" } else { "Not enrolled" }) + .subtitle(user) + .build(); + + let btn = if enrolled { + let b = gtk::Button::builder() + .label("Revoke") + .css_classes(["destructive-action"]) + .valign(gtk::Align::Center) + .build(); + let me = self.clone(); + let user_owned = user.to_string(); + let daemon_owned = daemon.clone(); + b.connect_clicked(move |_| { + let me = me.clone(); + let user = user_owned.clone(); + let daemon = daemon_owned.clone(); + glib::MainContext::default().spawn_local(async move { + match daemon.revoke_totp(&user).await { + Ok(true) => { + me.show_toast("TOTP enrollment revoked."); + me.refresh().await; + } + Ok(false) => me.show_toast("Was not enrolled."), + Err(e) => me.show_toast(&user_message(&e)), + } + }); + }); + b + } else { + let b = gtk::Button::builder() + .label("Enroll") + .css_classes(["pill", "suggested-action"]) + .valign(gtk::Align::Center) + .build(); + let me = self.clone(); + let user_owned = user.to_string(); + let daemon_owned = daemon.clone(); + b.connect_clicked(move |_| { + let me = me.clone(); + let user = user_owned.clone(); + let daemon = daemon_owned.clone(); + glib::MainContext::default().spawn_local(async move { + match daemon.enroll_totp(&user).await { + Ok(e) => { + me.show_qr_dialog(&e.otpauth_uri, &e.secret_b32); + me.refresh().await; + } + Err(err) => me.show_toast(&user_message(&err)), + } + }); + }); + b + }; + row.add_suffix(&btn); + group.add(&row); + pref.add(&group); + self.root.set_child(Some(&pref)); + } + + fn show_qr_dialog(self: &Rc, otpauth_uri: &str, secret_b32: &str) { + let dialog = adw::AlertDialog::builder() + .heading("Scan with your authenticator app") + .body(&format!( + "Or enter this secret manually: {secret_b32}" + )) + .build(); + dialog.add_response("close", "Done"); + dialog.set_default_response(Some("close")); + dialog.set_close_response("close"); + + // Render the QR as SVG, then load into a gtk::Picture. + let qr = qrcode::QrCode::new(otpauth_uri.as_bytes()) + .expect("otpauth URI is QR-encodable"); + let svg = qr.render::() + .min_dimensions(256, 256) + .build(); + // Wrap SVG bytes in a gio::Bytes → gdk::Texture. + let bytes = gtk::glib::Bytes::from(svg.as_bytes()); + let texture = gtk::gdk::Texture::from_bytes(&bytes).ok(); + if let Some(tex) = texture { + let pic = gtk::Picture::for_paintable(&tex); + pic.set_size_request(256, 256); + dialog.set_extra_child(Some(&pic)); + } + dialog.present(Some(&self.ctx.parent_window)); + } + + fn show_toast(self: &Rc, msg: &str) { + self.ctx + .toast_overlay + .add_toast(adw::Toast::builder().title(msg).timeout(5).build()); + } +} +``` + +**Step 3: Wire into `main.rs`** + +```rust +// gui/src/main.rs — at the top of the mod block +#[cfg(feature = "totp")] +mod totp_page; + +// inside connect_activate, after existing tab registrations: +#[cfg(feature = "totp")] +{ + let totp = totp_page::TotpPage::new(ctx.clone()); + stack.add_titled_with_icon( + &totp.root, + Some("totp"), + "TOTP", + "preferences-system-time-symbolic", + ); +} +``` + +**Step 4: Build + clippy** + +```bash +cargo build -p authforge-gui +cargo build -p authforge-gui --no-default-features +cargo clippy --workspace --all-targets -- -D warnings +``` + +All clean. The `--no-default-features` build excludes the TOTP tab and the `qrcode` dep entirely. + +**Step 5: Commit** + +```bash +git add gui/src/totp_page.rs gui/src/main.rs gui/src/bus.rs +git commit --no-gpg-sign -m "feat(gui): TOTP tab with QR code modal + revoke (feature-gated)" +``` + +--- + +## Task 8: CLI — `authforgectl totp` subcommand + +**Files:** +- Modify: `cli/src/main.rs` — add `Totp` subcommand. +- Modify: `cli/src/bus.rs` — add three feature-gated methods. +- Modify: `cli/src/commands/mod.rs` — dispatch the new subcommand. + +**Background:** Mirrors the `Recovery` subcommand pattern from Phase 12. CLI is built without feature gating (always present); when daemon is built `--no-default-features`, CLI invocations of TOTP methods get a `MethodNotFound` D-Bus error which the CLI surfaces verbatim. + +**Step 1: Extend CLI enum** + +```rust +// cli/src/main.rs +#[derive(Subcommand)] +enum Cmd { + // ... existing ... + Totp { + #[command(subcommand)] + cmd: TotpCmd, + }, +} + +#[derive(Subcommand)] +enum TotpCmd { + Enroll { user: String }, + Status { user: String }, + Revoke { user: String }, +} +``` + +**Step 2: Extend `bus.rs`** + +```rust +pub async fn enroll_totp(&self, user: &str) -> Result { + Ok(self.proxy.call("EnrollTotp", &(user,)).await?) +} + +pub async fn is_totp_enrolled(&self, user: &str) -> Result { + Ok(self.proxy.call("IsTotpEnrolled", &(user,)).await?) +} + +pub async fn revoke_totp(&self, user: &str) -> Result { + Ok(self.proxy.call("RevokeTotp", &(user,)).await?) +} +``` + +(No feature gate on the CLI side — these methods always compile; daemon decides whether the D-Bus method exists.) + +**Step 3: Wire dispatch** + +```rust +// cli/src/commands/mod.rs — append to the existing match +super::Cmd::Totp { cmd: super::TotpCmd::Enroll { user } } => { + let d = bus::Daemon::connect().await?; + let e = d.enroll_totp(&user).await?; + if json { + println!("{}", serde_json::to_string_pretty(&e)?); + } else { + println!("secret: {}", e.secret_b32); + println!("uri: {}", e.otpauth_uri); + println!(); + println!("Scan the URI as a QR or enter the secret in your authenticator app."); + } + Ok(()) +} +super::Cmd::Totp { cmd: super::TotpCmd::Status { user } } => { + let d = bus::Daemon::connect().await?; + let enrolled = d.is_totp_enrolled(&user).await?; + if json { + println!("{}", serde_json::json!({ "user": user, "enrolled": enrolled })); + } else if enrolled { + println!("{user}: enrolled"); + } else { + println!("{user}: not enrolled"); + } + Ok(()) +} +super::Cmd::Totp { cmd: super::TotpCmd::Revoke { user } } => { + let d = bus::Daemon::connect().await?; + let removed = d.revoke_totp(&user).await?; + if removed { + if !json { println!("revoked TOTP for {user}"); } + } else { + if !json { eprintln!("not enrolled: {user}"); } + std::process::exit(1); + } + Ok(()) +} +``` + +**Step 4: clap parser tests** + +In `cli/src/main.rs`'s test module: + +```rust +#[test] +fn parse_totp_enroll() { + let c = Cli::try_parse_from(["authforgectl", "totp", "enroll", "alice"]).unwrap(); + match c.cmd { + Cmd::Totp { cmd: TotpCmd::Enroll { user } } => assert_eq!(user, "alice"), + _ => panic!("wrong subcommand"), + } +} + +#[test] +fn parse_totp_status_and_revoke() { + assert!(matches!( + Cli::try_parse_from(["authforgectl", "totp", "status", "alice"]).unwrap().cmd, + Cmd::Totp { cmd: TotpCmd::Status { .. } } + )); + assert!(matches!( + Cli::try_parse_from(["authforgectl", "totp", "revoke", "alice"]).unwrap().cmd, + Cmd::Totp { cmd: TotpCmd::Revoke { .. } } + )); +} +``` + +**Step 5: Build + test + clippy** + +```bash +cargo test -p authforge-cli +cargo clippy --workspace --all-targets -- -D warnings +``` + +Both 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 totp enroll/status/revoke" +``` + +--- + +## Task 9: Master plan closeout note + +**Files:** +- Modify: `docs/plans/2026-04-26-authforge-implementation.md` + +**Step 1: Flip Phase 11 to ✅ Code complete** + +``` +| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | ✅ **Code complete** (yyyy-mm-dd) — `pam_google_authenticator` smoke deferred | +``` + +**Step 2: Append a closeout note** + +```markdown +### Phase 11 closeout notes (yyyy-mm-dd) + +Landed via [2026-04-27-phase-11-totp.md](2026-04-27-phase-11-totp.md). Built atop the prep lane's AppContext + the workspace `totp` Cargo feature. + +**Done:** +- `daemon/src/totp/mod.rs` — pure logic: 160-bit secret generation, base32-no-pad encoding, otpauth URI rendering. 5 unit tests. +- `daemon/src/storage/totp.rs` — `TotpStore` with atomic 0600 writes in pam_google_authenticator's expected file format. 6 unit tests. +- `StorageConfig.totp_dir` (env: `AUTHFORGE_TOTP_DIR`, default `/etc/google-authenticator`). `AppState::{enroll_totp,is_totp_enrolled,revoke_totp}` (all feature-gated). +- D-Bus: `EnrollTotp` (auth_self_keep), `IsTotpEnrolled` (no gate), `RevokeTotp` (auth_admin_keep). 3 integration tests. +- `policy_apply::render_profile` renders `pam_google_authenticator.so secret=/etc/google-authenticator/${USER}` between the recovery line and the FIDO2 line when any stack has `Mode::Required + Method::Totp`. 2 new tests. +- GUI: `gui/src/totp_page.rs` with single-user enroll/revoke, modal QR dialog (rendered via `qrcode` crate's SVG output → `gdk::Texture`). +- CLI: `authforgectl totp enroll|status|revoke `. 2 new clap parser tests. +- Cargo feature `totp` is default-on at workspace + daemon + GUI level. `cargo build --workspace --no-default-features` produces a TOTP-free build. +- `debian/control`: `Recommends: libpam-google-authenticator` on the daemon package. + +**Plan deviations:** +- The master plan called for "8 recovery codes (8 digits each, stored hashed via Argon2id)." This plan deferred TOTP-specific scratch codes and **reuses the Phase 12 recovery flow** instead. A user who loses their TOTP device asks an admin for a one-shot code via `authforgectl recovery generate`, the same path that handles a lost FIDO2 key. Avoids a parallel hashed-recovery file format and unifies the lost-credential UX. Documented here for posterity; revisit if smoke testing reveals workflow gaps. + +**Test count delta:** _(fill in)_ daemon (was 60 post-Phase 12, expect +14: 5 totp + 6 storage::totp + 3 dbus); +2 cli; +2 policy_apply renderer. + +**Deferred until Phase 14 VM smoke:** +- `pam_google_authenticator.so` actually verifying a code — needs `libpam-google-authenticator` installed and a real PAM stack. +- `authforgectl totp enroll alice` → scan QR with Aegis → `sudo whoami` prompts for the 6-digit code → success. +- Verify the rendered profile loads in `pam-auth-update --package` without rejection on a clean Ubuntu VM. +``` + +**Step 3: Commit** + +```bash +git add docs/plans/2026-04-26-authforge-implementation.md +git commit --no-gpg-sign -m "docs: phase 11 closeout notes" +``` + +--- + +## Phase 11 Acceptance Gate + +Verify before merging: + +- [ ] `cargo build --workspace --release` clean. +- [ ] `cargo build --workspace --release --no-default-features` clean (TOTP code compiled out). +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean. +- [ ] `cargo clippy --workspace --all-targets --no-default-features -- -D warnings` clean. +- [ ] `cargo test --workspace` — daemon test count up by ~14 (5 totp + 6 storage::totp + 3 dbus); cli +2; policy_apply +2. +- [ ] `cargo fmt --all -- --check` clean. +- [ ] Manual VM smoke (deferred to Phase 14): authforgectl totp enroll → scan → sudo prompts for code → success. + +--- + +## Risks / known unknowns + +| Risk | Mitigation | +|---|---| +| `qrcode` 0.14 SVG output → `gdk::Texture::from_bytes` chain may not work in all libadwaita 0.6 minors. | Falls back to a selectable label showing the otpauth:// URI as plain text. The base32 secret display is the universal fallback (manual entry into the authenticator app). | +| `pam_google_authenticator` rejects the file format if the daemon's `" TOTP_AUTH"` line has the wrong syntax. | Verified against the upstream manpage. If the smoke test reveals issues, the file is parseable line-by-line — easy to add diagnostic logging. | +| The `/etc/google-authenticator/` file is mode 0600 root-owned but `pam_google_authenticator` may want it user-owned in some PAM stacks. | Default Ubuntu PAM stacks (sudo, gdm-password, sshd) run the auth chain as root before privilege drop — root reads the file fine. KDE / niche PAM stacks may differ; flagged for Phase 14 smoke. | +| `qrcode` crate's `min_dimensions` produces a tiny QR for short strings. | The otpauth URI is ~80-100 chars; `min_dimensions(256, 256)` gives a comfortable 25×25 module grid. | +| Workspace `--no-default-features` build breaks when a single `#[cfg(feature = "totp")]` is missing. | CI gets a `cargo build --workspace --no-default-features` job (add to `.github/workflows/...` if not already there — flag in Task 1's commit message). | +| `data-encoding` 2.6 vs `data-encoding` 2.x patch drift. | Pin at 2.6 in the workspace `[dependencies]`. Bumping is a separate PR. | + +--- + +## 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 the prep lane has merged to `main`. + +After this lane lands, the coordinator doc lists the parallel-safe order: any of (Phase 9 + 12 GUI), Phase 10, Phase 11 can merge in any order. diff --git a/docs/plans/2026-04-27-prep-shell-and-pending.md b/docs/plans/2026-04-27-prep-shell-and-pending.md new file mode 100644 index 0000000..a1ca16e --- /dev/null +++ b/docs/plans/2026-04-27-prep-shell-and-pending.md @@ -0,0 +1,487 @@ +# Prep Lane: GUI shell scaffolding + pending-flag query — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> +> Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md). This lane lands the cross-cutting infrastructure for Phases 9, 10, 11, and the Phase 12 GUI tab so the three follow-on lanes can run truly concurrent in worktrees. + +**Goal:** Land `AppContext`, the `--first-run` flag scaffold (with a stub `firstrun::run()` function), and the daemon's `GetPendingStatus` D-Bus method (plus the matching GUI client wrapper) — without doing any of the actual phase work. After this lane merges, Phases 9, 10, 11 are file-disjoint at the level of `main.rs`, `bus.rs`, and `daemon/src/{dbus,state}.rs`. + +**Architecture:** +- `AppContext` is the small shared-handle 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)). It owns `parent_window`, `toast_overlay`, and the `Rc>>`. `KeysPage::new` switches to taking it. +- `--first-run` is a clap-ish flag in `gui/src/main.rs`. The `connect_activate` body branches: when set, it calls `firstrun::run(ctx)`; otherwise it builds the normal ViewStack-style window. Phase 10 fills `firstrun::run()` with the real modal + watchdog. +- `GetPendingStatus(user)` is a new D-Bus method that returns a `PendingStatus { present: bool, flag: PendingFlag }` wire type. One round-trip carries both "is there a pending flag" and "what's in it." Phase 10's first-login modal uses this on startup to decide whether to show or exit. + +**Tech Stack:** +- `gtk4-rs` + `libadwaita` (already in tree post-Phase 8). +- `zbus` (workspace tokio config — same as Phase 8 lands with). +- `authforge_common` for the new `PendingStatus` wire type. + +**Reference:** +- Phase 8 plan + commits (post-merge `4ec6911`): contracts the prep lane builds on. +- [daemon/src/storage/pending.rs](../../daemon/src/storage/pending.rs): `PendingStore::get` already returns `Result>`. +- [daemon/src/state.rs](../../daemon/src/state.rs): `has_pending` exists at line 229 but is `#[cfg(test)]`-gated; this plan promotes it. + +--- + +## Conventions + +- One logical change per commit. Conventional prefixes: `chore:`, `refactor(gui):`, `feat(gui):`, `feat(daemon):`, `test:`. +- Always commit with `--no-gpg-sign`. +- TDD only for the wire-type round-trip test in `common`. Widget refactor (Task 1) is a mechanical move; D-Bus method gets a p2p integration test alongside the existing recovery tests. +- After every commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo build --workspace`. + +--- + +## Task 1: `AppContext` struct + `KeysPage` migration + +**Files:** +- Create: `gui/src/app_context.rs` +- Modify: `gui/src/main.rs` (add `mod app_context;`, construct + pass) +- Modify: `gui/src/keys_page.rs` (constructor + field changes) + +**Step 1: Create the struct** + +```rust +// gui/src/app_context.rs +//! Shared per-window handles passed to every page constructor. +//! +//! `daemon` is a `RefCell>` because the connect-on-startup attempt +//! may fail (pages render their disconnected banner); a successful Retry +//! repopulates this same cell, and every page sees the new value via the Rc +//! clone chain. + +use crate::bus::Daemon; +use std::cell::RefCell; +use std::rc::Rc; + +#[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`** + +In `gui/src/keys_page.rs`: +- Change `pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc` to `pub fn new(ctx: AppContext) -> Rc`. +- Replace the `parent_window` and `toast_overlay` and `daemon` fields with a single `ctx: AppContext`. +- Replace all `self.parent_window` with `self.ctx.parent_window.clone()`, `self.toast_overlay` with `self.ctx.toast_overlay.clone()`, and `self.daemon` with `self.ctx.daemon.clone()` (or borrow as needed). +- Add `use crate::app_context::AppContext;` at the top. + +**Step 3: Update `main.rs`** + +Replace the existing `KeysPage::new(...)` site with the AppContext flow: + +```rust +mod app_context; +mod bus; +mod enroll_dialog; +mod error; +mod keys_page; + +// 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(&keys.root, Some("keys"), "Keys"); +// ... rest unchanged ... +``` + +**Step 4: Build + clippy + test** + +Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo test -p authforge-gui` +Expected: Phase 8's 4 error-classifier tests still PASS; build clean. + +**Step 5: Commit** + +```bash +git add gui/src/app_context.rs gui/src/keys_page.rs gui/src/main.rs +git commit --no-gpg-sign -m "refactor(gui): extract AppContext for shared page handles" +``` + +--- + +## Task 2: `--first-run` flag + stub `firstrun::run()` + +**Files:** +- Create: `gui/src/firstrun.rs` +- Modify: `gui/src/main.rs` (parse flag, branch) + +**Background:** Phase 10 needs a place to put its fullscreen modal logic that doesn't conflict with the ViewStack work landing in Phases 9 + 11. Establish the entry point now; Phase 10 fills the body. + +**Step 1: Stub the firstrun module** + +```rust +// gui/src/firstrun.rs +//! First-login modal. Phase 10 implements the body of `run`. + +#![allow(dead_code)] // body lands in Phase 10. + +use crate::app_context::AppContext; + +/// Entry point for `authforge --first-run`. Phase 10 fills this with the +/// fullscreen modal + watchdog + enroll-then-clear-pending logic. +/// Until then it exits cleanly so a developer who passes `--first-run` +/// doesn't get a stuck process. +pub(crate) fn run(_ctx: AppContext) { + eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting."); + std::process::exit(0); +} +``` + +**Step 2: Wire the flag into `main.rs`** + +GTK uses `gio::ApplicationFlags::HANDLES_COMMAND_LINE` and a `connect_command_line` handler to receive argv. The minimum-disruption pattern: parse `std::env::args` directly before constructing `adw::Application`, set a static / closure-captured bool, and branch on it inside `connect_activate`. + +```rust +// gui/src/main.rs +use adw::prelude::*; +use gtk::glib; + +mod app_context; +mod bus; +mod enroll_dialog; +mod error; +mod firstrun; +mod keys_page; + +const APP_ID: &str = "io.dangerousthings.AuthForge"; + +fn main() -> glib::ExitCode { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime"); + let _guard = runtime.enter(); + + let first_run = std::env::args().any(|a| a == "--first-run"); + + let app = adw::Application::builder().application_id(APP_ID).build(); + app.connect_activate(move |app| { + let win = adw::ApplicationWindow::builder() + .application(app) + .default_width(720) + .default_height(540) + .title("Authentication") + .build(); + let toast_overlay = adw::ToastOverlay::new(); + let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone()); + + if first_run { + firstrun::run(ctx); + return; + } + + // Normal mode: header + ViewStack with the Keys tab. Phases 9 and 11 + // append more tabs here. + let header = adw::HeaderBar::new(); + let stack = adw::ViewStack::new(); + let switcher = adw::ViewSwitcher::builder() + .stack(&stack) + .policy(adw::ViewSwitcherPolicy::Wide) + .build(); + header.set_title_widget(Some(&switcher)); + + let keys = keys_page::KeysPage::new(ctx.clone()); + stack.add_titled(&keys.root, Some("keys"), "Keys"); + + let layout = gtk::Box::new(gtk::Orientation::Vertical, 0); + layout.append(&header); + layout.append(&stack); + stack.set_vexpand(true); + toast_overlay.set_child(Some(&layout)); + win.set_content(Some(&toast_overlay)); + win.present(); + }); + app.run() +} +``` + +**Step 3: Build + clippy + smoke** + +```bash +cargo build -p authforge-gui +cargo clippy -p authforge-gui --all-targets -- -D warnings +target/debug/authforge --first-run # prints "...stub; exiting." and returns 0 +``` + +(Manual smoke is a one-liner; CI doesn't have a display server but the stub doesn't try to open a window.) + +**Step 4: Commit** + +```bash +git add gui/src/firstrun.rs gui/src/main.rs +git commit --no-gpg-sign -m "feat(gui): --first-run flag + firstrun::run stub for Phase 10" +``` + +--- + +## Task 3: `PendingStatus` wire type + +**Files:** +- Modify: `common/src/types.rs` + +**Step 1: Add the wire type and a Default impl on `PendingFlag`** + +zvariant doesn't carry `Option`, so `GetPendingStatus` returns a struct with an explicit `present: bool` next to the (possibly-empty) `flag`. + +```rust +// at the top of common/src/types.rs, ensure PendingFlag derives Default. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] +pub struct PendingFlag { + pub required_methods: Vec, + pub created_unix: u64, + pub deadline_unix: u64, + pub re_enroll: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] +pub struct PendingStatus { + pub present: bool, + pub flag: PendingFlag, // contents are the default when !present; consumer must check present first +} +``` + +(If `PendingFlag` already has `Default`, leave the existing derive — adding `Default` to the existing derive list is the only change.) + +**Step 2: Add a serde round-trip test** + +Append to the existing `mod tests` in `common/src/types.rs`: + +```rust +#[test] +fn pending_status_serde() { + let s = PendingStatus { + present: true, + flag: PendingFlag { + required_methods: vec![Method::Fido2], + created_unix: 1_700_000_000, + deadline_unix: 0, + re_enroll: false, + }, + }; + let bytes = serde_json::to_vec(&s).unwrap(); + let back: PendingStatus = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(s, back); +} + +#[test] +fn pending_status_default_when_absent() { + let s = PendingStatus { + present: false, + flag: PendingFlag::default(), + }; + assert!(!s.present); + assert!(s.flag.required_methods.is_empty()); +} +``` + +**Step 3: Run the tests** + +Run: `cargo test -p authforge-common` +Expected: previous count + 2 PASS. + +**Step 4: Commit** + +```bash +git add common/src/types.rs +git commit --no-gpg-sign -m "feat(common): PendingStatus wire type + Default impl on PendingFlag" +``` + +--- + +## Task 4: Daemon — promote `has_pending`, add `get_pending_status` + +**Files:** +- Modify: `daemon/src/state.rs` + +**Step 1: Promote `has_pending` out of `#[cfg(test)]`** + +At [daemon/src/state.rs:225-231](../../daemon/src/state.rs#L225-L231), remove the `#[cfg(test)]` gate and the doc comment about test-only use. The function stays the same body. + +**Step 2: Add `get_pending_status`** + +Append to the `impl AppState` block, near `clear_pending`: + +```rust +pub async fn get_pending_status(&self, user: &str) -> Result { + use authforge_common::types::PendingStatus; + match self.pending.get(user)? { + Some(flag) => Ok(PendingStatus { present: true, flag }), + None => Ok(PendingStatus { + present: false, + flag: PendingFlag::default(), + }), + } +} +``` + +Add the `PendingStatus` import to the existing `use authforge_common::types::{...}` line. + +**Step 3: Run the daemon test suite** + +Run: `cargo test -p authforge-daemon` +Expected: previous count, 0 failures (the new method has no tests yet — Task 5 wires it through D-Bus and adds tests there). + +**Step 4: Commit** + +```bash +git add daemon/src/state.rs +git commit --no-gpg-sign -m "feat(daemon): AppState::get_pending_status returns wire-friendly bool+flag" +``` + +--- + +## Task 5: D-Bus `GetPendingStatus` + integration test + +**Files:** +- Modify: `daemon/src/dbus.rs` + +**Step 1: Add the D-Bus method** + +Inside `impl AuthForge` (the `#[zbus::interface]` block), near `clear_pending_flag`: + +```rust +async fn get_pending_status( + &self, + user: String, +) -> zbus::fdo::Result { + self.state + .get_pending_status(&user) + .await + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) +} +``` + +No polkit gate — this is a pure read; the daemon's existing pattern is gates on writes only. + +Add `PendingStatus` to the `use authforge_common::types::{...}` import line at the top. + +**Step 2: Add integration tests** + +Append to the test module in `daemon/src/dbus.rs`, near `set_and_clear_pending_flag` at line 374: + +```rust +#[tokio::test] +async fn pending_status_is_absent_for_fresh_user() { + use authforge_common::types::PendingStatus; + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap(); + assert!(!s.present); + assert!(s.flag.required_methods.is_empty()); +} + +#[tokio::test] +async fn pending_status_round_trips_set_flag() { + use authforge_common::types::{PendingFlag, PendingStatus}; + let (_srv, client, _state, _tmp) = p2p_pair().await; + let p = proxy(&client).await; + let flag = PendingFlag { + required_methods: vec![Method::Fido2], + created_unix: 100, + deadline_unix: 200, + re_enroll: true, + }; + let _: () = p.call("SetPendingFlag", &("alice", flag.clone())).await.unwrap(); + let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap(); + assert!(s.present); + assert_eq!(s.flag, flag); +} +``` + +**Step 3: Run the daemon test suite** + +Run: `cargo test -p authforge-daemon` +Expected: previous count + 2 PASS. + +**Step 4: Clippy + fmt** + +Run: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. + +**Step 5: Commit** + +```bash +git add daemon/src/dbus.rs +git commit --no-gpg-sign -m "feat(daemon): GetPendingStatus D-Bus method + 2 integration tests" +``` + +--- + +## Task 6: GUI client — `Daemon::get_pending_status` + +**Files:** +- Modify: `gui/src/bus.rs` + +**Step 1: Add the wrapper** + +Append to the `impl Daemon` block: + +```rust +pub async fn get_pending_status( + &self, + user: &str, +) -> zbus::Result { + self.proxy.call("GetPendingStatus", &(user,)).await +} +``` + +Add `PendingStatus` to the `use authforge_common::types::{...}` import line if it isn't already drawn through the workspace path. + +**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::Daemon::get_pending_status client wrapper" +``` + +--- + +## Prep Lane Acceptance Gate + +Verify before merging back to main: + +- [ ] `cargo build --workspace --release` clean. +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean. +- [ ] `cargo test --workspace` — daemon test count up by 2 (the new D-Bus tests); common test count up by 2 (the new wire-type tests); gui test count unchanged (4 from Phase 8). +- [ ] `cargo fmt --all -- --check` clean. +- [ ] `target/debug/authforge --first-run` exits 0 with the stub message (no display server needed). + +Commit count target: **6 commits** (one per task). Merge with `--no-ff` so the lane is visible in `git log`. + +--- + +## Risks / known unknowns + +| Risk | Mitigation | +|---|---| +| Phase 9 plan's Task 1 already creates `AppContext` with the same shape — duplication. | Phase 9's Task 1 becomes a no-op once prep merges; Phase 9 plan's Task 1 step says "if AppContext already exists from prep, skip and proceed to Task 2." (Add a one-line note to the Phase 9 plan in this lane's final commit.) | +| `PendingFlag` already has a hand-written `Default` impl somewhere. | Check via `grep -rn "impl Default for PendingFlag" common/`. If yes, skip Task 3 Step 1's first sub-step. | +| `--first-run` flag conflicts with future GTK/clap-style argument parsing. | This is `std::env::args().any(...)` — the lightest possible parse. If future work introduces clap, the prep lane's flag-detection becomes a one-line move. | +| `connect_activate` closure now captures `first_run` via `move` — Rust borrow checker may push back on closure-trait selection (Fn vs FnOnce). | `bool` is `Copy`; the move is a copy. Will compile clean — flagged for completeness. | + +--- + +## 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 dispatch subagents for this lane — the work is too small to be worth orchestration overhead. + +After this lane merges to `main`, the three follow-on lanes (per the [coordinator doc](2026-04-27-parallel-fanout-9-10-11.md)) can dispatch in parallel.