Files
authforge/docs/plans/2026-04-26-authforge-implementation.md
2026-04-27 20:03:07 -07:00

80 KiB
Raw Blame History

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


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 DoneBundled 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 Code complete (2026-04-27) — piuparts smoke deferred to Phase 14
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 modal
              │              │            │
              └──────────────┴────────────┘
                             ↓
                        Phase 13  ← OPEN NEXT
                        deb finalization (every binary built)
                             ↓
                        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 open right now (post-Phase 11 merge)

All parallelism wins have been collected — Phases 9, 10, 11, and the Phase 12 GUI tab merged on 2026-04-27 via the prep-lane + 3-worktree fanout described at 2026-04-27-parallel-fanout-9-10-11.md. What remains is the sequential tail.

Sequential tail

Phase 13 (deb finalization) is now open — every binary it packages is built. Phase 14 (PPA) chains off 13 and is the VM smoke gate for the remaining "Code complete" deferrals: Phase 1 (5-deb output from debuild), Phase 3 (real Yubikey enrollment), Phase 4 (pam-auth-update --package against a real /etc/pam.d/), Phase 8 (GUI walkthrough w/ Yubikey), Phase 10 (Flow C autostart + watchdog), Phase 11 (pam_google_authenticator 6-digit verify with a real authenticator app). Phase 6 + Phase 12-backend's pamtester recipes (smoke tests 13 in pam/TESTING.md) were verified locally on 2026-04-27 once libargon2-dev + pamtester were installed; smoke test 4 (recovery-code path with the daemon running) remains for Phase 14. Phase 17 (integration tests) chains off 14 — also lands the safe_user canonicalize-then-verify hardening flagged in pam/TESTING.md. 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.
  • Three-lane fanout (Phases 9 / 10 / 11) needed a prep lane first. A naive "branch off main, dispatch three subagents" plan hit a hard wall — every lane wanted to edit gui/src/main.rs, two wanted daemon/src/{dbus,state}.rs. Splitting out a prep lane (AppContext refactor + --first-run flag scaffold + daemon GetPendingStatus method) and merging it first made the three follow-on lanes file-disjoint. Coordinator doc at 2026-04-27-parallel-fanout-9-10-11.md. Wall-clock from prep merge to all three lanes merged: ~90 minutes including a doc-conflict resolution on the Phase 9-vs-Phase 10 closeout and an interrupt-and-resume cycle when one agent stalled mid-Task-7.

Phase 1 closeout notes (2026-04-27)

Phase 1 expanded to step granularity at 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) 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. All 17 tasks landed in 8 commits, tagged v0.2.0-storage.

Done in Phase 2:

  • common/src/policy.rsPolicy::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.rsPolicyStore 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.rspam_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.rsrusqlite (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.stacksp2p_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. Tagged v0.3.0-multi-lane. 12 commits (1 plan + 11 implementation).

Phase 3 — FIDO2 enrollment backend:

  • daemon/src/fido/format.rsPamU2fCred -> kh,pk,es256,+presence with hex::encode; CoseType matching COSE alg -7/-8.
  • daemon/src/fido/authenticator.rsAuthenticator trait with discover() and make_credential(rp_id, user, pin). AuthnError::{NoDevice, Cancelled, PinRequired, Backend}.
  • daemon/src/fido/mock.rsMockAuthenticator::with_one_yubikey() for tests; counter-bumped per call.
  • daemon/src/fido/ctap.rsCtapAuthenticator 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.rsPolicyApplier::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. Eight task commits + one plan correction + merge commit (943d9b0).

What landed:

  • gui/src/error.rsclassify(&zbus::Error) + user_message() mapping zbus errors to Disconnected / AccessDenied / Other. 4 unit tests, all green.
  • gui/src/bus.rsDaemon 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 pattern.
  • gui/src/keys_page.rsKeysPage 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. 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) — 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.rsRecoveryStore: 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_verifys 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. 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.rsAppContext 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. 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. 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.rsTotpStore 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_streamgdk::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_bytesgdk_pixbuf::Pixbuf::from_streamgdk::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 13 closeout notes (2026-04-27)

Landed via 2026-04-27-phase-13-deb-packaging.md. 10 commits on main (no worktree — all changes are in debian/ and don't touch the workspace's Rust crates).

