47 KiB
authforge Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Note on plan structure: This is a multi-month project. Phase 0 is fully detailed at step granularity. Phases 1–18 are specified with goals, deliverables, file targets, and task lists at sufficient detail to begin implementation. Each subsequent phase should re-invoke
superpowers:writing-plansto expand its tasks to step-level granularity at the moment that phase begins (state from prior phases informs the expansion).
Goal: Ship authforge as an apt package on a PPA that turns U2F / FIDO2 passkey / TOTP MFA on Ubuntu desktops into a two-command install with a polished libadwaita GUI, sane policy, and a first-login enrollment flow.
Architecture: Rust system daemon (authforged) exposes a D-Bus interface. A GTK4 / libadwaita app and a CLI are thin clients. A small C PAM module (pam_authforge_pending.so) backstops first-login enrollment. PAM stack edits go through pam-auth-update. Policy is drop-in TOML in /etc/authforge/policy.d/. Full design in docs/plans/2026-04-26-authforge-design.md.
Tech Stack: Rust 2021 edition (daemon, CLI), GTK4 + libadwaita via gtk4-rs and libadwaita-rs (GUI), C (PAM module), libfido2 via ctap-hid-fido2 crate, D-Bus via zbus, polkit, debhelper-compat 13 packaging, sbuild for clean builds, GitHub Actions CI, Launchpad PPA for distribution.
Reference design: docs/plans/2026-04-26-authforge-design.md
Phase Roadmap
| Phase | Goal | Approx. Effort | Status |
|---|---|---|---|
| 0 | Repo, workspace, CI, packaging skeleton | 2–3 days | ✅ Done (2026-04-26) |
| 1 | Daemon: D-Bus interface stub, systemd unit, polkit rules | 3–4 days | ✅ Code complete (2026-04-27); smoke gates deferred |
| 2 | Daemon: storage layer (policy.d parser, pending flags, user db) | 3–4 days | Next — re-invoke superpowers:writing-plans to expand |
| 3 | Daemon: FIDO2 enrollment backend via ctap-hid-fido2 |
5–7 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 | 6–8 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-memoryAppStatewith fixtures. - polkit authorizer with
Permissive(env-var bypass) +System(realorg.freedesktop.PolicyKit1.Authority) modes. - All wire types in
common:Mode,Method,Transport,Credential,StackPolicy,StorageBackend,Storage,Firstrun,Policy,PendingFlag,Violation,PolicyApplyResult. AllSerialize + Deserialize + zvariant::Type. - systemd unit (
authforge-daemon.service) with hardening (NoNewPrivileges,ProtectSystem=full, scopedReadWritePaths). - D-Bus activation file + system policy XML.
- polkit policy XML with all 7 actions from the design doc.
authforge-daemon.postinstdrivingdaemon-reload+enable --now.- Packaging (
debian/rules+control) installs the new assets and depends ondbus,policykit-1. - CI installs
dbusso the new tests run there too. - 21 unit/integration tests across
common(7) anddaemon(14, including p2p D-Bus tests viatokio::net::UnixStream::pair).
Deferred until a Phase 14 smoke run on a clean Ubuntu VM:
debuild -us -uc -b5-deb output (needs libgtk-4-dev / libadwaita-1-dev / libfido2-dev / libpam0g-dev installed).busctl introspect/ polkit prompt manual smoke.systemctl status authforge-daemonafter install.
Plan deviations recorded in the commit:
PendingFlag.deadline_unixisu64with0= no deadline (zvariantTypederive does not supportOption<T>; sentinel matches the design doc's existingdeadline_hours = 0convention).- Sender → pid resolution for polkit
unix-processsubjects punts to early Phase 2 — Phase 1 passes pid 0, which the permissive authorizer ignores. Until pid resolution lands, the daemon is run withAUTHFORGE_POLKIT_BYPASS=1for any local smoke test (loudwarn!log on startup).
Phase 0: Repository Scaffolding (FULLY DETAILED)
Goal: Empty-but-valid repo that builds, passes a trivial test, and produces installable (no-op) deb packages from dpkg-buildpackage. Locks in the project structure so all later phases drop into known places.
Deliverables:
- Cargo workspace with crates:
daemon,cli,common(shared types). - Debian packaging skeleton in
debian/that builds the metapackage + 5 component debs. - C source tree for the PAM module under
pam/. - GUI placeholder under
gui/(Meson + Rust target). - GitHub Actions CI:
cargo build,cargo test,cargo clippy,debuild --no-sign,lintian. README.mdwith build instructions.
Task 0.1: Initialize git repo and license
Files:
- Create:
/home/work/VSCodeProjects/ubuntu_fido/.gitignore - Create:
/home/work/VSCodeProjects/ubuntu_fido/LICENSE(Apache 2.0) - Create:
/home/work/VSCodeProjects/ubuntu_fido/README.md
Step 1: Run git init in the project directory.
cd /home/work/VSCodeProjects/ubuntu_fido
git init -b main
Step 2: Write .gitignore:
target/
*.deb
*.buildinfo
*.changes
*.dsc
*.tar.xz
debian/.debhelper/
debian/files
debian/*.substvars
debian/*.debhelper.log
debian/authforge*/
build/
.vscode/
*.swp
Step 3: Write Apache 2.0 LICENSE file (standard text — copy from https://apache.org/licenses/LICENSE-2.0.txt). Author line: Copyright 2026 Dangerous Things, LLC.
Step 4: Write minimal README.md:
# authforge
Turnkey U2F / FIDO2 passkey / TOTP MFA for Ubuntu desktops.
## Install (end users)
sudo add-apt-repository ppa:dangerousthings/authforge
sudo apt install authforge
## Build from source
See `docs/BUILDING.md`.
## Status
Pre-alpha. See `docs/plans/` for design and roadmap.
Step 5: Initial commit.
git add .
git commit -m "Initial commit: license, gitignore, readme"
Task 0.2: Create Cargo workspace
Files:
- Create:
Cargo.toml(workspace root) - Create:
rust-toolchain.toml - Create:
.cargo/config.toml
Step 1: Write Cargo.toml:
[workspace]
resolver = "2"
members = ["common", "daemon", "cli"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
authors = ["Dangerous Things <ops@dangerousthings.com>"]
repository = "https://github.com/dangerousthings/authforge"
rust-version = "1.78"
[workspace.dependencies]
zbus = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
anyhow = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }
ctap-hid-fido2 = "3"
nix = { version = "0.28", features = ["user", "process", "fs"] }
Step 2: Write rust-toolchain.toml:
[toolchain]
channel = "1.78"
components = ["rustfmt", "clippy"]
profile = "minimal"
Step 3: Write .cargo/config.toml:
[build]
rustflags = ["-D", "warnings"]
Step 4: Verify with cargo metadata --format-version=1 > /dev/null (will fail until the member crates exist; that's the next task).
Task 0.3: Create common crate
Files:
- Create:
common/Cargo.toml - Create:
common/src/lib.rs - Create:
common/src/policy.rs - Create:
common/src/types.rs
Step 1: common/Cargo.toml:
[package]
name = "authforge-common"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
Step 2: common/src/lib.rs:
pub mod policy;
pub mod types;
Step 3: common/src/types.rs:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Disabled,
Optional,
Required,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Method {
Fido2,
Totp,
}
Step 4: common/src/policy.rs — empty module for now, populated in Phase 2:
// Policy parsing implemented in Phase 2.
Step 5: Write a failing test in common/src/types.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_serializes_to_lowercase() {
let m = Mode::Required;
let s = serde_json::to_string(&m).unwrap();
assert_eq!(s, "\"required\"");
}
}
This needs serde_json as a dev-dep; add [dev-dependencies] serde_json = { workspace = true } to common/Cargo.toml.
Step 6: Run cargo test -p authforge-common. Expected: PASS.
Step 7: Commit.
git add common Cargo.toml rust-toolchain.toml .cargo
git commit -m "Add common crate with shared Mode and Method types"
Task 0.4: Create daemon crate skeleton
Files:
- Create:
daemon/Cargo.toml - Create:
daemon/src/main.rs
Step 1: daemon/Cargo.toml:
[package]
name = "authforged"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "authforged"
path = "src/main.rs"
[dependencies]
authforge-common = { path = "../common" }
zbus = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
Step 2: daemon/src/main.rs:
use anyhow::Result;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("authforged {} starting", env!("CARGO_PKG_VERSION"));
// D-Bus service registration in Phase 1.
Ok(())
}
Step 3: cargo build -p authforged. Expected: success.
Step 4: Commit.
git add daemon
git commit -m "Add daemon crate skeleton"
Task 0.5: Create cli crate skeleton
Files:
- Create:
cli/Cargo.toml - Create:
cli/src/main.rs
Step 1: cli/Cargo.toml:
[package]
name = "authforgectl"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "authforgectl"
path = "src/main.rs"
[dependencies]
authforge-common = { path = "../common" }
clap = { workspace = true }
anyhow = { workspace = true }
zbus = { workspace = true }
tokio = { workspace = true }
Step 2: cli/src/main.rs:
use anyhow::Result;
use clap::Parser;
#[derive(Parser)]
#[command(name = "authforgectl", version, about = "Manage authforge configuration")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(clap::Subcommand)]
enum Cmd {
Status,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Cli::parse();
match args.cmd {
Cmd::Status => println!("authforgectl: not yet implemented"),
}
Ok(())
}
Step 3: cargo build (whole workspace). Expected: success.
Step 4: Commit.
git add cli
git commit -m "Add cli crate skeleton"
Task 0.6: Create PAM module skeleton
Files:
- Create:
pam/Makefile - Create:
pam/pam_authforge_pending.c
Step 1: pam/pam_authforge_pending.c:
#define PAM_SM_AUTH
#include <security/pam_modules.h>
#include <security/pam_ext.h>
#include <stdio.h>
PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags,
int argc, const char **argv) {
(void)flags; (void)argc; (void)argv;
pam_syslog(pamh, LOG_INFO, "authforge_pending: stub - allowing");
return PAM_IGNORE; /* implemented in Phase 6 */
}
PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags,
int argc, const char **argv) {
(void)pamh; (void)flags; (void)argc; (void)argv;
return PAM_SUCCESS;
}
Step 2: pam/Makefile:
CFLAGS ?= -Wall -Wextra -Werror -fPIC -O2
LIBDIR ?= /usr/lib/$(shell dpkg-architecture -qDEB_HOST_MULTIARCH)/security
pam_authforge_pending.so: pam_authforge_pending.c
$(CC) $(CFLAGS) -shared -o $@ $< -lpam
install: pam_authforge_pending.so
install -D -m 0644 pam_authforge_pending.so $(DESTDIR)$(LIBDIR)/pam_authforge_pending.so
clean:
rm -f pam_authforge_pending.so
.PHONY: install clean
Step 3: Build it:
cd pam && make && cd ..
Expected: produces pam/pam_authforge_pending.so (~10 KB). Requires libpam0g-dev (will be in build-deps).
Step 4: Commit.
git add pam
git commit -m "Add PAM module stub"
Task 0.7: Create GUI placeholder
Files:
- Create:
gui/Cargo.toml - Create:
gui/src/main.rs - Create:
gui/data/io.dangerousthings.AuthForge.desktop - Create:
gui/data/io.dangerousthings.AuthForge.svg(1KB placeholder icon)
Step 1: Add gui to workspace members in root Cargo.toml.
Step 2: gui/Cargo.toml:
[package]
name = "authforge-gui"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "authforge"
path = "src/main.rs"
[dependencies]
gtk = { package = "gtk4", version = "0.8" }
adw = { package = "libadwaita", version = "0.6" }
authforge-common = { path = "../common" }
anyhow = { workspace = true }
Step 3: gui/src/main.rs — minimal libadwaita "Hello" window:
use adw::prelude::*;
use gtk::glib;
const APP_ID: &str = "io.dangerousthings.AuthForge";
fn main() -> glib::ExitCode {
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(|app| {
let win = adw::ApplicationWindow::builder()
.application(app)
.default_width(720)
.default_height(540)
.title("Authentication")
.build();
let header = adw::HeaderBar::new();
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
let status = adw::StatusPage::builder()
.icon_name("dialog-password-symbolic")
.title("Authentication setup")
.description("Phase 0 placeholder. Real UI in Phase 8.")
.build();
toolbar.set_content(Some(&status));
win.set_content(Some(&toolbar));
win.present();
});
app.run()
}
Step 4: .desktop file at gui/data/io.dangerousthings.AuthForge.desktop:
[Desktop Entry]
Name=Authentication
GenericName=Security Keys & MFA
Comment=Manage U2F/FIDO2 keys and authentication policy
Exec=authforge
Icon=io.dangerousthings.AuthForge
Terminal=false
Type=Application
Categories=Settings;Security;
Keywords=mfa;u2f;fido2;passkey;totp;security;
StartupNotify=true
Step 5: Placeholder SVG icon — a simple gray key glyph; sourced from any open icon set under a compatible license. Save at gui/data/io.dangerousthings.AuthForge.svg.
Step 6: Build (assumes GTK4 / libadwaita dev packages installed: libgtk-4-dev, libadwaita-1-dev).
cargo build -p authforge-gui
Expected: success.
Step 7: Commit.
git add gui Cargo.toml
git commit -m "Add GUI crate skeleton with libadwaita placeholder window"
Task 0.8: Debian packaging skeleton
Files:
- Create:
debian/changelog - Create:
debian/control - Create:
debian/copyright - Create:
debian/rules - Create:
debian/source/format - Create:
debian/compat(or usedebhelper-compat (= 13)in control) - Create:
debian/authforge.install - Create:
debian/authforge-daemon.install - Create:
debian/authforge-daemon.service - Create:
debian/authforge-pam.install - Create:
debian/authforge-cli.install - Create:
debian/authforge-gui.install
Step 1: debian/changelog:
authforge (0.1.0-1) UNRELEASED; urgency=low
* Initial packaging skeleton.
-- Dangerous Things <ops@dangerousthings.com> Sun, 26 Apr 2026 12:00:00 +0000
Step 2: debian/source/format:
3.0 (native)
Step 3: debian/control — declares all 5 binary packages and their relationships:
Source: authforge
Section: admin
Priority: optional
Maintainer: Dangerous Things <ops@dangerousthings.com>
Build-Depends:
debhelper-compat (= 13),
cargo,
rustc (>= 1.78),
libpam0g-dev,
libgtk-4-dev,
libadwaita-1-dev,
libfido2-dev,
pkg-config
Standards-Version: 4.6.2
Homepage: https://github.com/dangerousthings/authforge
Vcs-Browser: https://github.com/dangerousthings/authforge
Vcs-Git: https://github.com/dangerousthings/authforge.git
Package: authforge
Architecture: any
Depends: authforge-daemon (= ${binary:Version}),
authforge-pam (= ${binary:Version}),
authforge-cli (= ${binary:Version}),
${misc:Depends}
Recommends: authforge-gui (= ${binary:Version})
Suggests: authforge-gnome-integration
Description: Turnkey FIDO2/U2F/TOTP MFA for Ubuntu (metapackage)
Installs the full authforge stack: daemon, PAM module, CLI, and
(if recommended) GUI.
Package: authforge-daemon
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-u2f, libfido2-1
Description: System daemon for authforge MFA management
Provides the privileged D-Bus service that orchestrates enrollment,
policy edits, and lockout-prevention checks.
Package: authforge-pam
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-runtime
Description: PAM module backstopping authforge first-login enrollment
Refuses authentication when /var/lib/authforge/pending/<user> exists
and the user has not completed first-login MFA setup.
Package: authforge-cli
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}, authforge-daemon
Description: CLI for authforge (admin and fleet)
Configure policy, enroll on behalf of users, generate recovery codes.
Package: authforge-gui
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}, authforge-daemon
Description: GTK4/libadwaita UI for authforge
End-user-facing settings panel for enrollment and policy.
Step 4: debian/rules (executable, mode 0755):
#!/usr/bin/make -f
export DH_VERBOSE = 1
export CARGO_HOME = $(CURDIR)/target/cargo-home
%:
dh $@
override_dh_auto_build:
cargo build --release --workspace
$(MAKE) -C pam
override_dh_auto_install:
# Daemon
install -D -m 0755 target/release/authforged \
debian/authforge-daemon/usr/sbin/authforged
# CLI
install -D -m 0755 target/release/authforgectl \
debian/authforge-cli/usr/bin/authforgectl
# GUI
install -D -m 0755 target/release/authforge \
debian/authforge-gui/usr/bin/authforge
install -D -m 0644 gui/data/io.dangerousthings.AuthForge.desktop \
debian/authforge-gui/usr/share/applications/io.dangerousthings.AuthForge.desktop
install -D -m 0644 gui/data/io.dangerousthings.AuthForge.svg \
debian/authforge-gui/usr/share/icons/hicolor/scalable/apps/io.dangerousthings.AuthForge.svg
# PAM
$(MAKE) -C pam install DESTDIR=$(CURDIR)/debian/authforge-pam
override_dh_auto_test:
cargo test --workspace --release
override_dh_auto_clean:
cargo clean
$(MAKE) -C pam clean
Step 5: Create empty .install files for each package (the dh_install magic; populated in Phase 13). For now they can be empty or omitted since debian/rules does manual installs.
Step 6: debian/copyright — boilerplate Apache-2.0 copyright in machine-readable format. Generate from cargo about later, hand-roll for now.
Step 7: Build the deb:
sudo apt install -y debhelper devscripts dh-make sbuild lintian \
libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config
debuild -us -uc -b
Expected: produces 5 .deb files in the parent directory. Lintian may complain (we'll silence one warning at a time in later phases).
Step 8: Verify install + remove on a throwaway VM (Multipass shell or LXD container).
sudo dpkg -i ../authforge*.deb || sudo apt -f install
sudo systemctl status authforge.service # will fail until Phase 1 ships unit
sudo apt purge authforge*
Step 9: Commit.
git add debian
git commit -m "Add debhelper-13 packaging skeleton (5 component debs)"
Task 0.9: GitHub Actions CI
Files:
- Create:
.github/workflows/ci.yml
Step 1: Workflow file:
name: CI
on:
push: { branches: [main] }
pull_request:
jobs:
rust:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.78
with: { components: rustfmt, clippy }
- run: sudo apt-get update && sudo apt-get install -y libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
deb:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y debhelper devscripts lintian libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config cargo rustc
- run: debuild -us -uc -b
- run: lintian --info --display-info ../authforge*.changes || true # warnings allowed in Phase 0
Step 2: Push and verify both jobs go green.
Step 3: Commit.
git add .github
git commit -m "Add GitHub Actions CI for cargo + deb"
Task 0.10: Phase 0 acceptance gate
Verify all of the following before declaring Phase 0 complete:
cargo build --workspace --releasesucceeds.cargo test --workspacesucceeds (1 test incommon).cargo clippy --workspace -- -D warningsis clean.make -C pamproducespam_authforge_pending.so.debuild -us -uc -bproduces 5.debfiles.- On an Ubuntu 24.04 LXD container:
sudo dpkg -i ../authforge*.debsucceeds,sudo apt purge authforge*cleanly removes. - CI is green on
main.
If all check, tag v0.1.0-scaffolding:
git tag -a v0.1.0-scaffolding -m "Phase 0: repository scaffolding complete"
Phase 1: Daemon — D-Bus Interface, systemd, polkit
Goal: authforged registers on the system bus as io.dangerousthings.AuthForge, gets started by systemd, and enforces polkit-mediated method-level authorization. Methods are stubs returning sensible test data.
Re-invoke superpowers:writing-plans at start of this phase to expand to step granularity.
Files to create:
daemon/src/dbus.rs— zbusinterfacedefinitiondaemon/src/state.rs— in-memory state shelldebian/authforge-daemon.service— systemd unitdebian/io.dangerousthings.AuthForge.conf— D-Bus policydebian/io.dangerousthings.AuthForge.service— D-Bus activation filedebian/io.dangerousthings.AuthForge.policy— polkit actionsdebian/authforge-daemon.postinst— enable + start unit
D-Bus interface skeleton:
#[zbus::interface(name = "io.dangerousthings.AuthForge1")]
impl AuthForge {
async fn list_credentials(&self, user: String) -> zbus::fdo::Result<Vec<Credential>> { ... }
async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result<Credential> { ... }
async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> { ... }
async fn enroll_other(&self, user: String, nickname: String) -> zbus::fdo::Result<Credential> { ... }
async fn get_policy(&self) -> zbus::fdo::Result<Policy> { ... }
async fn set_policy(&self, p: Policy) -> zbus::fdo::Result<PolicyApplyResult> { ... }
async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> { ... }
async fn clear_pending_flag(&self, user: String) -> zbus::fdo::Result<()> { ... }
async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result<String> { ... }
}
polkit actions (XML in io.dangerousthings.AuthForge.policy):
| Action | Default for active session |
|---|---|
io.dangerousthings.AuthForge.enroll-own |
auth_self_keep |
io.dangerousthings.AuthForge.remove-own |
auth_self_keep |
io.dangerousthings.AuthForge.enroll-other |
auth_admin_keep |
io.dangerousthings.AuthForge.set-policy |
auth_admin_keep |
io.dangerousthings.AuthForge.set-pending |
auth_admin_keep |
io.dangerousthings.AuthForge.clear-pending |
auth_admin_keep |
io.dangerousthings.AuthForge.generate-recovery |
auth_admin_keep |
Tasks:
- Add zbus interface module with stubs returning fixture data.
- Wire up
connection.request_name("io.dangerousthings.AuthForge"). - Write systemd unit (
Type=dbus,BusName=io.dangerousthings.AuthForge,User=root). - Write D-Bus system policy (allow root to own; allow
at_consoleto call read methods; restrict write methods). - Write polkit policy XML; daemon checks each method against polkit before executing.
- Wire postinst:
systemctl daemon-reload && systemctl enable --now authforge.service. - Test:
busctl call io.dangerousthings.AuthForge /io/dangerousthings/AuthForge io.dangerousthings.AuthForge1 ListCredentials s "$USER"returns the fixture data. - Test: as non-root,
SetPolicytriggers polkit prompt.
Acceptance: D-Bus introspection works (busctl introspect ...), all 9 methods are callable as stubs, polkit prompts appear at correct times.
Phase 2: Daemon — Storage Layer
Goal: Real I/O to /etc/authforge/policy.d/, /var/lib/authforge/pending/, and ~/.config/Yubico/u2f_keys (or /etc/u2f_mappings). Replaces fixtures from Phase 1.
Files:
common/src/policy.rs— fully implemented TOML parser with last-key-wins mergedaemon/src/storage/policy.rs— read/write policy.d/ dir, inotify watcherdaemon/src/storage/pending.rs— read/write pending flagsdaemon/src/storage/credentials.rs— manipulate u2f_keys files (per-user vs central)daemon/src/storage/userdb.rs—/var/lib/authforge/users.dbcache (sqlite viarusqlite)
Tasks:
- Implement
Policy::load_from_dir(path)with merge semantics. TDD: write 3 test fixture dirs, assert merge order. - Implement
Policy::save(stack, mode, methods)writing to50-local.confwhile preserving90-fleet.conf. - Implement inotify watcher; daemon emits D-Bus signal
PolicyChangedon change. - Implement pending flag (JSON file) read/write/clear.
- Implement credential file manipulation (parse
pamu2fcfgline format, append/remove by credId). - Implement userdb cache: tracks all users with any enrolled credentials for fast lockout simulation lookup.
- Wire all storage modules into the D-Bus methods.
Acceptance: Round-trip every state via D-Bus; verify file contents on disk match expected format precisely (pam_u2f reads them).
Phase 3: Daemon — FIDO2 Enrollment Backend
Goal: Real device enrollment using ctap-hid-fido2. Touch / PIN prompts. Emits per-step events over D-Bus so the GUI can show progress.
Files:
daemon/src/fido/mod.rsdaemon/src/fido/enroll.rs— credential creationdaemon/src/fido/devices.rs— discovery, hot-plugdaemon/src/fido/format.rs— convert to pam_u2f-compatiblekeyHandle:userKey:counterstrings
Tasks:
- Add
ctap-hid-fido2 = "3"to daemon deps. - Device discovery loop (poll every 500ms; for NFC, depend on PCSC stack via
pcsccrate). - Enrollment:
make_credentialwith rp_id"pam://localhost", user_id from username hash, optionaluvflag. - Emit D-Bus signals
DeviceFound { name, transport },TouchRequired { device },EnrollmentSucceeded { credential },EnrollmentFailed { reason }. - Convert resulting credential into pam_u2f line format.
- Write integration test using
libfido2's software backend (FIDO_SW_OPENSSL).
Acceptance: With a real Yubikey plugged in, busctl call ... EnrollOwn s "alice" s "Yellow Yubikey" succeeds, the line appears in ~alice/.config/Yubico/u2f_keys, and pam_u2f validates against it.
Phase 4: Daemon — Policy Apply via pam-auth-update
Goal: When SetPolicy is called, daemon updates /etc/pam.d/* correctly and idempotently using pam-auth-update.
Files:
daemon/src/policy_apply/mod.rsdaemon/src/policy_apply/profile.rs— generates the pam-configs file dynamicallydebian/authforge-pam.install— ships the static pam-configs profiledebian/authforge-daemon.postinst— runspam-auth-update --packageon install
Approach: The pam-configs profile is templated. Daemon writes the appropriate variant to /usr/share/pam-configs/authforge based on which stacks are required, then invokes pam-auth-update --package. (Alternative: ship multiple profiles, enable/disable each. The first approach is simpler.)
Tasks:
- Write template renderer.
- Implement
apply()that: writes new profile → callspam-auth-update --package→ checks exit code. - Add rollback: stash previous profile before write; restore + re-run pam-auth-update on failure.
- Wire into
SetPolicyD-Bus method (which has already passed the lockout simulator, see Phase 5).
Acceptance: Setting sudo to required in policy actually causes sudo to demand a security key in a fresh shell.
Phase 5: Daemon — Lockout Simulator
Goal: Before any policy write, dry-run the new policy against currently-known users and refuse changes that would lock anyone out.
Files:
daemon/src/lockout/mod.rsdaemon/src/lockout/simulate.rs
Approach: For each affected stack and each user in users.db plus the active session's user:
- Determine which methods the policy would require.
- Check user's enrolled credentials.
- If no enrolled method satisfies the requirement, the user would be locked out.
Returns a list of (user, stack, reason) violations.
Tasks:
- Implement simulator as pure function over
(NewPolicy, UserDb, ActiveUser) -> Vec<Violation>. - Heavy unit-test coverage (every plausible combo).
- Wire into
SetPolicy: if violations and noforceflag, return error with violation list. - Optional
pam_test_authenticate-based real check: spawn a sandboxed PAM run as the affected user with mock credentials; this is a v1.1 hardening.
Acceptance: Cannot accidentally lock yourself out via GUI or CLI without explicit --force.
Phase 6: PAM Module — pam_authforge_pending.so
Goal: The C module that backstops first-login enrollment. Replaces the Phase 0 stub.
Files:
pam/pam_authforge_pending.c— full implementationpam/Makefile— already exists from Phase 0
Behavior:
pam_sm_authenticate:- Get
PAM_USER. - Check for
/var/lib/authforge/pending/<user>(usestat(), root-owned, mode 0644, no setuid surprises). - If exists:
a. If recovery code provided and matches
/var/lib/authforge/recovery/<user>(8-digit, 24h validity), accept and re-flag for re-enrollment. b. Otherwise returnPAM_AUTH_ERRwith conv message: "Account setup incomplete. Please complete enrollment in the Authentication app." - If no flag: return
PAM_IGNORE.
- Get
pam_sm_setcred:PAM_SUCCESS.
Tasks:
- Implement file checks with proper path sanitization (reject usernames with
/,\0,..). - Implement recovery code constant-time comparison (
memcmpis not enough — use a constant-time helper). - Add structured logging via
pam_syslog. - Test with
pamtester:sudo touch /var/lib/authforge/pending/alice pamtester authforge alice authenticate # expect: failure with our message sudo rm /var/lib/authforge/pending/alice pamtester authforge alice authenticate # expect: success (PAM_IGNORE → other modules carry it)
Acceptance: A user with a pending flag cannot log in; daemon clearing the flag immediately allows login.
Phase 7: CLI — authforgectl
Goal: Full admin/fleet CLI. Talks D-Bus to the daemon. No magic — every command is a thin wrapper around a D-Bus call.
Files:
cli/src/main.rs— replaces Phase 0 skeletoncli/src/commands/*.rs— one file per top-level subcommand
Subcommands:
authforgectl status
authforgectl enroll [--user USER] [--nickname NAME]
authforgectl list [--user USER]
authforgectl remove [--user USER] CRED_ID
authforgectl policy show
authforgectl policy set <stack> <mode> [--methods METHOD,...]
authforgectl policy apply [--force-i-know-what-im-doing]
authforgectl policy validate
authforgectl pending set USER [--methods METHOD,...]
authforgectl pending clear USER
authforgectl pending list
authforgectl recovery generate USER
authforgectl recovery list USER
Output: human-readable by default; --json flag for machine-parseable output (essential for Ansible integration).
Tasks: straightforward; one task per subcommand.
Acceptance: All flows from the design doc are achievable from CLI alone (headless server use case).
Phase 8: GUI — Shell + Security Keys Tab
Goal: Replace Phase 0 placeholder with the real adaptive libadwaita UI for credential management.
Files:
gui/src/app.rs—adw::Applicationsetupgui/src/window.rs— main window withAdwViewSwitchergui/src/views/keys.rs— Security Keys tab (list, add, remove)gui/src/dbus_client.rs— async wrapper around zbus clientgui/data/resources.gresource.xml— bundled UI files (XML/Blueprint)- Various
.uifiles (use Blueprint viablueprint-compilerif available; fallback to XML)
Tasks:
- Move from
ApplicationWindowtoAdwApplicationWindow+AdwViewStack+AdwViewSwitcher. - Implement
KeysViewwithAdwPreferencesPagecontaining oneAdwActionRowper credential. - Implement enrollment dialog:
AdwDialogwithAdwStatusPageshowing icon based on detected device type (USB / NFC / implant). - Listen for D-Bus signals
DeviceFound,TouchRequired, etc., update dialog UI in response. - Empty state when no credentials.
- Smoke test on a real Ubuntu 24.04 GNOME session: enroll a Yubikey, see it appear in the list, remove it, see it disappear.
Acceptance: A user can do all credential management from this tab without a terminal.
Phase 9: GUI — Policy Tab
Goal: The "where is MFA required" toggles, with the lockout warning UX baked in.
Files:
gui/src/views/policy.rsgui/src/views/policy_advanced.rs— pkexec-gated full list
Tasks:
- Three master toggles (gdm-password / sudo / sshd) as
AdwComboRowwith three options. - "Advanced…" expander triggers polkit prompt; on success, shows full list including any auto-detected stacks.
- Pre-apply lockout simulation: button labeled "Apply" calls
policy validatefirst; if violations, show inlineAdwBannerwith "fix it" suggestions. - After apply, show toast confirming change.
- Visual indication of which stacks are currently in each mode.
Acceptance: Cannot lock self out via GUI; admin can configure ssh policy via the advanced expander.
Phase 10: First-Login Flow
Goal: End-to-end demo of Flow C from the design doc.
Files:
gui/src/views/firstrun.rs— fullscreen modal modegui/data/authforge-firstrun.desktop— autostart entrydebian/authforge-gui.install— install autostart entry to/etc/xdg/autostart/gui/src/main.rs— handle--first-runCLI flag
Tasks:
- Add
--first-runflag toauthforgebinary; alters startup to fullscreen modal mode (no header bar, no decorations, sticky-on-top). - On startup in first-run mode, query daemon for
pending_flag(current_user); if absent, exit immediately. - Show welcome page + run enrollment flow.
- On successful enrollment, call
clear_pending_flagvia D-Bus, exit cleanly. - Watchdog: if no successful enrollment within 60s of any user input, call
gnome-session-quit --logout --no-prompt. - Autostart
.desktopfile withOnlyShowIn=GNOMEandX-GNOME-Autostart-Phase=Initialization.
Test (manual, in VM):
useradd -m testuser,passwd testuser(set tempPW),chage -d 0 testuser.sudo authforgectl pending set testuser --methods fido2.- Log out, log in as testuser → forced password change → enrollment modal appears → enroll Yubikey → modal closes → desktop usable.
- Verify: subsequent SSH-as-testuser-without-key is blocked by
pam_authforge_pending.soif pending wasn't cleared.
Acceptance: End-to-end flow C works in a VM smoke test.
Phase 11: TOTP Support (Feature Flag)
Goal: TOTP as a third authenticator method, gated behind a build-time Cargo feature.
Files:
daemon/Cargo.toml— addtotpfeaturedaemon/src/totp/mod.rs— secret generation, recovery codesgui/src/views/totp.rs— QR code display, secret enrollmentdebian/authforge-daemon.install— conditionally install pam-configs entry for TOTPdebian/control—Recommends: libpam-google-authenticatorwhen feature enabled
Tasks:
- Cargo feature
totp(default-on). - Generate base32 secret (160 bits per RFC 6238).
- Render QR code via
qrcodecrate; show in modal with copyable secret string. - Generate 8 recovery codes (8 digits each, stored hashed via Argon2id).
- Wire into pam-auth-update profile: when TOTP-required stack is set, profile includes
pam_google_authenticator.sowithsecret=/etc/google-authenticator/${USER}(file written by daemon during enrollment). - GUI tab visible only when
totpfeature is enabled at build (use#[cfg(feature = "totp")]).
Acceptance: Can enroll TOTP, scan QR into Aegis, log into sudo using TOTP token.
Phase 12: Recovery Flow
Goal: Lost-key path. Admin generates code, user uses it once.
Files:
daemon/src/recovery.rsgui/src/views/recovery.rs
Tasks:
generate_recovery_code(user)writes/var/lib/authforge/recovery/<user>(root-owned, 0600) containing Argon2id hash + expiry timestamp.- PAM module checks recovery code at password prompt (already added in Phase 6); on success, removes recovery file and writes a
/var/lib/authforge/pending/<user>flag withre_enroll = true. - CLI:
authforgectl recovery generate aliceoutputs the 8-digit code. - GUI: "Recovery" tab shows generate / list / revoke.
- Print-PDF feature:
gtk_print_unix_dialogwith template containing user's TOTP secret (opt-in only) and recovery codes.
Acceptance: A user with no working key can use a recovery code to log in once and be re-enrolled.
Phase 13: Debian Packaging Finalization
Goal: All packages install cleanly, postinst/prerm/postrm scripts handle every state transition correctly, debconf preseed works.
Files:
debian/authforge-daemon.{postinst,prerm,postrm}debian/authforge-pam.{postinst,prerm,postrm}debian/authforge.config— debconf scriptdebian/authforge.templates— debconf templates
Tasks:
- postinst: enable + start daemon; run
pam-auth-update --package; if first-time install and debconf provided answers, write initial/etc/authforge/policy.d/00-debconf.conf. - prerm: disable PAM enforcement (
pam-auth-update --package --remove) so removal can't lock anyone out. - postrm purge: rm
/etc/authforge/,/var/lib/authforge/. - debconf templates: ask "Default policy? (None / Optional everywhere / Required for sudo)".
- lintian: get all warnings down to zero or explicitly overridden with rationale.
- Test matrix: install → upgrade (from 0.0.x → 0.1.0) → remove → purge → reinstall, on Ubuntu 22.04 and 24.04.
Acceptance: piuparts authforge_0.1.0-1_amd64.deb passes.
Phase 14: Launchpad PPA Setup
Goal: End users can sudo add-apt-repository ppa:dangerousthings/authforge.
Tasks:
- Create Launchpad team
dangerousthings(or use existing). - Create PPA
authforge. - Generate signing key (gpg, store passphrase in 1Password / vault).
dput ppa:dangerousthings/authforge authforge_0.1.0-1_source.changes(note: Launchpad builds from source).- Wait for build, verify install on a fresh VM.
- Document signing key fingerprint in README.
Acceptance: Two-command install works on a clean Ubuntu 24.04 VM.
Phase 15: authforge-gnome-integration
Goal: Optional shortcut deb that makes authforge discoverable from gnome-control-center Users panel.
Files:
- New source tree under
gnome-integration/(separate Cargo workspace member or pure-data deb) debian/authforge-gnome-integration.install
Approach (research required during this phase): GNOME 46+ allows panel extensions via dbus-activated services. Ship a small JS/GJS extension or a dynamic library loaded by gnome-control-center. If neither stable approach exists, ship a .desktop file under /usr/share/applications/ tagged with X-GNOME-Settings-Panel=user-accounts or similar — exact mechanism is GNOME-version-dependent.
Tasks:
- Spike: identify the cleanest hook in GNOME 46 / 47.
- Implement.
- Test against current Ubuntu LTS GNOME version.
- Pin Recommends to specific gnome-control-center major version range.
Acceptance: Opening Settings → Users → some-user shows a "Configure security…" link that launches authforge --user some-user with proper polkit context.
Phase 16: Ansible Role
Goal: dangerousthings.authforge role on Ansible Galaxy that handles install, policy, and enrollment for fleet deployments.
Files (in a separate ansible-role tree, possibly its own repo):
ansible-role/tasks/main.ymlansible-role/defaults/main.ymlansible-role/templates/policy.conf.j2ansible-role/meta/main.yml
Tasks:
- Role tasks: add PPA, install packages, drop policy file at
/etc/authforge/policy.d/90-fleet.conf, restart daemon. - Variables for: enabled stacks, modes per stack, central credential storage on/off, default firstrun methods.
- Examples in
examples/playbook.yml. - Publish to Galaxy.
Acceptance: A bare Ubuntu 24.04 VM, after running the role, has the package installed and policy applied.
Phase 17: Integration Test Harness
Goal: Repeatable end-to-end test that proves all major flows on a clean VM.
Files:
tests/integration/run.shtests/integration/Vagrantfile(or LXD/Multipass alternative)tests/integration/scenarios/*.bats(Bash Automated Testing System)
Scenarios:
- Fresh install → enroll Yubikey via CLI → set sudo to optional → sudo with key works.
- First-login flow: create user, set pending, simulate login, expect enrollment modal.
- Policy lockout prevention: set sudo to required without enrolling → expect rejection.
- Recovery flow: enroll, "lose" key (delete file), generate recovery, login with code, re-enroll.
- Upgrade in place: install 0.1.0, upgrade to 0.1.1, verify state preserved.
- Purge: install, configure, purge, expect clean system.
Use umockdev to simulate USB devices in CI; require real hardware for nightly extended runs.
Acceptance: All 6 scenarios run green on PR.
Phase 18: User Docs
Files:
docs/user/getting-started.mddocs/user/admin-guide.mddocs/user/fleet-deployment.mddocs/user/recovery.mddocs/user/troubleshooting.md- Optional: small static site (mdBook or Docusaurus) at
https://authforge.dangerousthings.com
Tasks:
- Getting started: end-user view of "install + enroll my Yubikey + require it for sudo".
- Admin guide: managing users, policy, recovery.
- Fleet deployment: Ansible role usage, debconf preseed, central credential storage.
- Recovery: what to do if you lose your key.
- Troubleshooting: common failure modes (lockout via misconfig, NFC not detected, polkit prompt not appearing).
Acceptance: A new user can go from "I read the README" to "I have MFA on sudo" in under 10 minutes.
R: v1.0 Release
Tasks:
- Bump version to
1.0.0inCargo.toml,debian/changelog. - Tag
v1.0.0. - Push to PPA.
- Announce: blog post on dangerousthings.com, post to r/Ubuntu, r/yubikey, r/Linux, lobste.rs, Hacker News.
- Open feedback channel (GitHub Discussions).
Cross-cutting concerns (apply to every phase)
- Frequent commits. One logical change per commit. Use conventional commit prefixes (
feat:,fix:,test:,docs:,refactor:,chore:). - TDD where mechanical. Daemon logic, CLI argument parsing, policy parsing — all must be test-first. UI work is exempt; it gets manual smoke + screenshot review.
- No untested code paths in lockout simulator or PAM module. These are the lockout-risk surface — every branch covered.
- YAGNI. Resist the urge to build "future" extension points. The architecture allows KDE / Debian / Fedora additions cleanly; do not preemptively code abstractions for them in v1.
- Use
superpowers:verification-before-completionbefore claiming any phase complete. Run the acceptance checklist; do not advance without evidence. - Re-invoke
superpowers:writing-plansat the start of each phase ≥ 1 to expand its task list to step granularity using state from prior phases.
Risks and contingencies
| Risk | Likelihood | Impact | Plan |
|---|---|---|---|
gnome-control-center Users panel hook (Phase 15) has no stable extension point. |
Medium | Low | Ship without the integration deb if needed; standalone app remains fully functional. Discoverability only. |
ctap-hid-fido2 doesn't expose hot-plug events |
Low | Medium | Fall back to polling (already planned). Add event support upstream if needed. |
| Real-world Ubuntu PAM stacks already include third-party MFA modules (sssd, libpam-yubico) | Medium | Medium | postinst detects + warns; admin must explicitly accept import path. Document migration. |
| Launchpad PPA build fails for non-amd64 arches | Low | Low | Restrict architectures in debian/control initially (Architecture: amd64 arm64); expand after demand. |
| First-login modal can be killed via Ctrl-Alt-F2 → ssh → kill | Medium | Low | PAM module backstop (Phase 6) catches this — sshd login also blocked while pending flag exists. Document. |
Execution Handoff
When the user returns:
Plan complete and saved to
docs/plans/2026-04-26-authforge-implementation.md. The companion design doc is atdocs/plans/2026-04-26-authforge-design.md. Phase 0 is fully detailed; phases 1–18 are spec'd to a level sufficient to begin work. Each later phase should re-invokesuperpowers:writing-plansfor step-level expansion as it begins.Two execution options:
1. Subagent-driven (this session) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Parallel session — Open a new session in a worktree using
superpowers:executing-plans, batch execution with checkpoints.Which approach?