Cleans up scattered status markers into a single legend (Done / Code complete / Spec'd / Bundled), backfills v0.1.0-phase1 tag reference for Phase 1, marks the table consistently for what's actually shipping vs. what needs the Phase 14 VM smoke. Updates the lane diagram to show what's done vs. in flight, and adds a 'lessons learned from this session's parallelism' section capturing when subagents-in-worktrees actually work and when they slip on sandbox-blocked verification commands. Adds closeout notes for Phases 3, 4+5, 6, 7, 15, 16 alongside the existing Phase 1 and Phase 2 sections — what shipped, plan deviations, and any wire- breaking signature changes (SetPolicy gained a force flag in Phase 4+5).
62 KiB
authforge Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Note on plan structure: This is a multi-month project. Phase 0 is fully detailed at step granularity. Phases 1–18 are specified with goals, deliverables, file targets, and task lists at sufficient detail to begin implementation. Each subsequent phase should re-invoke
superpowers:writing-plansto 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 | 2–3 days | ✅ Done (2026-04-26, tag v0.1.0-scaffolding) |
| 1 | Daemon: D-Bus interface stub, systemd unit, polkit rules | 3–4 days | ✅ Code complete (2026-04-27, tag v0.1.0-phase1) |
| 2 | Daemon: storage layer (policy.d parser, pending flags, user db) | 3–4 days | ✅ Done (2026-04-27, tag v0.2.0-storage) |
| 3 | Daemon: FIDO2 enrollment backend via ctap-hid-fido2 |
5–7 days | ✅ Code complete (2026-04-27, tag v0.3.0-multi-lane) — real-HW smoke deferred |
| 4 | Daemon: policy apply via pam-auth-update wrapper | 4 days | ✅ Code complete (2026-04-27, tag v0.4.0-parallel) — root-write smoke deferred |
| 5 | Daemon: lockout simulator | 3 days | ✅ Done — Bundled with Phase 4 |
| 6 | PAM module: pam_authforge_pending.so (C) |
3 days | ✅ Code complete (2026-04-27) — pamtester smoke recipe in pam/TESTING.md |
| 7 | CLI: authforgectl |
4 days | ✅ Done (2026-04-27) — 14 subcommands |
| 8 | GUI: app shell + Security Keys tab | 6–8 days | Spec'd — next single-lane bundle (see § Parallel Execution Lanes) |
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | Spec'd — opens after Phase 8 |
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | Spec'd — needs Phase 6 ✓ + Phase 8 |
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | Spec'd — opens now (needs Phase 2 ✓ + Phase 4 ✓) |
| 12 | Recovery flow (codes + emergency unlock) | 4 days | Spec'd — opens now (needs Phase 6 ✓) |
| 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: 11 of 19 phases code-complete (58%). 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 9 Phase 12 Phase 16 ✅
GUI keys GUI policy recovery Ansible (parallel agent)
(next) (after 5) (open)
│ │ │
└──────────┼────────────┘
↓
Phase 10
first-login (needs 6 + 8)
│
↓
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 4+5)
Three lanes touch disjoint file trees and can land in any order:
| Lane | Phases | Touches | Why parallel-safe |
|---|---|---|---|
| GUI | 8 (then 9, after 5 ✓) | gui/src/ |
The D-Bus contract is fully wired; GUI is a pure consumer. Needs libgtk-4-dev + libadwaita-1-dev on the box. |
| Recovery | 12 | daemon/src/recovery.rs, pam/pam_authforge_pending.c (recovery hook), small dbus.rs GenerateRecoveryCode body |
The PAM module already has a recovery_code_matches stub returning 0; Phase 12 makes it real. Daemon side is brand-new file. |
| TOTP | 11 | daemon/src/totp/, gui/src/views/totp.rs (when 8 lands), debian/control Recommends |
Feature-flagged at build time; only the daemon-side bits open right now. GUI tab waits on Phase 8. |
Sequential tail (no parallelism wins)
Phase 10 (first-login) needs both Phase 6 ✓ and Phase 8. Phase 13 (deb finalization) needs every binary built. Phase 14 (PPA) chains off 13 — and is the VM smoke gate for everything currently in "Code complete" status. 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 covercd <worktree> && …,git -C <worktree> …, and broadcargo */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 viaCARGO_TARGET_DIR. We didn't need to — eachgit worktree addproduces 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/registrycargo 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. 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-memoryAppStatewith fixtures. - polkit authorizer with
Permissive(env-var bypass) +System(realorg.freedesktop.PolicyKit1.Authority) modes. - All wire types in
common:Mode,Method,Transport,Credential,StackPolicy,StorageBackend,Storage,Firstrun,Policy,PendingFlag,Violation,PolicyApplyResult. AllSerialize + Deserialize + zvariant::Type. - systemd unit (
authforge-daemon.service) with hardening (NoNewPrivileges,ProtectSystem=full, scopedReadWritePaths). - D-Bus activation file + system policy XML.
- polkit policy XML with all 7 actions from the design doc.
authforge-daemon.postinstdrivingdaemon-reload+enable --now.- Packaging (
debian/rules+control) installs the new assets and depends ondbus,policykit-1. - CI installs
dbusso the new tests run there too. - 21 unit/integration tests across
common(7) anddaemon(14, including p2p D-Bus tests viatokio::net::UnixStream::pair).
Deferred until a Phase 14 smoke run on a clean Ubuntu VM:
debuild -us -uc -b5-deb output (needs libgtk-4-dev / libadwaita-1-dev / libfido2-dev / libpam0g-dev installed).busctl introspect/ polkit prompt manual smoke.systemctl status authforge-daemonafter install.
Plan deviations recorded in the commit:
PendingFlag.deadline_unixisu64with0= no deadline (zvariantTypederive does not supportOption<T>; sentinel matches the design doc's existingdeadline_hours = 0convention).- Sender → pid resolution for polkit
unix-processsubjects punts to early Phase 2 — Phase 1 passes pid 0, which the permissive authorizer ignores. Until pid resolution lands, the daemon is run withAUTHFORGE_POLKIT_BYPASS=1for any local smoke test (loudwarn!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.rs—Policy::load_from_dir(lex-ordered last-wins merge; ignores non-.conf; missing-dir → default) andPolicy::save_local(writes50-local.conf, never touches siblings). Policy-related types moved here fromtypes.rswith re-exports for stability.daemon/src/storage/policy.rs—PolicyStorewraps load/save;watch()returns a(RecommendedWatcher, watch::Receiver<()>)driven bynotify::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_u2fline parser (username:cred1[:cred2…]),CredentialsStorewith idempotent add-by-credId and remove-by-credId,CredsPathResolverdispatching 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-backedAppStateseeded with central-mode storage so credential writes don't try to touch/home/<user>/….daemon/src/main.rs— spawns a watcher task that emitsPolicyChangedon everyrx.changed();AUTHFORGE_POLICY_DIR/_PENDING_DIR/_USERDBenv-var overrides for non-prod runs.AppState::open(StorageConfig)replaceswith_fixtures(). All storage errors threaded throughStateErrorand surfaced asorg.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_namereturnsErr(ENOENT)rather thanOk(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_statetest renamed toset_policy_persists_stacksand asserts onlymerged.stacks == pol.stacks—p2p_pairseeds a[storage]block in00-test.conf, so the merged read is by design a superset of whatSetPolicywrote to50-local.conf. The new assertion captures the actual user-visible behavior..gitignoreextended 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.rs—PamU2fCred->kh,pk,es256,+presencewithhex::encode;CoseTypematching COSE alg-7/-8.daemon/src/fido/authenticator.rs—Authenticatortrait withdiscover()andmake_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—CtapAuthenticatorwrappingctap-hid-fido2 = "3.5.9". Hardware-dependent; compile-clean gate only.AppState::enroll(user, nickname)replaces the Phase 2 add-credential stub: callsauthn.make_credential, writes pam_u2f line, records userdb enrollment, returnsCredentialwithhex(keyHandle)as id.- D-Bus signals
DeviceFound,TouchRequired,EnrollmentSucceeded,EnrollmentFailedemitted fromenroll_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-devwith-Wall -Wextra -Werror -fPIC -O2. 16KB ELF. pam/TESTING.md+pam/test/authforge.pamddocument the manualpamtestersmoke 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--jsonflag. cli/src/bus.rs— owned-proxy wrapper viazbus::Proxy::new_owned.- 5 clap-parser tests cover subcommand definition validity, status parse, policy-set with method list, enroll with user+nickname,
--jsonglobal.
Plan deviations:
HidInfoin ctap-hid-fido2 3.5.9 haspid,vid,product_string,info,param— noserial_number/path. Usedproduct_stringfor the device name andHidParam::Path/VidPidfor the path label.FidoKeyHidFactoryandLibCfglive at the crate root, not underfidokey::.fido/mod.rscarries#![allow(dead_code)]until Phase 8 GUI consumes theDeviceFoundsignal —discover()andDiscoveredDevicefields 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 thepam-configs/authforgeprofile (Default: yeswhen any stack requires fido2;pam_u2f.so+pam_authforge_pending.sochain when fido2 required, only the pending backstop otherwise) and runspam-auth-update --package.- Stash-and-restore on failure: prior profile contents are read before write, restored if
pam-auth-updatefails, andpam-auth-updateis 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/shshim.
Phase 5 — Lockout simulator:
daemon/src/lockout.rs— puresimulate(&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, returnsPolicyApplyResult { applied: false, violations }without writing or invoking pam-auth-update. Otherwise persists viaPolicyStore::saveand invokesPolicyApplier::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—.desktopoverlay taggedX-GNOME-Settings-Panel=user-accounts,NoDisplay=true,OnlyShowIn=GNOME;. Adds "Configure security…" insidegnome-control-centerUsers panel.debian/controlparagraph for the new binary deb (Architecture: all,Depends: authforge-gui, gnome-control-center).debian/rulesinstall line fordebian/authforge-gnome-integration/usr/share/applications/.gnome-integration/README.mddocuments 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(gatedauthforge-gui), templatespolicy.conf.j2to/etc/authforge/policy.d/90-fleet.conf, runsauthforgectl pending setfor users inauthforge_pending_users. - Single restart handler for
authforge-daemon.service. - All 5 YAML files validated via
yaml.safe_load.ansible-lintskipped — not installed in the harness sandbox. - Subagent-initiated divergences (kept): added
authforge_ppaoverride variable so internal-mirror users don't fork the role; pre-creates/etc/authforge/policy.d/defensively (idempotent); standardansible_managedtemplate header.
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.mdwith 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 usedebhelper-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 --releasesucceeds.cargo test --workspacesucceeds (1 test incommon).cargo clippy --workspace -- -D warningsis clean.make -C pamproducespam_authforge_pending.so.debuild -us -uc -bproduces 5.debfiles.- On an Ubuntu 24.04 LXD container:
sudo dpkg -i ../authforge*.debsucceeds,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— zbusinterfacedefinitiondaemon/src/state.rs— in-memory state shelldebian/authforge-daemon.service— systemd unitdebian/io.dangerousthings.AuthForge.conf— D-Bus policydebian/io.dangerousthings.AuthForge.service— D-Bus activation filedebian/io.dangerousthings.AuthForge.policy— polkit actionsdebian/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:
- Add zbus interface module with stubs returning fixture data.
- Wire up
connection.request_name("io.dangerousthings.AuthForge"). - Write systemd unit (
Type=dbus,BusName=io.dangerousthings.AuthForge,User=root). - Write D-Bus system policy (allow root to own; allow
at_consoleto call read methods; restrict write methods). - Write polkit policy XML; daemon checks each method against polkit before executing.
- Wire postinst:
systemctl daemon-reload && systemctl enable --now authforge.service. - Test:
busctl call io.dangerousthings.AuthForge /io/dangerousthings/AuthForge io.dangerousthings.AuthForge1 ListCredentials s "$USER"returns the fixture data. - Test: as non-root,
SetPolicytriggers 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 mergedaemon/src/storage/policy.rs— read/write policy.d/ dir, inotify watcherdaemon/src/storage/pending.rs— read/write pending flagsdaemon/src/storage/credentials.rs— manipulate u2f_keys files (per-user vs central)daemon/src/storage/userdb.rs—/var/lib/authforge/users.dbcache (sqlite viarusqlite)
Tasks:
- Implement
Policy::load_from_dir(path)with merge semantics. TDD: write 3 test fixture dirs, assert merge order. - Implement
Policy::save(stack, mode, methods)writing to50-local.confwhile preserving90-fleet.conf. - Implement inotify watcher; daemon emits D-Bus signal
PolicyChangedon change. - Implement pending flag (JSON file) read/write/clear.
- Implement credential file manipulation (parse
pamu2fcfgline format, append/remove by credId). - Implement userdb cache: tracks all users with any enrolled credentials for fast lockout simulation lookup.
- 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.rsdaemon/src/fido/enroll.rs— credential creationdaemon/src/fido/devices.rs— discovery, hot-plugdaemon/src/fido/format.rs— convert to pam_u2f-compatiblekeyHandle:userKey:counterstrings
Tasks:
- Add
ctap-hid-fido2 = "3"to daemon deps. - Device discovery loop (poll every 500ms; for NFC, depend on PCSC stack via
pcsccrate). - Enrollment:
make_credentialwith rp_id"pam://localhost", user_id from username hash, optionaluvflag. - Emit D-Bus signals
DeviceFound { name, transport },TouchRequired { device },EnrollmentSucceeded { credential },EnrollmentFailed { reason }. - Convert resulting credential into pam_u2f line format.
- 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.rsdaemon/src/policy_apply/profile.rs— generates the pam-configs file dynamicallydebian/authforge-pam.install— ships the static pam-configs profiledebian/authforge-daemon.postinst— runspam-auth-update --packageon 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:
- Write template renderer.
- Implement
apply()that: writes new profile → callspam-auth-update --package→ checks exit code. - Add rollback: stash previous profile before write; restore + re-run pam-auth-update on failure.
- Wire into
SetPolicyD-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.rsdaemon/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:
- Implement simulator as pure function over
(NewPolicy, UserDb, ActiveUser) -> Vec<Violation>. - Heavy unit-test coverage (every plausible combo).
- Wire into
SetPolicy: if violations and noforceflag, return error with violation list. - 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 implementationpam/Makefile— already exists from Phase 0
Behavior:
pam_sm_authenticate:- Get
PAM_USER. - Check for
/var/lib/authforge/pending/<user>(usestat(), root-owned, mode 0644, no setuid surprises). - 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 returnPAM_AUTH_ERRwith conv message: "Account setup incomplete. Please complete enrollment in the Authentication app." - If no flag: return
PAM_IGNORE.
- Get
pam_sm_setcred:PAM_SUCCESS.
Tasks:
- Implement file checks with proper path sanitization (reject usernames with
/,\0,..). - Implement recovery code constant-time comparison (
memcmpis not enough — use a constant-time helper). - Add structured logging via
pam_syslog. - 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 skeletoncli/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::Applicationsetupgui/src/window.rs— main window withAdwViewSwitchergui/src/views/keys.rs— Security Keys tab (list, add, remove)gui/src/dbus_client.rs— async wrapper around zbus clientgui/data/resources.gresource.xml— bundled UI files (XML/Blueprint)- Various
.uifiles (use Blueprint viablueprint-compilerif available; fallback to XML)
Tasks:
- Move from
ApplicationWindowtoAdwApplicationWindow+AdwViewStack+AdwViewSwitcher. - Implement
KeysViewwithAdwPreferencesPagecontaining oneAdwActionRowper credential. - Implement enrollment dialog:
AdwDialogwithAdwStatusPageshowing icon based on detected device type (USB / NFC / implant). - Listen for D-Bus signals
DeviceFound,TouchRequired, etc., update dialog UI in response. - Empty state when no credentials.
- 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.rsgui/src/views/policy_advanced.rs— pkexec-gated full list
Tasks:
- Three master toggles (gdm-password / sudo / sshd) as
AdwComboRowwith three options. - "Advanced…" expander triggers polkit prompt; on success, shows full list including any auto-detected stacks.
- Pre-apply lockout simulation: button labeled "Apply" calls
policy validatefirst; if violations, show inlineAdwBannerwith "fix it" suggestions. - After apply, show toast confirming change.
- 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 modegui/data/authforge-firstrun.desktop— autostart entrydebian/authforge-gui.install— install autostart entry to/etc/xdg/autostart/gui/src/main.rs— handle--first-runCLI flag
Tasks:
- Add
--first-runflag toauthforgebinary; alters startup to fullscreen modal mode (no header bar, no decorations, sticky-on-top). - On startup in first-run mode, query daemon for
pending_flag(current_user); if absent, exit immediately. - Show welcome page + run enrollment flow.
- On successful enrollment, call
clear_pending_flagvia D-Bus, exit cleanly. - Watchdog: if no successful enrollment within 60s of any user input, call
gnome-session-quit --logout --no-prompt. - Autostart
.desktopfile withOnlyShowIn=GNOMEandX-GNOME-Autostart-Phase=Initialization.
Test (manual, in VM):
useradd -m testuser,passwd testuser(set tempPW),chage -d 0 testuser.sudo authforgectl pending set testuser --methods fido2.- Log out, log in as testuser → forced password change → enrollment modal appears → enroll Yubikey → modal closes → desktop usable.
- Verify: subsequent SSH-as-testuser-without-key is blocked by
pam_authforge_pending.soif 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— addtotpfeaturedaemon/src/totp/mod.rs— secret generation, recovery codesgui/src/views/totp.rs— QR code display, secret enrollmentdebian/authforge-daemon.install— conditionally install pam-configs entry for TOTPdebian/control—Recommends: libpam-google-authenticatorwhen feature enabled
Tasks:
- Cargo feature
totp(default-on). - Generate base32 secret (160 bits per RFC 6238).
- Render QR code via
qrcodecrate; show in modal with copyable secret string. - Generate 8 recovery codes (8 digits each, stored hashed via Argon2id).
- Wire into pam-auth-update profile: when TOTP-required stack is set, profile includes
pam_google_authenticator.sowithsecret=/etc/google-authenticator/${USER}(file written by daemon during enrollment). - GUI tab visible only when
totpfeature 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.rsgui/src/views/recovery.rs
Tasks:
generate_recovery_code(user)writes/var/lib/authforge/recovery/<user>(root-owned, 0600) containing Argon2id hash + expiry timestamp.- 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 withre_enroll = true. - CLI:
authforgectl recovery generate aliceoutputs the 8-digit code. - GUI: "Recovery" tab shows generate / list / revoke.
- Print-PDF feature:
gtk_print_unix_dialogwith 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 scriptdebian/authforge.templates— debconf templates
Tasks:
- 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. - prerm: disable PAM enforcement (
pam-auth-update --package --remove) so removal can't lock anyone out. - postrm purge: rm
/etc/authforge/,/var/lib/authforge/. - debconf templates: ask "Default policy? (None / Optional everywhere / Required for sudo)".
- lintian: get all warnings down to zero or explicitly overridden with rationale.
- 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:
- Create Launchpad team
dangerousthings(or use existing). - Create PPA
authforge. - Generate signing key (gpg, store passphrase in 1Password / vault).
dput ppa:dangerousthings/authforge authforge_0.1.0-1_source.changes(note: Launchpad builds from source).- Wait for build, verify install on a fresh VM.
- 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:
- Spike: identify the cleanest hook in GNOME 46 / 47.
- Implement.
- Test against current Ubuntu LTS GNOME version.
- 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.ymlansible-role/defaults/main.ymlansible-role/templates/policy.conf.j2ansible-role/meta/main.yml
Tasks:
- Role tasks: add PPA, install packages, drop policy file at
/etc/authforge/policy.d/90-fleet.conf, restart daemon. - Variables for: enabled stacks, modes per stack, central credential storage on/off, default firstrun methods.
- Examples in
examples/playbook.yml. - 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.shtests/integration/Vagrantfile(or LXD/Multipass alternative)tests/integration/scenarios/*.bats(Bash Automated Testing System)
Scenarios:
- Fresh install → enroll Yubikey via CLI → set sudo to optional → sudo with key works.
- First-login flow: create user, set pending, simulate login, expect enrollment modal.
- Policy lockout prevention: set sudo to required without enrolling → expect rejection.
- Recovery flow: enroll, "lose" key (delete file), generate recovery, login with code, re-enroll.
- Upgrade in place: install 0.1.0, upgrade to 0.1.1, verify state preserved.
- 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.mddocs/user/admin-guide.mddocs/user/fleet-deployment.mddocs/user/recovery.mddocs/user/troubleshooting.md- Optional: small static site (mdBook or Docusaurus) at
https://authforge.dangerousthings.com
Tasks:
- Getting started: end-user view of "install + enroll my Yubikey + require it for sudo".
- Admin guide: managing users, policy, recovery.
- Fleet deployment: Ansible role usage, debconf preseed, central credential storage.
- Recovery: what to do if you lose your key.
- 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:
- Bump version to
1.0.0inCargo.toml,debian/changelog. - Tag
v1.0.0. - Push to PPA.
- Announce: blog post on dangerousthings.com, post to r/Ubuntu, r/yubikey, r/Linux, lobste.rs, Hacker News.
- 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-completionbefore claiming any phase complete. Run the acceptance checklist; do not advance without evidence. - Re-invoke
superpowers:writing-plansat 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 atdocs/plans/2026-04-26-authforge-design.md. Phase 0 is fully detailed; phases 1–18 are spec'd to a level sufficient to begin work. Each later phase should re-invokesuperpowers:writing-plansfor 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?