Files
authforge/docs/plans/2026-04-26-authforge-implementation.md
michael 91731150d5 docs: phase 12 backend closeout notes
Flips Phase 12 status to  Code complete; bumps progress to 12/19 (63%).
Closeout block summarizes the 8 implementation tasks, lists all new
artifacts, calls out the 80-test count (was 60), and flags the Phase 14
deferred verifications (libargon2-dev compile + pamtester smoke).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:53:09 -07:00

65 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 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 Code complete (2026-04-27) — backend + PAM landed; GUI tab queued in Phase 9 lane
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: 12 of 19 phases code-complete (63%). 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 cover cd <worktree> && …, git -C <worktree> …, and broad cargo * / make *. Two subagents (Phase 15 gnome + Phase 16 Ansible) finished cleanly in 7 minutes wall-clock for ~250 lines of YAML/desktop config combined.
  • They don't work when the harness sandbox blocks desktop-file-validate / dpkg-checkbuilddeps / ansible-lint — the Phase 15+16 agents both reported these as "skipped, hand-checked." Verification slips through to manual review of the diff. Acceptable for data/config phases; risky for code phases without strong test coverage.
  • Phase 4+5 had to land together because both modify the SetPolicy code path; splitting them creates a window where SetPolicy applies without a lockout pre-check.
  • Worktrees share target/ if and only if you don't isolate via CARGO_TARGET_DIR. We didn't need to — each git worktree add produces its own working tree, so cargo target dirs are per-worktree by default. Concurrent cargo runs across worktrees would still race on the global ~/.cargo/registry cargo lock, but in practice that's a brief synchronization, not a hang.

Phase 1 closeout notes (2026-04-27)

Phase 1 expanded to step granularity at 2026-04-26-phase-1-dbus.md. 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 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. GUI tab is queued in the Phase 9 lane (waiting on 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 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?