Done:

  • debian/authforge.pam-config — baseline pam-auth-update profile, Default: no, pending-flag-only. Daemon's policy_apply::render_profile overwrites this file at runtime once a real policy is set.
  • debian/authforge-pam.{postinst,prerm} — registers via pam-auth-update --package on configure; deregisters via --remove authforge on remove/deconfigure. Idempotent. Use command -v pam-auth-update (not hard-coded /usr/sbin/) to satisfy lintian.
  • debian/authforge-daemon.postrm — purges /etc/authforge/ + /var/lib/authforge/ on purge; preserves them on plain remove (Debian Policy §6.6).
  • debian/authforge.{templates,config} — debconf prompt: None / Optional everywhere / Required for sudo. Default None. Pre-Depends: debconf (>= 0.5) | debconf-2.0 added to the metapackage.
  • debian/authforge.postinst — reads the debconf answer; writes /etc/authforge/policy.d/00-debconf.conf exactly once on first install ([ -z "$2" ]). Schema cross-checked against daemon/src/storage/policy.rs test fixture ([stacks.<name>] + mode + methods).
  • Per-binary lintian-overrides (six files) — zero W/E from lintian on the built debs. Real fixes folded in: policykit-1polkitd | policykit-1 (obsolete-package error); dropped manual /lib/systemd/system/ install line so dh_installsystemd owns the unit at /usr/lib/systemd/system/ (file-in-root-and-usr error). Overrides cover deferred-manpage (Phase 18), cross-package desktop-command-not-in-package (gnome-integration → authforge-gui), and initial-upload-closes-no-bugs (PPA-only, no ITP).
  • debian/PIUPARTS.md — manual smoke recipe for Ubuntu 24.04. Install → reinstall → remove → purge cycle documented; piuparts run is the Phase 14 gate.
  • debian/changelog — bumped (0.1.0-1) UNRELEASED; urgency=low(0.1.0-1) noble; urgency=medium ready for Phase 14 dput.
  • .gitignore — added debian/*.debhelper so build-time hook snippets don't pollute git status.

Plan deviations:

  • dpkg-buildpackage invoked with -d throughout (cargo + rustc are installed via rustup, not apt, so the apt-level Build-Depends check fails even though the build itself works). The Build-Depends in debian/control are correct for the Phase 14 PPA / Launchpad sbuild — only the local dev-env check needs the override.
  • Lintian sweep folded three real fixes into the same commit as the overrides rather than splitting per the plan's literal "fix any real warnings" suffix — the fixes are all packaging-control changes (control file, rules file, hook scripts) and live in the same diff window. Single atomic commit reads cleaner than three.
  • Added a # shellcheck source=/dev/null directive to authforge.config and authforge.postinst for the /usr/share/debconf/confmodule source line — shellcheck SC1091 is info-level and was returning exit 1 under the local config; the directive is the standard idiom for sourced runtime files.

Test count delta: 0. Phase 13 ships no Rust code. cargo test --workspace still reports 112 passed (cli 9 + common 8 + daemon 78 + gui 10 + pam 0 + zero failures). cargo fmt --all -- --check clean.

Deferred until Phase 14 PPA + VM smoke:

  • piuparts actually run on a clean Ubuntu 24.04 chroot.
  • pam-auth-update --package actually mutating /etc/pam.d/common-* on a real system.
  • The "Required for sudo" debconf branch tested end-to-end (sudo prompts for FIDO2 + password).
  • dpkg-buildpackage -us -uc -b producing five debs that install on a fresh VM (build verified locally; install path is the Phase 14 gate).

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.

cd /home/work/VSCodeProjects/ubuntu_fido
git init -b main

Step 2: Write .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:

# 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.

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:

[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:

[toolchain]
channel = "1.78"
components = ["rustfmt", "clippy"]
profile = "minimal"

Step 3: Write .cargo/config.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:

[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:

pub mod policy;
pub mod types;

Step 3: common/src/types.rs:

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:

// Policy parsing implemented in Phase 2.

Step 5: Write a failing test in common/src/types.rs:

#[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.

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:

[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:

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.

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:

[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:

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.

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:

#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:

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:

cd pam && make && cd ..

Expected: produces pam/pam_authforge_pending.so (~10 KB). Requires libpam0g-dev (will be in build-deps).

Step 4: Commit.

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:

[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:

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 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).

cargo build -p authforge-gui

Expected: success.

Step 7: Commit.

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):

#!/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:

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).

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.

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:

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.

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:

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:

#[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.rsadw::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/controlRecommends: 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?