Files
authforge/docs/plans/2026-04-26-authforge-implementation.md

47 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

Phase Goal Approx. Effort Status
0 Repo, workspace, CI, packaging skeleton 23 days Done (2026-04-26)
1 Daemon: D-Bus interface stub, systemd unit, polkit rules 34 days Code complete (2026-04-27); smoke gates deferred
2 Daemon: storage layer (policy.d parser, pending flags, user db) 34 days Next — re-invoke superpowers:writing-plans to expand
3 Daemon: FIDO2 enrollment backend via ctap-hid-fido2 57 days Spec'd
4 Daemon: policy apply via pam-auth-update wrapper 4 days Spec'd
5 Daemon: lockout simulator 3 days Spec'd
6 PAM module: pam_authforge_pending.so (C) 3 days Spec'd
7 CLI: authforgectl 4 days Spec'd
8 GUI: app shell + Security Keys tab 68 days Spec'd
9 GUI: Policy tab + lockout-warning UX 4 days Spec'd
10 First-login flow: autostart entry + fullscreen modal 4 days Spec'd
11 TOTP support (PAM module + GUI tab) — feature flag 5 days Spec'd
12 Recovery flow (codes + emergency unlock) 4 days Spec'd
13 Debian packaging finalization (postinst, debconf, purge) 4 days Spec'd
14 Launchpad PPA build setup 2 days Spec'd
15 authforge-gnome-integration (Users panel shortcut) 3 days Spec'd
16 Ansible role dangerousthings.authforge 2 days Spec'd
17 Integration test harness (Multipass / LXD VM) 4 days Spec'd
18 User docs + onboarding site 3 days Spec'd
R v1.0 release 1 day

Total rough estimate: ~14 weeks of focused work for v1.0. Subsequent phases (Debian packaging, KDE port, RPM) are scoped in the design doc.

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 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?