Files
authforge/docs/plans/2026-04-26-authforge-implementation.md
2026-04-27 08:42:19 -07:00

1398 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](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 | ✅ **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 | ✅ **Done** (2026-04-27, tag `v0.4.0-parallel`) |
| 5 | Daemon: lockout simulator | 3 days | ✅ **Done** (bundled with Phase 4) |
| 6 | PAM module: `pam_authforge_pending.so` (C) | 3 days | ✅ **Code complete** (2026-04-27); pamtester smoke recipe in pam/TESTING.md |
| 7 | CLI: `authforgectl` | 4 days | ✅ **Code complete** (2026-04-27); 14 subcommands wired |
| 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 | ✅ **Done** (2026-04-27, parallel subagent) |
| 16 | Ansible role `dangerousthings.authforge` | 2 days | ✅ **Done** (2026-04-27, parallel subagent) |
| 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.
---
## 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: anytime)
FIDO2 policy+ PAM C CLI (Phase 18: anytime)
backend lockout module authforgectl
│ │ │ │
↓ ↓ ↓ ↓
└──────┬───┘ │ │
↓ │ │
┌───────────┐ │ │
Phase 8 Phase 9 (Phase 12 Phase 16
GUI keys GUI policy recovery) Ansible
│ │ │ │
└──────────┼────────────┘ │
↓ │
Phase 10 │
first-login │
│ │
└────────────┬───────────┘
Phase 13
deb finalization
Phase 14
Launchpad PPA
Phase 17
integration tests
Phase 18
user docs
R
v1.0 release
```
### Parallelizable now (post-Phase 2)
Four lanes touch disjoint file trees and can land in any order:
| Lane | Phases | Touches | Why parallel-safe |
|---|---|---|---|
| **A: FIDO2 backend** | 3 | `daemon/src/fido/`, small `dbus.rs` hook for signals | Self-contained; only consumes the storage façade from Phase 2. |
| **B: Policy + lockout** | 4 + 5 (must land together) | `daemon/src/policy_apply/`, `daemon/src/lockout/`, `dbus.rs` SetPolicy path | 5 gates 4 in the same call chain, so they go as one unit, but the unit doesn't touch FIDO2, PAM, or CLI. |
| **C: PAM module** | 6 | `pam/pam_authforge_pending.c` + `Makefile` | Pure C against the JSON-pending-flag contract Phase 2 already writes. Zero overlap with Rust code. |
| **D: CLI** | 7 | `cli/src/` | The D-Bus contract was settled in Phase 1; CLI is a thin client. |
### Parallelizable in second wave (after first wave's lanes land)
- **Phase 8** (GUI Keys tab) — opens after Lane A (Phase 3) lands. Needs real enrollment for the demo.
- **Phase 9** (GUI Policy tab) — opens after Lane B (Phase 5) lands. Needs the lockout simulator for the warning banner.
- **Phase 11** (TOTP, feature-flagged) — opens after Lane B. Independent of Phases 8/9.
- **Phase 12** (Recovery flow) — opens after Lane C (Phase 6). Independent of GUI lanes.
- **Phase 16** (Ansible role) — opens after Lane D (Phase 7). Independent of everything else.
### Third wave (single dependency)
- **Phase 10** (first-login flow) — needs Phase 6 (PAM backstop) + Phase 8 (GUI fullscreen). Cannot parallelize earlier.
### Always-anytime slots
- **Phase 15** (`authforge-gnome-integration` shim) — pure data deb; can be built whenever there's idle capacity.
- **Phase 18** (user docs) — best authored alongside their phase, but writing can start whenever a feature stabilizes.
### Sequential tail (no parallelism wins)
- **Phase 13** (deb finalization) needs all binaries built.
- **Phase 14** (PPA) needs Phase 13.
- **Phase 17** (integration test harness) needs end-to-end binaries from at least one PPA build.
- **R** (release tag) is last.
### Practical notes for executing this in parallel
- Subagents in `git worktree` is the canonical isolation mechanism, but Phase 1 hit a harness `cwd` quirk that broke worktree-based subagents. Re-test before relying on it; otherwise batch independent lanes in a single session.
- Phase 4 + Phase 5 must land in the same change because both modify the SetPolicy path; splitting them creates a window where SetPolicy applies without a lockout pre-check.
- Phase 8 (GUI) requires `libgtk-4-dev` + `libadwaita-1-dev` on the dev box. Treat it as its own physical build environment.
- Phase 6 (PAM C) requires `libpam0g-dev`. Build is a one-line `make -C pam`; no Rust toolchain involvement.
---
### Phase 1 closeout notes (2026-04-27)
Phase 1 expanded to step granularity at [2026-04-26-phase-1-dbus.md](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](../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](2026-04-27-phase-2-storage.md). All 17 tasks landed in 8 commits, tagged `v0.2.0-storage`.
**Done in Phase 2:**
- `common/src/policy.rs``Policy::load_from_dir` (lex-ordered last-wins merge; ignores non-`.conf`; missing-dir → default) 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.rs``PolicyStore` 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.rs``pam_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.rs``rusqlite` (bundled SQLite) cache for the lockout simulator; `record_enrollment`, `drop_enrollment`, `users_with`, `has_any`.
- `daemon/src/dbus.rs` — adds `#[zbus(signal)] policy_changed`. Tests run against tempdir-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.stacks``p2p_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 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.
```bash
cd /home/work/VSCodeProjects/ubuntu_fido
git init -b main
```
**Step 2:** Write `.gitignore`:
```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`:
```markdown
# 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.
```bash
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`:
```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`:
```toml
[toolchain]
channel = "1.78"
components = ["rustfmt", "clippy"]
profile = "minimal"
```
**Step 3:** Write `.cargo/config.toml`:
```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`:
```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`:
```rust
pub mod policy;
pub mod types;
```
**Step 3:** `common/src/types.rs`:
```rust
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:
```rust
// Policy parsing implemented in Phase 2.
```
**Step 5:** Write a failing test in `common/src/types.rs`:
```rust
#[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.
```bash
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`:
```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`:
```rust
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.
```bash
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`:
```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`:
```rust
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.
```bash
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`:
```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`:
```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:
```bash
cd pam && make && cd ..
```
Expected: produces `pam/pam_authforge_pending.so` (~10 KB). Requires `libpam0g-dev` (will be in build-deps).
**Step 4:** Commit.
```bash
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`:
```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:
```rust
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
[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`).
```bash
cargo build -p authforge-gui
```
Expected: success.
**Step 7:** Commit.
```bash
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):
```makefile
#!/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:
```bash
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).
```bash
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.
```bash
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:
```yaml
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.
```bash
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`:
```bash
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:**
```rust
#[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.rs` — `adw::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/control` — `Recommends: 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?