Files
authforge/docs/plans/2026-04-26-authforge-implementation.md
2026-04-27 12:15:33 -07:00

1559 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 118 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 | 23 days | ✅ **Done** (2026-04-26, tag `v0.1.0-scaffolding`) |
| 1 | Daemon: D-Bus interface stub, systemd unit, polkit rules | 34 days | ✅ **Code complete** (2026-04-27, tag `v0.1.0-phase1`) |
| 2 | Daemon: storage layer (policy.d parser, pending flags, user db) | 34 days | ✅ **Done** (2026-04-27, tag `v0.2.0-storage`) |
| 3 | Daemon: FIDO2 enrollment backend via `ctap-hid-fido2` | 57 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 | 68 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 | ✅ **Code complete** (2026-04-27) — bundles Phase 12 GUI tab; 3 unit tests in `policy_form` |
| 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 | ✅ **Code complete** (2026-04-27) — `pam_google_authenticator` smoke deferred |
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM + GUI tab all landed |
| 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: 14 of 19 phases code-complete (74%).** 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 <worktree> && …`, `git -C <worktree> …`, 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<T>`; 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/<user>/…`).
- `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/<user>/…`.
- `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/<user>`" 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/<user>`. 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<Violation>`. 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<ProxyInner>`) 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 `<expires_unix>\n<argon2id-PHC>\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<RecoveryCodeSummary>`), 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 <user>`. 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 9 + Phase 12 GUI closeout notes (2026-04-27)
Landed via the plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md). Built atop the Phase 8 GUI shell. Task 1 (AppContext refactor) was pre-landed by the prep lane (commit `a927d96`); the remaining 8 tasks ship as 8 commits on `feature/phase-9-and-12-gui`.
**Done in Phase 9:**
- `gui/src/app_context.rs``AppContext` struct shared across pages (foreseen by Phase 8 Task 7; pre-landed by prep lane).
- `gui/src/policy_form.rs` — pure mode-mapping + violation-grouping helpers. 3 unit tests.
- `gui/src/policy_page.rs` — Policy tab with three master stacks (gdm-password / sudo / sshd) as `AdwComboRow`, inline `AdwBanner` for lockout simulation results, "Force apply" action, `AdwExpanderRow` for non-master stacks, live refresh via `PolicyChanged` subscription.
**Done in Phase 12 GUI:**
- `gui/src/recovery_page.rs` — list / generate / revoke. One-shot copy dialog (`adw::AlertDialog`) for freshly issued codes; the daemon stores Argon2id hashes only, so plaintext is unrecoverable after the dialog closes.
- `gui/src/bus.rs` extended with `get_policy`, `set_policy`, `generate_recovery_code`, `list_recovery_codes`, `revoke_recovery_code`, `subscribe_policy_changed`.
**Test count:** gui 4 → 7 (+3 from `policy_form`). Workspace total: 7 cli + 13 common + 60 daemon + 7 gui = **87 tests**.
**Plan deviations:**
- `add_titled_with_icon` from the plan replaced with `add_titled` to match the existing `KeysPage` registration in `main.rs` (icons aren't surfaced by the current `ViewSwitcher`).
- `policy_form.rs` carries a module-level `#![allow(dead_code)]` so the staged-but-not-yet-wired symbols pass the `-D warnings` gate at the Task 3 commit; consistent with the same pattern in `bus.rs` / `error.rs` from Phase 8.
- `dialog.present(Some(&parent))` from the plan corrected to `dialog.present(&parent)``adw::Dialog::present` in libadwaita 0.6.0 takes `&impl IsA<Widget>` not `Option<…>`.
**Deferred until Phase 14 VM smoke:**
- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session with a Yubikey.
### 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 11 closeout notes (2026-04-27)
Landed via [2026-04-27-phase-11-totp.md](2026-04-27-phase-11-totp.md). Built atop the prep lane's `AppContext` and 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 and a modal QR dialog (rendered via `qrcode` crate's SVG output → `gdk_pixbuf::Pixbuf::from_stream``gdk::Texture::for_pixbuf`).
- CLI: `authforgectl totp enroll|status|revoke <user>`. 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 (verified clippy-clean in both modes).
- `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 lane defers 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. Revisit if smoke testing reveals workflow gaps.
- Task 7's `dialog.present(Some(&parent))` corrected to `dialog.present(&parent)``adw::Dialog::present` in libadwaita 0.6.0 takes `&impl IsA<Widget>`, not `Option<…>`. Same correction Phase 9 + 12 GUI lane noted.
- Task 7's `gtk::gdk::Texture::from_bytes(&bytes)` (not present in `gdk4` 0.8.2) routed through `gtk::gio::MemoryInputStream::from_bytes``gdk_pixbuf::Pixbuf::from_stream``gdk::Texture::for_pixbuf`. The `if let Ok(_)` guard preserves the silent-fallback behaviour: when the SVG loader is missing, the dialog still shows the secret + URI for manual entry.
- Task 7's tab registration uses `add_titled` (no icon), matching the existing convention in `main.rs` from the Phase 9 + 12 GUI closeout.
**Test count delta:** workspace 96 → 112 (+16). Breakdown: cli 7 → 9 (+2), daemon 60 → 78 (+18: 5 totp + 6 storage::totp + 3 dbus TOTP + 2 policy_apply TOTP + 2 ancillary policy_apply assertions); common +0; gui +0 (TOTP tab is manual-smoke).
**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.
---
# 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 <ops@dangerousthings.com>"]
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 <security/pam_modules.h>
#include <security/pam_ext.h>
#include <stdio.h>
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 <ops@dangerousthings.com> 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 <ops@dangerousthings.com>
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/<user> 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<Vec<Credential>> { ... }
async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result<Credential> { ... }
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<Credential> { ... }
async fn get_policy(&self) -> zbus::fdo::Result<Policy> { ... }
async fn set_policy(&self, p: Policy) -> zbus::fdo::Result<PolicyApplyResult> { ... }
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<String> { ... }
}
```
**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<Violation>`.
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/<user>` (use `stat()`, root-owned, mode 0644, no setuid surprises).
3. If exists:
a. If recovery code provided and matches `/var/lib/authforge/recovery/<user>` (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 <stack> <mode> [--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/<user>` (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/<user>` 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 118 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?