# authforge Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. > > **Note on plan structure:** This is a multi-month project. Phase 0 is fully detailed at step granularity. Phases 1–18 are specified with goals, deliverables, file targets, and task lists at sufficient detail to begin implementation. Each subsequent phase should re-invoke `superpowers:writing-plans` to expand its tasks to step-level granularity at the moment that phase begins (state from prior phases informs the expansion). **Goal:** Ship `authforge` as an apt package on a PPA that turns U2F / FIDO2 passkey / TOTP MFA on Ubuntu desktops into a two-command install with a polished libadwaita GUI, sane policy, and a first-login enrollment flow. **Architecture:** Rust system daemon (`authforged`) exposes a D-Bus interface. A GTK4 / libadwaita app and a CLI are thin clients. A small C PAM module (`pam_authforge_pending.so`) backstops first-login enrollment. PAM stack edits go through `pam-auth-update`. Policy is drop-in TOML in `/etc/authforge/policy.d/`. Full design in `docs/plans/2026-04-26-authforge-design.md`. **Tech Stack:** Rust 2021 edition (daemon, CLI), GTK4 + libadwaita via `gtk4-rs` and `libadwaita-rs` (GUI), C (PAM module), `libfido2` via `ctap-hid-fido2` crate, D-Bus via `zbus`, polkit, debhelper-compat 13 packaging, sbuild for clean builds, GitHub Actions CI, Launchpad PPA for distribution. **Reference design:** [docs/plans/2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) --- ## Phase Roadmap ### Status legend - ✅ **Done** — code on `main`, unit/integration tests green, no Phase 14 VM smoke required (data/packaging/refactor phases that don't talk to real hardware or root-owned files). - ✅ **Code complete** — code on `main`, unit/integration tests green; one or more acceptance gates require real hardware or `/usr/share/...` write access and are deferred to the Phase 14 PPA-build VM smoke. - **Spec'd** — high-level scope in this doc; needs a step-level expansion plan (re-invoke `superpowers:writing-plans`) before execution. - **Bundled** — landed together with another phase because they share a code path that can't be split atomically. ### Roadmap | Phase | Goal | Approx. Effort | Status | |---|---|---|---| | 0 | Repo, workspace, CI, packaging skeleton | 2–3 days | ✅ **Done** (2026-04-26, tag `v0.1.0-scaffolding`) | | 1 | Daemon: D-Bus interface stub, systemd unit, polkit rules | 3–4 days | ✅ **Code complete** (2026-04-27, tag `v0.1.0-phase1`) | | 2 | Daemon: storage layer (policy.d parser, pending flags, user db) | 3–4 days | ✅ **Done** (2026-04-27, tag `v0.2.0-storage`) | | 3 | Daemon: FIDO2 enrollment backend via `ctap-hid-fido2` | 5–7 days | ✅ **Code complete** (2026-04-27, tag `v0.3.0-multi-lane`) — real-HW smoke deferred | | 4 | Daemon: policy apply via pam-auth-update wrapper | 4 days | ✅ **Code complete** (2026-04-27, tag `v0.4.0-parallel`) — root-write smoke deferred | | 5 | Daemon: lockout simulator | 3 days | ✅ **Done** — **Bundled** with Phase 4 | | 6 | PAM module: `pam_authforge_pending.so` (C) | 3 days | ✅ **Code complete** (2026-04-27) — `pamtester` smoke recipe in `pam/TESTING.md` | | 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands | | 8 | GUI: app shell + Security Keys tab | 6–8 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` | | 9 | GUI: Policy tab + lockout-warning UX | 4 days | **Spec'd** — open now (plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md); bundles Phase 12 GUI tab) | | 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (2026-04-27) — VM smoke deferred | | 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) | | 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM landed; GUI tab folded into the Phase 9 lane plan | | 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built | | 14 | Launchpad PPA build setup | 2 days | **Spec'd** — sequential tail after 13; **VM smoke gate for all "Code complete" phases** | | 15 | `authforge-gnome-integration` (Users panel shortcut) | 3 days | ✅ **Done** (2026-04-27) — parallel subagent (gnome lane) | | 16 | Ansible role `dangerousthings.authforge` | 2 days | ✅ **Done** (2026-04-27) — parallel subagent (ansible lane) | | 17 | Integration test harness (Multipass / LXD VM) | 4 days | **Spec'd** — sequential tail | | 18 | User docs + onboarding site | 3 days | **Spec'd** — anytime slot | | **R** | **v1.0 release** | 1 day | — | **Progress: 13 of 19 phases code-complete (68%).** Total original estimate was ~14 weeks of focused work for v1.0. Subsequent phases (Debian packaging, KDE port, RPM) are scoped in the design doc. --- ## Parallel Execution Lanes The phase numbering is a dependency-respecting ordering, **not** a serial schedule. Multiple phases touch disjoint file trees and can be developed in parallel by independent contributors, subagents in worktrees, or a single session bouncing between lanes. ``` Phase 0 ✅ scaffold │ Phase 1 ✅ D-Bus contract │ Phase 2 ✅ storage layer │ ┌──────────┬───────────┼───────────┬──────────┐ │ │ │ │ │ Phase 3 ✅ Phase 4+5 ✅ Phase 6 ✅ Phase 7 ✅ Phase 15 ✅ FIDO2 policy+ PAM C CLI GNOME shim backend lockout module authforgectl (parallel agent) │ │ │ │ ↓ ↓ ↓ ↓ └──────┬───┘ │ │ ↓ │ │ Phase 8 ✅ Phase 12 ✅ Phase 16 ✅ GUI keys+shell recovery Ansible (parallel agent) │ backend+PAM │ │ ↓ ↓ ┌──────────────┬────────────┐ │ │ │ Phase 9 + 12-GUI Phase 11 Phase 10 policy + recovery TOTP first-login (needs 6 ✓ + 8 ✓) tabs (open) (open) (open) │ │ │ └──────────────┴────────────┘ ↓ Phase 13 deb finalization (needs every binary) ↓ Phase 14 Launchpad PPA (VM smoke gate for "Code complete" phases) ↓ Phase 17 integration tests ↓ Phase 18 user docs ↓ R v1.0 release ``` ### What's parallel-safe right now (post-Phase 8 + Phase 12 backend) Three lanes are open and touch disjoint file trees: | Lane | Phases | Touches | Why parallel-safe | |---|---|---|---| | **GUI policy + recovery** | 9 + 12 GUI tab | `gui/src/{app_context,policy_form,policy_page,recovery_page}.rs` (new), additive bus.rs methods, ViewStack tabs in `main.rs` | Both views consume the typed D-Bus contract that landed with Phases 8 + 12 backend. AppContext refactor was foreseen by Phase 8's Task 7. Plan: [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md). | | **TOTP** | 11 | `daemon/src/totp/` (new), `gui/src/totp_page.rs` (new), `debian/control` Recommends | Feature-flagged at build time. Phase 8 ✓ unblocked the GUI tab; daemon-side TOTP module is brand-new. Step-level plan still to be written. | | **First-login** | 10 | `gui/src/firstrun.rs` (new), `gui/data/authforge-firstrun.desktop`, `--first-run` CLI flag in `gui/src/main.rs` | Both Phase 6 ✓ (PAM pending blocker) and Phase 8 ✓ (GUI shell) are in. Step-level plan still to be written. | ### Sequential tail (no parallelism wins) Phase 13 (deb finalization) needs every binary built — opens once Phases 9, 10, 11 land. Phase 14 (PPA) chains off 13 — and is the **VM smoke gate** for everything currently in "Code complete" status (1, 3, 4, 6, 8, 12 — six phases with deferred real-hardware/root-write/libargon2-dev gates). Phase 17 (integration tests) chains off 14. Phase 18 (user docs) and R (release) follow. ### Lessons learned from this session's parallelism - **Subagents in `isolation: "worktree"` work** when permissions cover `cd && …`, `git -C …`, and broad `cargo *` / `make *`. Two subagents (Phase 15 gnome + Phase 16 Ansible) finished cleanly in 7 minutes wall-clock for ~250 lines of YAML/desktop config combined. - **They don't work** when the harness sandbox blocks `desktop-file-validate` / `dpkg-checkbuilddeps` / `ansible-lint` — the Phase 15+16 agents both reported these as "skipped, hand-checked." Verification slips through to manual review of the diff. Acceptable for data/config phases; risky for code phases without strong test coverage. - **Phase 4+5 had to land together** because both modify the SetPolicy code path; splitting them creates a window where SetPolicy applies without a lockout pre-check. - **Worktrees share `target/` if and only if you don't isolate** via `CARGO_TARGET_DIR`. We didn't need to — each `git worktree add` produces its own working tree, so cargo target dirs are per-worktree by default. Concurrent cargo runs across worktrees would still race on the global `~/.cargo/registry` cargo lock, but in practice that's a brief synchronization, not a hang. --- ### Phase 1 closeout notes (2026-04-27) Phase 1 expanded to step granularity at [2026-04-26-phase-1-dbus.md](2026-04-26-phase-1-dbus.md). Code-side work is done; some acceptance gates require an Ubuntu desktop with libgtk-4-dev / libadwaita-1-dev / libfido2-dev / libpam0g-dev installed (see [BUILDING.md](../BUILDING.md)) and are deferred to a smoke pass before Phase 14 (PPA build). **Done in Phase 1:** - 9-method D-Bus interface (`io.dangerousthings.AuthForge1`) backed by an in-memory `AppState` with fixtures. - polkit authorizer with `Permissive` (env-var bypass) + `System` (real `org.freedesktop.PolicyKit1.Authority`) modes. - All wire types in `common`: `Mode`, `Method`, `Transport`, `Credential`, `StackPolicy`, `StorageBackend`, `Storage`, `Firstrun`, `Policy`, `PendingFlag`, `Violation`, `PolicyApplyResult`. All `Serialize + Deserialize + zvariant::Type`. - systemd unit (`authforge-daemon.service`) with hardening (`NoNewPrivileges`, `ProtectSystem=full`, scoped `ReadWritePaths`). - D-Bus activation file + system policy XML. - polkit policy XML with all 7 actions from the design doc. - `authforge-daemon.postinst` driving `daemon-reload` + `enable --now`. - Packaging (`debian/rules` + `control`) installs the new assets and depends on `dbus`, `policykit-1`. - CI installs `dbus` so the new tests run there too. - 21 unit/integration tests across `common` (7) and `daemon` (14, including p2p D-Bus tests via `tokio::net::UnixStream::pair`). **Deferred until a Phase 14 smoke run on a clean Ubuntu VM:** - `debuild -us -uc -b` 5-deb output (needs libgtk-4-dev / libadwaita-1-dev / libfido2-dev / libpam0g-dev installed). - `busctl introspect` / polkit prompt manual smoke. - `systemctl status authforge-daemon` after install. **Plan deviations recorded in the commit:** - `PendingFlag.deadline_unix` is `u64` with `0` = no deadline (zvariant `Type` derive does not support `Option`; sentinel matches the design doc's existing `deadline_hours = 0` convention). - Sender → pid resolution for polkit `unix-process` subjects punts to early Phase 2 — Phase 1 passes pid 0, which the permissive authorizer ignores. Until pid resolution lands, the daemon is run with `AUTHFORGE_POLKIT_BYPASS=1` for any local smoke test (loud `warn!` log on startup). ### Phase 2 closeout notes (2026-04-27) Phase 2 expanded to step granularity at [2026-04-27-phase-2-storage.md](2026-04-27-phase-2-storage.md). All 17 tasks landed in 8 commits, tagged `v0.2.0-storage`. **Done in Phase 2:** - `common/src/policy.rs` — `Policy::load_from_dir` (lex-ordered last-wins merge; ignores non-`.conf`; missing-dir → default) and `Policy::save_local` (writes `50-local.conf`, never touches siblings). Policy-related types moved here from `types.rs` with re-exports for stability. - `daemon/src/storage/policy.rs` — `PolicyStore` wraps load/save; `watch()` returns a `(RecommendedWatcher, watch::Receiver<()>)` driven by `notify::recommended_watcher` (inotify on Linux). - `daemon/src/storage/pending.rs` — JSON read/write/clear with explicit path-traversal rejection (`.`, `..`, `/`, `\0`, empty). - `daemon/src/storage/credentials.rs` — `pam_u2f` line parser (`username:cred1[:cred2…]`), `CredentialsStore` with idempotent add-by-credId and remove-by-credId, `CredsPathResolver` dispatching central vs per-user paths (NSS errors fall back to `/home//…`). - `daemon/src/storage/userdb.rs` — `rusqlite` (bundled SQLite) cache for the lockout simulator; `record_enrollment`, `drop_enrollment`, `users_with`, `has_any`. - `daemon/src/dbus.rs` — adds `#[zbus(signal)] policy_changed`. Tests run against tempdir-backed `AppState` seeded with central-mode storage so credential writes don't try to touch `/home//…`. - `daemon/src/main.rs` — spawns a watcher task that emits `PolicyChanged` on every `rx.changed()`; `AUTHFORGE_POLICY_DIR`/`_PENDING_DIR`/`_USERDB` env-var overrides for non-prod runs. - `AppState::open(StorageConfig)` replaces `with_fixtures()`. All storage errors threaded through `StateError` and surfaced as `org.freedesktop.DBus.Error.Failed`. **Test count:** 13/13 common + 26/26 daemon = **39 tests** (was 14+7=21 at end of Phase 1). Clippy + fmt clean. **Plan deviations:** - `nix::unistd::User::from_name` returns `Err(ENOENT)` rather than `Ok(None)` on this dev box for unknown users; the resolver now treats any lookup error as "fall back to `/home/`" rather than propagating. Comment updated. - `set_policy_replaces_state` test renamed to `set_policy_persists_stacks` and asserts only `merged.stacks == pol.stacks` — `p2p_pair` seeds a `[storage]` block in `00-test.conf`, so the merged read is by design a superset of what `SetPolicy` wrote to `50-local.conf`. The new assertion captures the actual user-visible behavior. - `.gitignore` extended to ignore `.claude/` (Phase 1 left a stale agent worktree under `.claude/worktrees/`; ignoring prevents accidental submodule-style commits). ### Phase 3 + 6 + 7 closeout notes (2026-04-27) Bundled multi-lane execution against the plan at [2026-04-27-phase-3-6-7-multi-lane.md](2026-04-27-phase-3-6-7-multi-lane.md). Tagged `v0.3.0-multi-lane`. 12 commits (1 plan + 11 implementation). **Phase 3 — FIDO2 enrollment backend:** - `daemon/src/fido/format.rs` — `PamU2fCred` -> `kh,pk,es256,+presence` with `hex::encode`; `CoseType` matching COSE alg `-7`/`-8`. - `daemon/src/fido/authenticator.rs` — `Authenticator` trait with `discover()` and `make_credential(rp_id, user, pin)`. `AuthnError::{NoDevice, Cancelled, PinRequired, Backend}`. - `daemon/src/fido/mock.rs` — `MockAuthenticator::with_one_yubikey()` for tests; counter-bumped per call. - `daemon/src/fido/ctap.rs` — `CtapAuthenticator` wrapping `ctap-hid-fido2 = "3.5.9"`. Hardware-dependent; compile-clean gate only. - `AppState::enroll(user, nickname)` replaces the Phase 2 add-credential stub: calls `authn.make_credential`, writes pam_u2f line, records userdb enrollment, returns `Credential` with `hex(keyHandle)` as id. - D-Bus signals `DeviceFound`, `TouchRequired`, `EnrollmentSucceeded`, `EnrollmentFailed` emitted from `enroll_own` / `enroll_other`. **Phase 6 — `pam_authforge_pending.so`:** - Replaces the Phase 0 stub. Reads `PAM_USER`, rejects unsafe usernames, `stat()`s `/var/lib/authforge/pending/`. Present → user-facing message + `PAM_AUTH_ERR`. ENOENT → `PAM_IGNORE`. Other errno → fail closed. - Builds clean against `libpam0g-dev` with `-Wall -Wextra -Werror -fPIC -O2`. 16KB ELF. - `pam/TESTING.md` + `pam/test/authforge.pamd` document the manual `pamtester` smoke recipe. **Phase 7 — `authforgectl` CLI:** - 14 subcommands wired through D-Bus: `status`, `enroll`, `list`, `remove`, `policy {show,set,apply,validate}`, `pending {set,clear,list}`, `recovery {generate,list}`. Global `--json` flag. - `cli/src/bus.rs` — owned-proxy wrapper via `zbus::Proxy::new_owned`. - 5 clap-parser tests cover subcommand definition validity, status parse, policy-set with method list, enroll with user+nickname, `--json` global. **Plan deviations:** - `HidInfo` in ctap-hid-fido2 3.5.9 has `pid`, `vid`, `product_string`, `info`, `param` — no `serial_number` / `path`. Used `product_string` for the device name and `HidParam::Path`/`VidPid` for the path label. - `FidoKeyHidFactory` and `LibCfg` live at the crate root, not under `fidokey::`. - `fido/mod.rs` carries `#![allow(dead_code)]` until Phase 8 GUI consumes the `DeviceFound` signal — `discover()` and `DiscoveredDevice` fields are wired via the trait but not yet called from non-test code. ### Phase 4 + 5 closeout notes (2026-04-27) Tagged `v0.4.0-parallel`. Phase 4 + Phase 5 land together because both modify the SetPolicy code path; splitting would create a window where SetPolicy applies without a lockout pre-check. **Phase 4 — Policy apply via pam-auth-update:** - `daemon/src/policy_apply.rs` — `PolicyApplier::new(profile_path, pam_auth_update)`. Renders the `pam-configs/authforge` profile (`Default: yes` when any stack requires fido2; `pam_u2f.so` + `pam_authforge_pending.so` chain when fido2 required, only the pending backstop otherwise) and runs `pam-auth-update --package`. - **Stash-and-restore on failure:** prior profile contents are read before write, restored if `pam-auth-update` fails, and `pam-auth-update` is re-run to put the system back into its previous PAM state. - 4 unit tests including a real-process rollback test against a failing `/bin/sh` shim. **Phase 5 — Lockout simulator:** - `daemon/src/lockout.rs` — pure `simulate(&Policy, &[UserEnrollment]) -> Vec`. Iterates Required stacks, flags users with no enrolled credential of any required method. - 5 unit tests: optional-mode skipped, required-with-unenrolled flagged, any-method-satisfies, empty registry, multi-stack violation count. - `AppState::set_policy(p, force)` always runs the simulator first; if violations and `!force`, returns `PolicyApplyResult { applied: false, violations }` without writing or invoking pam-auth-update. Otherwise persists via `PolicyStore::save` and invokes `PolicyApplier::apply`. **Wire-protocol breaking change (pre-alpha, OK):** `SetPolicy(p)` → `SetPolicy(p, force: bool)`. CLI's `policy set` runs with `force=false` and surfaces violations as a non-zero exit + stderr list pointing the user at `policy apply --force-i-know-what-im-doing`. **StorageConfig grew two fields:** `pam_profile_path` and `pam_auth_update`, env-var driven (`AUTHFORGE_PAMCONF_PATH`, `AUTHFORGE_PAM_AUTH_UPDATE`). Tests inject a no-op `/bin/sh` shim into a tempdir. ### Phase 15 + 16 closeout notes (2026-04-27) Both delivered by parallel subagents in `git worktree` isolation while Lane 1 (Phase 4+5) ran in the main session. ~7 minutes wall-clock for both, vs. ~15+ if I'd done them sequentially. **Phase 15 — `authforge-gnome-integration`:** - `gnome-integration/io.dangerousthings.AuthForge.UsersPanel.desktop` — `.desktop` overlay tagged `X-GNOME-Settings-Panel=user-accounts`, `NoDisplay=true`, `OnlyShowIn=GNOME;`. Adds "Configure security…" inside `gnome-control-center` Users panel. - `debian/control` paragraph for the new binary deb (`Architecture: all`, `Depends: authforge-gui, gnome-control-center`). - `debian/rules` install line for `debian/authforge-gnome-integration/usr/share/applications/`. - `gnome-integration/README.md` documents the GNOME 46/47 settings-panel-overlay mechanism and notes JS-extension support is deferred until a stable cross-version mechanism exists. **Phase 16 — Ansible role `dangerousthings.authforge`:** - Full role at `ansible-role/`: meta, defaults, handlers, tasks, templates, README, examples/playbook.yml. - Adds the PPA, installs `authforge-daemon authforge-pam authforge-cli` (gated `authforge-gui`), templates `policy.conf.j2` to `/etc/authforge/policy.d/90-fleet.conf`, runs `authforgectl pending set` for users in `authforge_pending_users`. - Single restart handler for `authforge-daemon.service`. - All 5 YAML files validated via `yaml.safe_load`. `ansible-lint` skipped — not installed in the harness sandbox. - **Subagent-initiated divergences (kept):** added `authforge_ppa` override variable so internal-mirror users don't fork the role; pre-creates `/etc/authforge/policy.d/` defensively (idempotent); standard `ansible_managed` template header. ### Phase 8 closeout notes (2026-04-27) Single-lane execution in a worktree at `.claude/worktrees/phase-8-gui-keys`. Plan at [2026-04-27-phase-8-gui-keys.md](2026-04-27-phase-8-gui-keys.md). Eight task commits + one plan correction + merge commit (`943d9b0`). **What landed:** - `gui/src/error.rs` — `classify(&zbus::Error)` + `user_message()` mapping zbus errors to `Disconnected` / `AccessDenied` / `Other`. 4 unit tests, all green. - `gui/src/bus.rs` — `Daemon` wrapper (Clone via inner `Arc`) with `list_credentials`, `enroll_own`, `remove_own`, `subscribe_enrollment_failed`, plus `current_user()` helper. Mirrors [cli/src/bus.rs](../../cli/src/bus.rs) pattern. - `gui/src/keys_page.rs` — `KeysPage` widget. Empty state with retry banner when daemon unreachable, otherwise lists each credential as an `adw::ActionRow` with a per-row trash button + "Enroll a new key" activatable row at the bottom. Refresh re-runs `ListCredentials` from scratch. - `gui/src/enroll_dialog.rs` — modal `adw::AlertDialog` with spinner. Spawns one task that races `enroll_own` against the `EnrollmentFailed` signal stream via `futures_util::future::select`; success comes from the call's return (`Credential`), the signal lets a fast failure get a useful message before the call's typed `Err` traverses the bus. - `gui/src/main.rs` — multi-thread tokio runtime + `runtime.enter()` guard held for `app.run()`. `adw::ApplicationWindow` containing `gtk::Box[HeaderBar, ViewStack]` wrapped in `adw::ToastOverlay`. `KeysPage` is the only visible page; the `ViewStack` is forward-looking for Phase 9. - `gui/TESTING.md` — automated gate (`cargo build`, `clippy`, `test`) + manual smoke recipe for a Yubikey-equipped Ubuntu box. **Plan deviations (recorded in commit messages):** - The plan asserted `zbus = { features = ["glib"] }` for runtime integration — zbus 4.4 has no such feature. Switched to the workspace tokio config + a tokio runtime guard in `main`. Plan correction commit (`a17f70c`) ships alongside the implementation. - `adw::ToolbarView` is libadwaita `v1_4`-gated; the project sticks with `gtk::Box` vertical layout (commit `c6a5e94` established this). - `adw::AlertDialog` is libadwaita `v1_5`-gated; `gui/Cargo.toml` enables that feature. Targets Ubuntu 24.04+ (libadwaita 1.5). - `Proxy::new(...).into_owned()` doesn't exist in zbus 4.4; used `Proxy::new_owned(...)` (the CLI's known-working pattern). - Dropped the planned `subscribe_enrollment_succeeded` from `bus.rs` — the dialog only races against the failed signal because the call's return value carries the new `Credential` (source of truth for success). YAGNI. **Deferred to Phase 14 PPA-build VM smoke:** - All six steps in `gui/TESTING.md` (touch a real Yubikey through the modal; remove a credential; pull the key during enrollment to see the failure swap; kill the daemon to see the disconnected banner). ### Phase 12 backend closeout notes (2026-04-27) Backend + PAM landed via the plan at [2026-04-27-phase-12-recovery-backend.md](2026-04-27-phase-12-recovery-backend.md). 9 commits on `feature/phase-12-recovery-backend`, merged to `main` as `4ec6911`. GUI tab is folded into the Phase 9 lane plan ([2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md)) — that lane is now open since Phase 8 ✓. **Done:** - `daemon/src/storage/safe_user.rs` — extracted shared username path-segment sanitizer; `PendingStore` migrated to it. 2 new unit tests. - `daemon/src/recovery.rs` — pure logic: `generate_code` (8 digits), `hash_code` (Argon2id PHC via OS RNG salt), `verify_code` (constant-time). 4 unit tests. - `daemon/src/storage/recovery.rs` — `RecoveryStore`: atomic 0600 writes (temp+rename), two-line file format `\n\n`, one-shot `verify_and_consume` semantics, expiry handling, alphabetical `list()`. 7 unit tests. - `StorageConfig` gains `recovery_dir` field with `AUTHFORGE_RECOVERY_DIR` env override (default `/var/lib/authforge/recovery`). `AppState` exposes `issue_recovery` / `list_recovery` / `revoke_recovery`. - D-Bus: real `GenerateRecoveryCode` (replaced stub), new `ListRecoveryCodes` (returns `Vec`), new `RevokeRecoveryCode`. polkit policy gains `…list-recovery` + `…revoke-recovery` actions (`auth_admin_keep`). 4 new D-Bus integration tests including a real on-disk `$argon2id$` PHC round-trip. - PAM module gains a `mode=recovery` argv branch. Reads the two-line file, `argon2_verify`s against `PAM_AUTHTOK`; on match unlinks the recovery file (one-shot) and writes `pending(re_enroll=true)`. Always returns `PAM_IGNORE` on failure paths so a missing/wrong code never blocks normal auth. Linked against `libargon2`. - `policy_apply::render_profile` renders the recovery line first in the auth stack with `[success=done default=ignore]` so a successful recovery short-circuits the rest of the auth chain. - CLI: `authforgectl recovery list` (was placeholder) and `authforgectl recovery revoke `. List takes no args (matches D-Bus signature). **Test count:** 7 cli + 13 common + 60 daemon = **80 tests** (was 5+13+42=60 at end of Phase 4+5). +20 tests across the 7 implementation tasks. **Plan deviations:** none of substance. **Deferred until Phase 14 VM smoke:** - `make -C pam` against a host with `libargon2-dev` installed (not in the dev sandbox). - `pamtester` recovery walkthrough — recipe lives in `pam/TESTING.md` § Smoke test 4. - End-to-end recovery flow against a real PAM stack: admin issues code via CLI → user logs in entering it at the password prompt → recovery file deleted → pending(re_enroll=true) flag present → re-enrollment modal triggers on next login. ### Phase 10 closeout notes (2026-04-27) 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. Daemon-connect / `GetPendingStatus` failures render a Skip button via a shared `render_unavailable` helper instead of stranding the user for 60s. - `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:** Task 4's Skip-button code was factored into a single `render_unavailable(win, status, message)` helper called from both the daemon-connect and `GetPendingStatus` error arms, rather than inlining the snippet twice as the plan literal suggested. Same UX, no duplication. **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`). Recipe lives in `gui/FIRSTRUN-TESTING.md`. --- # Phase 0: Repository Scaffolding (FULLY DETAILED) **Goal:** Empty-but-valid repo that builds, passes a trivial test, and produces installable (no-op) deb packages from `dpkg-buildpackage`. Locks in the project structure so all later phases drop into known places. **Deliverables:** - Cargo workspace with crates: `daemon`, `cli`, `common` (shared types). - Debian packaging skeleton in `debian/` that builds the metapackage + 5 component debs. - C source tree for the PAM module under `pam/`. - GUI placeholder under `gui/` (Meson + Rust target). - GitHub Actions CI: `cargo build`, `cargo test`, `cargo clippy`, `debuild --no-sign`, `lintian`. - `README.md` with build instructions. ### Task 0.1: Initialize git repo and license **Files:** - Create: `/home/work/VSCodeProjects/ubuntu_fido/.gitignore` - Create: `/home/work/VSCodeProjects/ubuntu_fido/LICENSE` (Apache 2.0) - Create: `/home/work/VSCodeProjects/ubuntu_fido/README.md` **Step 1:** Run `git init` in the project directory. ```bash cd /home/work/VSCodeProjects/ubuntu_fido git init -b main ``` **Step 2:** Write `.gitignore`: ```gitignore target/ *.deb *.buildinfo *.changes *.dsc *.tar.xz debian/.debhelper/ debian/files debian/*.substvars debian/*.debhelper.log debian/authforge*/ build/ .vscode/ *.swp ``` **Step 3:** Write Apache 2.0 `LICENSE` file (standard text — copy from https://apache.org/licenses/LICENSE-2.0.txt). Author line: `Copyright 2026 Dangerous Things, LLC`. **Step 4:** Write minimal `README.md`: ```markdown # authforge Turnkey U2F / FIDO2 passkey / TOTP MFA for Ubuntu desktops. ## Install (end users) sudo add-apt-repository ppa:dangerousthings/authforge sudo apt install authforge ## Build from source See `docs/BUILDING.md`. ## Status Pre-alpha. See `docs/plans/` for design and roadmap. ``` **Step 5:** Initial commit. ```bash git add . git commit -m "Initial commit: license, gitignore, readme" ``` ### Task 0.2: Create Cargo workspace **Files:** - Create: `Cargo.toml` (workspace root) - Create: `rust-toolchain.toml` - Create: `.cargo/config.toml` **Step 1:** Write `Cargo.toml`: ```toml [workspace] resolver = "2" members = ["common", "daemon", "cli"] [workspace.package] version = "0.1.0" edition = "2021" license = "Apache-2.0" authors = ["Dangerous Things "] repository = "https://github.com/dangerousthings/authforge" rust-version = "1.78" [workspace.dependencies] zbus = "4" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" anyhow = "1" thiserror = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } ctap-hid-fido2 = "3" nix = { version = "0.28", features = ["user", "process", "fs"] } ``` **Step 2:** Write `rust-toolchain.toml`: ```toml [toolchain] channel = "1.78" components = ["rustfmt", "clippy"] profile = "minimal" ``` **Step 3:** Write `.cargo/config.toml`: ```toml [build] rustflags = ["-D", "warnings"] ``` **Step 4:** Verify with `cargo metadata --format-version=1 > /dev/null` (will fail until the member crates exist; that's the next task). ### Task 0.3: Create `common` crate **Files:** - Create: `common/Cargo.toml` - Create: `common/src/lib.rs` - Create: `common/src/policy.rs` - Create: `common/src/types.rs` **Step 1:** `common/Cargo.toml`: ```toml [package] name = "authforge-common" version.workspace = true edition.workspace = true license.workspace = true [dependencies] serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } ``` **Step 2:** `common/src/lib.rs`: ```rust pub mod policy; pub mod types; ``` **Step 3:** `common/src/types.rs`: ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Mode { Disabled, Optional, Required, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Method { Fido2, Totp, } ``` **Step 4:** `common/src/policy.rs` — empty module for now, populated in Phase 2: ```rust // Policy parsing implemented in Phase 2. ``` **Step 5:** Write a failing test in `common/src/types.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn mode_serializes_to_lowercase() { let m = Mode::Required; let s = serde_json::to_string(&m).unwrap(); assert_eq!(s, "\"required\""); } } ``` This needs `serde_json` as a dev-dep; add `[dev-dependencies] serde_json = { workspace = true }` to `common/Cargo.toml`. **Step 6:** Run `cargo test -p authforge-common`. Expected: PASS. **Step 7:** Commit. ```bash git add common Cargo.toml rust-toolchain.toml .cargo git commit -m "Add common crate with shared Mode and Method types" ``` ### Task 0.4: Create `daemon` crate skeleton **Files:** - Create: `daemon/Cargo.toml` - Create: `daemon/src/main.rs` **Step 1:** `daemon/Cargo.toml`: ```toml [package] name = "authforged" version.workspace = true edition.workspace = true license.workspace = true [[bin]] name = "authforged" path = "src/main.rs" [dependencies] authforge-common = { path = "../common" } zbus = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } anyhow = { workspace = true } ``` **Step 2:** `daemon/src/main.rs`: ```rust use anyhow::Result; use tracing::info; #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); info!("authforged {} starting", env!("CARGO_PKG_VERSION")); // D-Bus service registration in Phase 1. Ok(()) } ``` **Step 3:** `cargo build -p authforged`. Expected: success. **Step 4:** Commit. ```bash git add daemon git commit -m "Add daemon crate skeleton" ``` ### Task 0.5: Create `cli` crate skeleton **Files:** - Create: `cli/Cargo.toml` - Create: `cli/src/main.rs` **Step 1:** `cli/Cargo.toml`: ```toml [package] name = "authforgectl" version.workspace = true edition.workspace = true license.workspace = true [[bin]] name = "authforgectl" path = "src/main.rs" [dependencies] authforge-common = { path = "../common" } clap = { workspace = true } anyhow = { workspace = true } zbus = { workspace = true } tokio = { workspace = true } ``` **Step 2:** `cli/src/main.rs`: ```rust use anyhow::Result; use clap::Parser; #[derive(Parser)] #[command(name = "authforgectl", version, about = "Manage authforge configuration")] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(clap::Subcommand)] enum Cmd { Status, } #[tokio::main] async fn main() -> Result<()> { let args = Cli::parse(); match args.cmd { Cmd::Status => println!("authforgectl: not yet implemented"), } Ok(()) } ``` **Step 3:** `cargo build` (whole workspace). Expected: success. **Step 4:** Commit. ```bash git add cli git commit -m "Add cli crate skeleton" ``` ### Task 0.6: Create PAM module skeleton **Files:** - Create: `pam/Makefile` - Create: `pam/pam_authforge_pending.c` **Step 1:** `pam/pam_authforge_pending.c`: ```c #define PAM_SM_AUTH #include #include #include PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv) { (void)flags; (void)argc; (void)argv; pam_syslog(pamh, LOG_INFO, "authforge_pending: stub - allowing"); return PAM_IGNORE; /* implemented in Phase 6 */ } PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv) { (void)pamh; (void)flags; (void)argc; (void)argv; return PAM_SUCCESS; } ``` **Step 2:** `pam/Makefile`: ```makefile CFLAGS ?= -Wall -Wextra -Werror -fPIC -O2 LIBDIR ?= /usr/lib/$(shell dpkg-architecture -qDEB_HOST_MULTIARCH)/security pam_authforge_pending.so: pam_authforge_pending.c $(CC) $(CFLAGS) -shared -o $@ $< -lpam install: pam_authforge_pending.so install -D -m 0644 pam_authforge_pending.so $(DESTDIR)$(LIBDIR)/pam_authforge_pending.so clean: rm -f pam_authforge_pending.so .PHONY: install clean ``` **Step 3:** Build it: ```bash cd pam && make && cd .. ``` Expected: produces `pam/pam_authforge_pending.so` (~10 KB). Requires `libpam0g-dev` (will be in build-deps). **Step 4:** Commit. ```bash git add pam git commit -m "Add PAM module stub" ``` ### Task 0.7: Create GUI placeholder **Files:** - Create: `gui/Cargo.toml` - Create: `gui/src/main.rs` - Create: `gui/data/io.dangerousthings.AuthForge.desktop` - Create: `gui/data/io.dangerousthings.AuthForge.svg` (1KB placeholder icon) **Step 1:** Add `gui` to workspace members in root `Cargo.toml`. **Step 2:** `gui/Cargo.toml`: ```toml [package] name = "authforge-gui" version.workspace = true edition.workspace = true license.workspace = true [[bin]] name = "authforge" path = "src/main.rs" [dependencies] gtk = { package = "gtk4", version = "0.8" } adw = { package = "libadwaita", version = "0.6" } authforge-common = { path = "../common" } anyhow = { workspace = true } ``` **Step 3:** `gui/src/main.rs` — minimal libadwaita "Hello" window: ```rust use adw::prelude::*; use gtk::glib; const APP_ID: &str = "io.dangerousthings.AuthForge"; fn main() -> glib::ExitCode { let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_activate(|app| { let win = adw::ApplicationWindow::builder() .application(app) .default_width(720) .default_height(540) .title("Authentication") .build(); let header = adw::HeaderBar::new(); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); let status = adw::StatusPage::builder() .icon_name("dialog-password-symbolic") .title("Authentication setup") .description("Phase 0 placeholder. Real UI in Phase 8.") .build(); toolbar.set_content(Some(&status)); win.set_content(Some(&toolbar)); win.present(); }); app.run() } ``` **Step 4:** `.desktop` file at `gui/data/io.dangerousthings.AuthForge.desktop`: ```desktop [Desktop Entry] Name=Authentication GenericName=Security Keys & MFA Comment=Manage U2F/FIDO2 keys and authentication policy Exec=authforge Icon=io.dangerousthings.AuthForge Terminal=false Type=Application Categories=Settings;Security; Keywords=mfa;u2f;fido2;passkey;totp;security; StartupNotify=true ``` **Step 5:** Placeholder SVG icon — a simple gray key glyph; sourced from any open icon set under a compatible license. Save at `gui/data/io.dangerousthings.AuthForge.svg`. **Step 6:** Build (assumes GTK4 / libadwaita dev packages installed: `libgtk-4-dev`, `libadwaita-1-dev`). ```bash cargo build -p authforge-gui ``` Expected: success. **Step 7:** Commit. ```bash git add gui Cargo.toml git commit -m "Add GUI crate skeleton with libadwaita placeholder window" ``` ### Task 0.8: Debian packaging skeleton **Files:** - Create: `debian/changelog` - Create: `debian/control` - Create: `debian/copyright` - Create: `debian/rules` - Create: `debian/source/format` - Create: `debian/compat` (or use `debhelper-compat (= 13)` in control) - Create: `debian/authforge.install` - Create: `debian/authforge-daemon.install` - Create: `debian/authforge-daemon.service` - Create: `debian/authforge-pam.install` - Create: `debian/authforge-cli.install` - Create: `debian/authforge-gui.install` **Step 1:** `debian/changelog`: ``` authforge (0.1.0-1) UNRELEASED; urgency=low * Initial packaging skeleton. -- Dangerous Things Sun, 26 Apr 2026 12:00:00 +0000 ``` **Step 2:** `debian/source/format`: ``` 3.0 (native) ``` **Step 3:** `debian/control` — declares all 5 binary packages and their relationships: ``` Source: authforge Section: admin Priority: optional Maintainer: Dangerous Things Build-Depends: debhelper-compat (= 13), cargo, rustc (>= 1.78), libpam0g-dev, libgtk-4-dev, libadwaita-1-dev, libfido2-dev, pkg-config Standards-Version: 4.6.2 Homepage: https://github.com/dangerousthings/authforge Vcs-Browser: https://github.com/dangerousthings/authforge Vcs-Git: https://github.com/dangerousthings/authforge.git Package: authforge Architecture: any Depends: authforge-daemon (= ${binary:Version}), authforge-pam (= ${binary:Version}), authforge-cli (= ${binary:Version}), ${misc:Depends} Recommends: authforge-gui (= ${binary:Version}) Suggests: authforge-gnome-integration Description: Turnkey FIDO2/U2F/TOTP MFA for Ubuntu (metapackage) Installs the full authforge stack: daemon, PAM module, CLI, and (if recommended) GUI. Package: authforge-daemon Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-u2f, libfido2-1 Description: System daemon for authforge MFA management Provides the privileged D-Bus service that orchestrates enrollment, policy edits, and lockout-prevention checks. Package: authforge-pam Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-runtime Description: PAM module backstopping authforge first-login enrollment Refuses authentication when /var/lib/authforge/pending/ exists and the user has not completed first-login MFA setup. Package: authforge-cli Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends}, authforge-daemon Description: CLI for authforge (admin and fleet) Configure policy, enroll on behalf of users, generate recovery codes. Package: authforge-gui Architecture: any Depends: ${shlibs:Depends}, ${misc:Depends}, authforge-daemon Description: GTK4/libadwaita UI for authforge End-user-facing settings panel for enrollment and policy. ``` **Step 4:** `debian/rules` (executable, mode 0755): ```makefile #!/usr/bin/make -f export DH_VERBOSE = 1 export CARGO_HOME = $(CURDIR)/target/cargo-home %: dh $@ override_dh_auto_build: cargo build --release --workspace $(MAKE) -C pam override_dh_auto_install: # Daemon install -D -m 0755 target/release/authforged \ debian/authforge-daemon/usr/sbin/authforged # CLI install -D -m 0755 target/release/authforgectl \ debian/authforge-cli/usr/bin/authforgectl # GUI install -D -m 0755 target/release/authforge \ debian/authforge-gui/usr/bin/authforge install -D -m 0644 gui/data/io.dangerousthings.AuthForge.desktop \ debian/authforge-gui/usr/share/applications/io.dangerousthings.AuthForge.desktop install -D -m 0644 gui/data/io.dangerousthings.AuthForge.svg \ debian/authforge-gui/usr/share/icons/hicolor/scalable/apps/io.dangerousthings.AuthForge.svg # PAM $(MAKE) -C pam install DESTDIR=$(CURDIR)/debian/authforge-pam override_dh_auto_test: cargo test --workspace --release override_dh_auto_clean: cargo clean $(MAKE) -C pam clean ``` **Step 5:** Create empty `.install` files for each package (the `dh_install` magic; populated in Phase 13). For now they can be empty or omitted since `debian/rules` does manual installs. **Step 6:** `debian/copyright` — boilerplate Apache-2.0 copyright in machine-readable format. Generate from `cargo about` later, hand-roll for now. **Step 7:** Build the deb: ```bash sudo apt install -y debhelper devscripts dh-make sbuild lintian \ libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config debuild -us -uc -b ``` Expected: produces 5 `.deb` files in the parent directory. Lintian may complain (we'll silence one warning at a time in later phases). **Step 8:** Verify install + remove on a throwaway VM (Multipass shell or LXD container). ```bash sudo dpkg -i ../authforge*.deb || sudo apt -f install sudo systemctl status authforge.service # will fail until Phase 1 ships unit sudo apt purge authforge* ``` **Step 9:** Commit. ```bash git add debian git commit -m "Add debhelper-13 packaging skeleton (5 component debs)" ``` ### Task 0.9: GitHub Actions CI **Files:** - Create: `.github/workflows/ci.yml` **Step 1:** Workflow file: ```yaml name: CI on: push: { branches: [main] } pull_request: jobs: rust: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.78 with: { components: rustfmt, clippy } - run: sudo apt-get update && sudo apt-get install -y libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace deb: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - run: sudo apt-get update && sudo apt-get install -y debhelper devscripts lintian libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config cargo rustc - run: debuild -us -uc -b - run: lintian --info --display-info ../authforge*.changes || true # warnings allowed in Phase 0 ``` **Step 2:** Push and verify both jobs go green. **Step 3:** Commit. ```bash git add .github git commit -m "Add GitHub Actions CI for cargo + deb" ``` ### Task 0.10: Phase 0 acceptance gate Verify all of the following before declaring Phase 0 complete: - [ ] `cargo build --workspace --release` succeeds. - [ ] `cargo test --workspace` succeeds (1 test in `common`). - [ ] `cargo clippy --workspace -- -D warnings` is clean. - [ ] `make -C pam` produces `pam_authforge_pending.so`. - [ ] `debuild -us -uc -b` produces 5 `.deb` files. - [ ] On an Ubuntu 24.04 LXD container: `sudo dpkg -i ../authforge*.deb` succeeds, `sudo apt purge authforge*` cleanly removes. - [ ] CI is green on `main`. If all check, tag `v0.1.0-scaffolding`: ```bash git tag -a v0.1.0-scaffolding -m "Phase 0: repository scaffolding complete" ``` --- # Phase 1: Daemon — D-Bus Interface, systemd, polkit **Goal:** `authforged` registers on the system bus as `io.dangerousthings.AuthForge`, gets started by systemd, and enforces polkit-mediated method-level authorization. Methods are stubs returning sensible test data. **Re-invoke `superpowers:writing-plans` at start of this phase to expand to step granularity.** **Files to create:** - `daemon/src/dbus.rs` — zbus `interface` definition - `daemon/src/state.rs` — in-memory state shell - `debian/authforge-daemon.service` — systemd unit - `debian/io.dangerousthings.AuthForge.conf` — D-Bus policy - `debian/io.dangerousthings.AuthForge.service` — D-Bus activation file - `debian/io.dangerousthings.AuthForge.policy` — polkit actions - `debian/authforge-daemon.postinst` — enable + start unit **D-Bus interface skeleton:** ```rust #[zbus::interface(name = "io.dangerousthings.AuthForge1")] impl AuthForge { async fn list_credentials(&self, user: String) -> zbus::fdo::Result> { ... } async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result { ... } async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> { ... } async fn enroll_other(&self, user: String, nickname: String) -> zbus::fdo::Result { ... } async fn get_policy(&self) -> zbus::fdo::Result { ... } async fn set_policy(&self, p: Policy) -> zbus::fdo::Result { ... } async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> { ... } async fn clear_pending_flag(&self, user: String) -> zbus::fdo::Result<()> { ... } async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result { ... } } ``` **polkit actions** (XML in `io.dangerousthings.AuthForge.policy`): | Action | Default for active session | |---|---| | `io.dangerousthings.AuthForge.enroll-own` | `auth_self_keep` | | `io.dangerousthings.AuthForge.remove-own` | `auth_self_keep` | | `io.dangerousthings.AuthForge.enroll-other` | `auth_admin_keep` | | `io.dangerousthings.AuthForge.set-policy` | `auth_admin_keep` | | `io.dangerousthings.AuthForge.set-pending` | `auth_admin_keep` | | `io.dangerousthings.AuthForge.clear-pending` | `auth_admin_keep` | | `io.dangerousthings.AuthForge.generate-recovery` | `auth_admin_keep` | **Tasks:** 1. Add zbus interface module with stubs returning fixture data. 2. Wire up `connection.request_name("io.dangerousthings.AuthForge")`. 3. Write systemd unit (`Type=dbus`, `BusName=io.dangerousthings.AuthForge`, `User=root`). 4. Write D-Bus system policy (allow root to own; allow `at_console` to call read methods; restrict write methods). 5. Write polkit policy XML; daemon checks each method against polkit before executing. 6. Wire postinst: `systemctl daemon-reload && systemctl enable --now authforge.service`. 7. Test: `busctl call io.dangerousthings.AuthForge /io/dangerousthings/AuthForge io.dangerousthings.AuthForge1 ListCredentials s "$USER"` returns the fixture data. 8. Test: as non-root, `SetPolicy` triggers polkit prompt. **Acceptance:** D-Bus introspection works (`busctl introspect ...`), all 9 methods are callable as stubs, polkit prompts appear at correct times. --- # Phase 2: Daemon — Storage Layer **Goal:** Real I/O to `/etc/authforge/policy.d/`, `/var/lib/authforge/pending/`, and `~/.config/Yubico/u2f_keys` (or `/etc/u2f_mappings`). Replaces fixtures from Phase 1. **Files:** - `common/src/policy.rs` — fully implemented TOML parser with last-key-wins merge - `daemon/src/storage/policy.rs` — read/write policy.d/ dir, inotify watcher - `daemon/src/storage/pending.rs` — read/write pending flags - `daemon/src/storage/credentials.rs` — manipulate u2f_keys files (per-user vs central) - `daemon/src/storage/userdb.rs` — `/var/lib/authforge/users.db` cache (sqlite via `rusqlite`) **Tasks:** 1. Implement `Policy::load_from_dir(path)` with merge semantics. TDD: write 3 test fixture dirs, assert merge order. 2. Implement `Policy::save(stack, mode, methods)` writing to `50-local.conf` while preserving `90-fleet.conf`. 3. Implement inotify watcher; daemon emits D-Bus signal `PolicyChanged` on change. 4. Implement pending flag (JSON file) read/write/clear. 5. Implement credential file manipulation (parse `pamu2fcfg` line format, append/remove by credId). 6. Implement userdb cache: tracks all users with any enrolled credentials for fast lockout simulation lookup. 7. Wire all storage modules into the D-Bus methods. **Acceptance:** Round-trip every state via D-Bus; verify file contents on disk match expected format precisely (pam_u2f reads them). --- # Phase 3: Daemon — FIDO2 Enrollment Backend **Goal:** Real device enrollment using `ctap-hid-fido2`. Touch / PIN prompts. Emits per-step events over D-Bus so the GUI can show progress. **Files:** - `daemon/src/fido/mod.rs` - `daemon/src/fido/enroll.rs` — credential creation - `daemon/src/fido/devices.rs` — discovery, hot-plug - `daemon/src/fido/format.rs` — convert to pam_u2f-compatible `keyHandle:userKey:counter` strings **Tasks:** 1. Add `ctap-hid-fido2 = "3"` to daemon deps. 2. Device discovery loop (poll every 500ms; for NFC, depend on PCSC stack via `pcsc` crate). 3. Enrollment: `make_credential` with rp_id `"pam://localhost"`, user_id from username hash, optional `uv` flag. 4. Emit D-Bus signals `DeviceFound { name, transport }`, `TouchRequired { device }`, `EnrollmentSucceeded { credential }`, `EnrollmentFailed { reason }`. 5. Convert resulting credential into pam_u2f line format. 6. Write integration test using `libfido2`'s software backend (`FIDO_SW_OPENSSL`). **Acceptance:** With a real Yubikey plugged in, `busctl call ... EnrollOwn s "alice" s "Yellow Yubikey"` succeeds, the line appears in `~alice/.config/Yubico/u2f_keys`, and `pam_u2f` validates against it. --- # Phase 4: Daemon — Policy Apply via pam-auth-update **Goal:** When `SetPolicy` is called, daemon updates `/etc/pam.d/*` correctly and idempotently using `pam-auth-update`. **Files:** - `daemon/src/policy_apply/mod.rs` - `daemon/src/policy_apply/profile.rs` — generates the pam-configs file dynamically - `debian/authforge-pam.install` — ships the static pam-configs profile - `debian/authforge-daemon.postinst` — runs `pam-auth-update --package` on install **Approach:** The pam-configs profile is templated. Daemon writes the appropriate variant to `/usr/share/pam-configs/authforge` based on which stacks are required, then invokes `pam-auth-update --package`. (Alternative: ship multiple profiles, enable/disable each. The first approach is simpler.) **Tasks:** 1. Write template renderer. 2. Implement `apply()` that: writes new profile → calls `pam-auth-update --package` → checks exit code. 3. Add rollback: stash previous profile before write; restore + re-run pam-auth-update on failure. 4. Wire into `SetPolicy` D-Bus method (which has already passed the lockout simulator, see Phase 5). **Acceptance:** Setting `sudo` to required in policy actually causes `sudo` to demand a security key in a fresh shell. --- # Phase 5: Daemon — Lockout Simulator **Goal:** Before any policy write, dry-run the new policy against currently-known users and refuse changes that would lock anyone out. **Files:** - `daemon/src/lockout/mod.rs` - `daemon/src/lockout/simulate.rs` **Approach:** For each affected stack and each user in `users.db` plus the active session's user: - Determine which methods the policy would require. - Check user's enrolled credentials. - If no enrolled method satisfies the requirement, the user would be locked out. Returns a list of (user, stack, reason) violations. **Tasks:** 1. Implement simulator as pure function over `(NewPolicy, UserDb, ActiveUser) -> Vec`. 2. Heavy unit-test coverage (every plausible combo). 3. Wire into `SetPolicy`: if violations and no `force` flag, return error with violation list. 4. Optional `pam_test_authenticate`-based real check: spawn a sandboxed PAM run as the affected user with mock credentials; this is a v1.1 hardening. **Acceptance:** Cannot accidentally lock yourself out via GUI or CLI without explicit `--force`. --- # Phase 6: PAM Module — `pam_authforge_pending.so` **Goal:** The C module that backstops first-login enrollment. Replaces the Phase 0 stub. **Files:** - `pam/pam_authforge_pending.c` — full implementation - `pam/Makefile` — already exists from Phase 0 **Behavior:** - `pam_sm_authenticate`: 1. Get `PAM_USER`. 2. Check for `/var/lib/authforge/pending/` (use `stat()`, root-owned, mode 0644, no setuid surprises). 3. If exists: a. If recovery code provided and matches `/var/lib/authforge/recovery/` (8-digit, 24h validity), accept and re-flag for re-enrollment. b. Otherwise return `PAM_AUTH_ERR` with conv message: "Account setup incomplete. Please complete enrollment in the Authentication app." 4. If no flag: return `PAM_IGNORE`. - `pam_sm_setcred`: `PAM_SUCCESS`. **Tasks:** 1. Implement file checks with proper path sanitization (reject usernames with `/`, `\0`, `..`). 2. Implement recovery code constant-time comparison (`memcmp` is *not* enough — use a constant-time helper). 3. Add structured logging via `pam_syslog`. 4. Test with `pamtester`: ``` sudo touch /var/lib/authforge/pending/alice pamtester authforge alice authenticate # expect: failure with our message sudo rm /var/lib/authforge/pending/alice pamtester authforge alice authenticate # expect: success (PAM_IGNORE → other modules carry it) ``` **Acceptance:** A user with a pending flag cannot log in; daemon clearing the flag immediately allows login. --- # Phase 7: CLI — `authforgectl` **Goal:** Full admin/fleet CLI. Talks D-Bus to the daemon. No magic — every command is a thin wrapper around a D-Bus call. **Files:** - `cli/src/main.rs` — replaces Phase 0 skeleton - `cli/src/commands/*.rs` — one file per top-level subcommand **Subcommands:** ``` authforgectl status authforgectl enroll [--user USER] [--nickname NAME] authforgectl list [--user USER] authforgectl remove [--user USER] CRED_ID authforgectl policy show authforgectl policy set [--methods METHOD,...] authforgectl policy apply [--force-i-know-what-im-doing] authforgectl policy validate authforgectl pending set USER [--methods METHOD,...] authforgectl pending clear USER authforgectl pending list authforgectl recovery generate USER authforgectl recovery list USER ``` **Output:** human-readable by default; `--json` flag for machine-parseable output (essential for Ansible integration). **Tasks:** straightforward; one task per subcommand. **Acceptance:** All flows from the design doc are achievable from CLI alone (headless server use case). --- # Phase 8: GUI — Shell + Security Keys Tab **Goal:** Replace Phase 0 placeholder with the real adaptive libadwaita UI for credential management. **Files:** - `gui/src/app.rs` — `adw::Application` setup - `gui/src/window.rs` — main window with `AdwViewSwitcher` - `gui/src/views/keys.rs` — Security Keys tab (list, add, remove) - `gui/src/dbus_client.rs` — async wrapper around zbus client - `gui/data/resources.gresource.xml` — bundled UI files (XML/Blueprint) - Various `.ui` files (use Blueprint via `blueprint-compiler` if available; fallback to XML) **Tasks:** 1. Move from `ApplicationWindow` to `AdwApplicationWindow` + `AdwViewStack` + `AdwViewSwitcher`. 2. Implement `KeysView` with `AdwPreferencesPage` containing one `AdwActionRow` per credential. 3. Implement enrollment dialog: `AdwDialog` with `AdwStatusPage` showing icon based on detected device type (USB / NFC / implant). 4. Listen for D-Bus signals `DeviceFound`, `TouchRequired`, etc., update dialog UI in response. 5. Empty state when no credentials. 6. Smoke test on a real Ubuntu 24.04 GNOME session: enroll a Yubikey, see it appear in the list, remove it, see it disappear. **Acceptance:** A user can do all credential management from this tab without a terminal. --- # Phase 9: GUI — Policy Tab **Goal:** The "where is MFA required" toggles, with the lockout warning UX baked in. **Files:** - `gui/src/views/policy.rs` - `gui/src/views/policy_advanced.rs` — pkexec-gated full list **Tasks:** 1. Three master toggles (gdm-password / sudo / sshd) as `AdwComboRow` with three options. 2. "Advanced…" expander triggers polkit prompt; on success, shows full list including any auto-detected stacks. 3. Pre-apply lockout simulation: button labeled "Apply" calls `policy validate` first; if violations, show inline `AdwBanner` with "fix it" suggestions. 4. After apply, show toast confirming change. 5. Visual indication of which stacks are currently in each mode. **Acceptance:** Cannot lock self out via GUI; admin can configure ssh policy via the advanced expander. --- # Phase 10: First-Login Flow **Goal:** End-to-end demo of Flow C from the design doc. **Files:** - `gui/src/views/firstrun.rs` — fullscreen modal mode - `gui/data/authforge-firstrun.desktop` — autostart entry - `debian/authforge-gui.install` — install autostart entry to `/etc/xdg/autostart/` - `gui/src/main.rs` — handle `--first-run` CLI flag **Tasks:** 1. Add `--first-run` flag to `authforge` binary; alters startup to fullscreen modal mode (no header bar, no decorations, sticky-on-top). 2. On startup in first-run mode, query daemon for `pending_flag(current_user)`; if absent, exit immediately. 3. Show welcome page + run enrollment flow. 4. On successful enrollment, call `clear_pending_flag` via D-Bus, exit cleanly. 5. Watchdog: if no successful enrollment within 60s of any user input, call `gnome-session-quit --logout --no-prompt`. 6. Autostart `.desktop` file with `OnlyShowIn=GNOME` and `X-GNOME-Autostart-Phase=Initialization`. **Test (manual, in VM):** 1. `useradd -m testuser`, `passwd testuser` (set tempPW), `chage -d 0 testuser`. 2. `sudo authforgectl pending set testuser --methods fido2`. 3. Log out, log in as testuser → forced password change → enrollment modal appears → enroll Yubikey → modal closes → desktop usable. 4. Verify: subsequent SSH-as-testuser-without-key is blocked by `pam_authforge_pending.so` if pending wasn't cleared. **Acceptance:** End-to-end flow C works in a VM smoke test. --- # Phase 11: TOTP Support (Feature Flag) **Goal:** TOTP as a third authenticator method, gated behind a build-time Cargo feature. **Files:** - `daemon/Cargo.toml` — add `totp` feature - `daemon/src/totp/mod.rs` — secret generation, recovery codes - `gui/src/views/totp.rs` — QR code display, secret enrollment - `debian/authforge-daemon.install` — conditionally install pam-configs entry for TOTP - `debian/control` — `Recommends: libpam-google-authenticator` when feature enabled **Tasks:** 1. Cargo feature `totp` (default-on). 2. Generate base32 secret (160 bits per RFC 6238). 3. Render QR code via `qrcode` crate; show in modal with copyable secret string. 4. Generate 8 recovery codes (8 digits each, stored hashed via Argon2id). 5. Wire into pam-auth-update profile: when TOTP-required stack is set, profile includes `pam_google_authenticator.so` with `secret=/etc/google-authenticator/${USER}` (file written by daemon during enrollment). 6. GUI tab visible only when `totp` feature is enabled at build (use `#[cfg(feature = "totp")]`). **Acceptance:** Can enroll TOTP, scan QR into Aegis, log into sudo using TOTP token. --- # Phase 12: Recovery Flow **Goal:** Lost-key path. Admin generates code, user uses it once. **Files:** - `daemon/src/recovery.rs` - `gui/src/views/recovery.rs` **Tasks:** 1. `generate_recovery_code(user)` writes `/var/lib/authforge/recovery/` (root-owned, 0600) containing Argon2id hash + expiry timestamp. 2. PAM module checks recovery code at password prompt (already added in Phase 6); on success, removes recovery file and writes a `/var/lib/authforge/pending/` flag with `re_enroll = true`. 3. CLI: `authforgectl recovery generate alice` outputs the 8-digit code. 4. GUI: "Recovery" tab shows generate / list / revoke. 5. Print-PDF feature: `gtk_print_unix_dialog` with template containing user's TOTP secret (opt-in only) and recovery codes. **Acceptance:** A user with no working key can use a recovery code to log in once and be re-enrolled. --- # Phase 13: Debian Packaging Finalization **Goal:** All packages install cleanly, postinst/prerm/postrm scripts handle every state transition correctly, debconf preseed works. **Files:** - `debian/authforge-daemon.{postinst,prerm,postrm}` - `debian/authforge-pam.{postinst,prerm,postrm}` - `debian/authforge.config` — debconf script - `debian/authforge.templates` — debconf templates **Tasks:** 1. postinst: enable + start daemon; run `pam-auth-update --package`; if first-time install and debconf provided answers, write initial `/etc/authforge/policy.d/00-debconf.conf`. 2. prerm: disable PAM enforcement (`pam-auth-update --package --remove`) so removal can't lock anyone out. 3. postrm purge: rm `/etc/authforge/`, `/var/lib/authforge/`. 4. debconf templates: ask "Default policy? (None / Optional everywhere / Required for sudo)". 5. lintian: get all warnings down to zero or explicitly overridden with rationale. 6. Test matrix: install → upgrade (from 0.0.x → 0.1.0) → remove → purge → reinstall, on Ubuntu 22.04 and 24.04. **Acceptance:** `piuparts authforge_0.1.0-1_amd64.deb` passes. --- # Phase 14: Launchpad PPA Setup **Goal:** End users can `sudo add-apt-repository ppa:dangerousthings/authforge`. **Tasks:** 1. Create Launchpad team `dangerousthings` (or use existing). 2. Create PPA `authforge`. 3. Generate signing key (gpg, store passphrase in 1Password / vault). 4. `dput ppa:dangerousthings/authforge authforge_0.1.0-1_source.changes` (note: Launchpad builds from source). 5. Wait for build, verify install on a fresh VM. 6. Document signing key fingerprint in README. **Acceptance:** Two-command install works on a clean Ubuntu 24.04 VM. --- # Phase 15: `authforge-gnome-integration` **Goal:** Optional shortcut deb that makes authforge discoverable from `gnome-control-center` Users panel. **Files:** - New source tree under `gnome-integration/` (separate Cargo workspace member or pure-data deb) - `debian/authforge-gnome-integration.install` **Approach (research required during this phase):** GNOME 46+ allows panel extensions via dbus-activated services. Ship a small JS/GJS extension or a dynamic library loaded by gnome-control-center. If neither stable approach exists, ship a `.desktop` file under `/usr/share/applications/` tagged with `X-GNOME-Settings-Panel=user-accounts` or similar — exact mechanism is GNOME-version-dependent. **Tasks:** 1. Spike: identify the cleanest hook in GNOME 46 / 47. 2. Implement. 3. Test against current Ubuntu LTS GNOME version. 4. Pin Recommends to specific gnome-control-center major version range. **Acceptance:** Opening Settings → Users → some-user shows a "Configure security…" link that launches `authforge --user some-user` with proper polkit context. --- # Phase 16: Ansible Role **Goal:** `dangerousthings.authforge` role on Ansible Galaxy that handles install, policy, and enrollment for fleet deployments. **Files (in a separate `ansible-role` tree, possibly its own repo):** - `ansible-role/tasks/main.yml` - `ansible-role/defaults/main.yml` - `ansible-role/templates/policy.conf.j2` - `ansible-role/meta/main.yml` **Tasks:** 1. Role tasks: add PPA, install packages, drop policy file at `/etc/authforge/policy.d/90-fleet.conf`, restart daemon. 2. Variables for: enabled stacks, modes per stack, central credential storage on/off, default firstrun methods. 3. Examples in `examples/playbook.yml`. 4. Publish to Galaxy. **Acceptance:** A bare Ubuntu 24.04 VM, after running the role, has the package installed and policy applied. --- # Phase 17: Integration Test Harness **Goal:** Repeatable end-to-end test that proves all major flows on a clean VM. **Files:** - `tests/integration/run.sh` - `tests/integration/Vagrantfile` (or LXD/Multipass alternative) - `tests/integration/scenarios/*.bats` (Bash Automated Testing System) **Scenarios:** 1. Fresh install → enroll Yubikey via CLI → set sudo to optional → sudo with key works. 2. First-login flow: create user, set pending, simulate login, expect enrollment modal. 3. Policy lockout prevention: set sudo to required without enrolling → expect rejection. 4. Recovery flow: enroll, "lose" key (delete file), generate recovery, login with code, re-enroll. 5. Upgrade in place: install 0.1.0, upgrade to 0.1.1, verify state preserved. 6. Purge: install, configure, purge, expect clean system. Use `umockdev` to simulate USB devices in CI; require real hardware for nightly extended runs. **Acceptance:** All 6 scenarios run green on PR. --- # Phase 18: User Docs **Files:** - `docs/user/getting-started.md` - `docs/user/admin-guide.md` - `docs/user/fleet-deployment.md` - `docs/user/recovery.md` - `docs/user/troubleshooting.md` - Optional: small static site (mdBook or Docusaurus) at `https://authforge.dangerousthings.com` **Tasks:** 1. Getting started: end-user view of "install + enroll my Yubikey + require it for sudo". 2. Admin guide: managing users, policy, recovery. 3. Fleet deployment: Ansible role usage, debconf preseed, central credential storage. 4. Recovery: what to do if you lose your key. 5. Troubleshooting: common failure modes (lockout via misconfig, NFC not detected, polkit prompt not appearing). **Acceptance:** A new user can go from "I read the README" to "I have MFA on sudo" in under 10 minutes. --- # R: v1.0 Release **Tasks:** 1. Bump version to `1.0.0` in `Cargo.toml`, `debian/changelog`. 2. Tag `v1.0.0`. 3. Push to PPA. 4. Announce: blog post on dangerousthings.com, post to r/Ubuntu, r/yubikey, r/Linux, lobste.rs, Hacker News. 5. Open feedback channel (GitHub Discussions). --- # Cross-cutting concerns (apply to every phase) - **Frequent commits.** One logical change per commit. Use conventional commit prefixes (`feat:`, `fix:`, `test:`, `docs:`, `refactor:`, `chore:`). - **TDD where mechanical.** Daemon logic, CLI argument parsing, policy parsing — all must be test-first. UI work is exempt; it gets manual smoke + screenshot review. - **No untested code paths** in lockout simulator or PAM module. These are the lockout-risk surface — every branch covered. - **YAGNI.** Resist the urge to build "future" extension points. The architecture allows KDE / Debian / Fedora additions cleanly; do not preemptively code abstractions for them in v1. - **Use `superpowers:verification-before-completion` before claiming any phase complete.** Run the acceptance checklist; do not advance without evidence. - **Re-invoke `superpowers:writing-plans` at the start of each phase ≥ 1** to expand its task list to step granularity using state from prior phases. --- # Risks and contingencies | Risk | Likelihood | Impact | Plan | |---|---|---|---| | `gnome-control-center` Users panel hook (Phase 15) has no stable extension point. | Medium | Low | Ship without the integration deb if needed; standalone app remains fully functional. Discoverability only. | | `ctap-hid-fido2` doesn't expose hot-plug events | Low | Medium | Fall back to polling (already planned). Add event support upstream if needed. | | Real-world Ubuntu PAM stacks already include third-party MFA modules (sssd, libpam-yubico) | Medium | Medium | postinst detects + warns; admin must explicitly accept import path. Document migration. | | Launchpad PPA build fails for non-amd64 arches | Low | Low | Restrict architectures in `debian/control` initially (`Architecture: amd64 arm64`); expand after demand. | | First-login modal can be killed via Ctrl-Alt-F2 → ssh → kill | Medium | Low | PAM module backstop (Phase 6) catches this — sshd login also blocked while pending flag exists. Document. | --- # Execution Handoff When the user returns: > Plan complete and saved to `docs/plans/2026-04-26-authforge-implementation.md`. The companion design doc is at `docs/plans/2026-04-26-authforge-design.md`. Phase 0 is fully detailed; phases 1–18 are spec'd to a level sufficient to begin work. Each later phase should re-invoke `superpowers:writing-plans` for step-level expansion as it begins. > > Two execution options: > > **1. Subagent-driven (this session)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. > > **2. Parallel session** — Open a new session in a worktree using `superpowers:executing-plans`, batch execution with checkpoints. > > Which approach?