* Phase 9/10/11 status rows: dependencies satisfied, mark "open now" with pointers to step-level plans where they exist. * Phase 12 row: GUI tab is folded into the Phase 9 lane plan (not its own). * ASCII dependency diagram: collapse the just-landed Phase-8/Phase-12-backend layer; show the three open lanes (9+12-GUI, 11 TOTP, 10 first-login) fanning into Phase 13. * "Parallel-safe right now" heading bumped to post-Phase-8+12-backend. Table swaps the now-stale Recovery and post-Phase-8 GUI rows for the three actually-open lanes, with file footprints + plan links. * Sequential tail paragraph names the six Code-complete phases (1, 3, 4, 6, 8, 12) whose deferred gates Phase 14 must clear. * Phase 12 closeout intro names the merge commit and corrects the now-stale "waiting on Phase 8" wording. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1495 lines
68 KiB
Markdown
1495 lines
68 KiB
Markdown
# 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-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
|
||
|
||
### Status legend
|
||
|
||
- ✅ **Done** — code on `main`, unit/integration tests green, no Phase 14 VM smoke required (data/packaging/refactor phases that don't talk to real hardware or root-owned files).
|
||
- ✅ **Code complete** — code on `main`, unit/integration tests green; one or more acceptance gates require real hardware or `/usr/share/...` write access and are deferred to the Phase 14 PPA-build VM smoke.
|
||
- **Spec'd** — high-level scope in this doc; needs a step-level expansion plan (re-invoke `superpowers:writing-plans`) before execution.
|
||
- **Bundled** — landed together with another phase because they share a code path that can't be split atomically.
|
||
|
||
### Roadmap
|
||
|
||
| Phase | Goal | Approx. Effort | Status |
|
||
|---|---|---|---|
|
||
| 0 | Repo, workspace, CI, packaging skeleton | 2–3 days | ✅ **Done** (2026-04-26, tag `v0.1.0-scaffolding`) |
|
||
| 1 | Daemon: D-Bus interface stub, systemd unit, polkit rules | 3–4 days | ✅ **Code complete** (2026-04-27, tag `v0.1.0-phase1`) |
|
||
| 2 | Daemon: storage layer (policy.d parser, pending flags, user db) | 3–4 days | ✅ **Done** (2026-04-27, tag `v0.2.0-storage`) |
|
||
| 3 | Daemon: FIDO2 enrollment backend via `ctap-hid-fido2` | 5–7 days | ✅ **Code complete** (2026-04-27, tag `v0.3.0-multi-lane`) — real-HW smoke deferred |
|
||
| 4 | Daemon: policy apply via pam-auth-update wrapper | 4 days | ✅ **Code complete** (2026-04-27, tag `v0.4.0-parallel`) — root-write smoke deferred |
|
||
| 5 | Daemon: lockout simulator | 3 days | ✅ **Done** — **Bundled** with Phase 4 |
|
||
| 6 | PAM module: `pam_authforge_pending.so` (C) | 3 days | ✅ **Code complete** (2026-04-27) — `pamtester` smoke recipe in `pam/TESTING.md` |
|
||
| 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands |
|
||
| 8 | GUI: app shell + Security Keys tab | 6–8 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` |
|
||
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | **Spec'd** — open now (plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md); bundles Phase 12 GUI tab) |
|
||
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | **Spec'd** — open now (Phase 6 ✓ + Phase 8 ✓) |
|
||
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) |
|
||
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM landed; GUI tab folded into the Phase 9 lane plan |
|
||
| 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built |
|
||
| 14 | Launchpad PPA build setup | 2 days | **Spec'd** — sequential tail after 13; **VM smoke gate for all "Code complete" phases** |
|
||
| 15 | `authforge-gnome-integration` (Users panel shortcut) | 3 days | ✅ **Done** (2026-04-27) — parallel subagent (gnome lane) |
|
||
| 16 | Ansible role `dangerousthings.authforge` | 2 days | ✅ **Done** (2026-04-27) — parallel subagent (ansible lane) |
|
||
| 17 | Integration test harness (Multipass / LXD VM) | 4 days | **Spec'd** — sequential tail |
|
||
| 18 | User docs + onboarding site | 3 days | **Spec'd** — anytime slot |
|
||
| **R** | **v1.0 release** | 1 day | — |
|
||
|
||
**Progress: 13 of 19 phases code-complete (68%).** Total original estimate was ~14 weeks of focused work for v1.0. Subsequent phases (Debian packaging, KDE port, RPM) are scoped in the design doc.
|
||
|
||
---
|
||
|
||
## Parallel Execution Lanes
|
||
|
||
The phase numbering is a dependency-respecting ordering, **not** a serial schedule. Multiple phases touch disjoint file trees and can be developed in parallel by independent contributors, subagents in worktrees, or a single session bouncing between lanes.
|
||
|
||
```
|
||
Phase 0 ✅ scaffold
|
||
│
|
||
Phase 1 ✅ D-Bus contract
|
||
│
|
||
Phase 2 ✅ storage layer
|
||
│
|
||
┌──────────┬───────────┼───────────┬──────────┐
|
||
│ │ │ │ │
|
||
Phase 3 ✅ Phase 4+5 ✅ Phase 6 ✅ Phase 7 ✅ Phase 15 ✅
|
||
FIDO2 policy+ PAM C CLI GNOME shim
|
||
backend lockout module authforgectl (parallel agent)
|
||
│ │ │ │
|
||
↓ ↓ ↓ ↓
|
||
└──────┬───┘ │ │
|
||
↓ │ │
|
||
Phase 8 ✅ Phase 12 ✅ Phase 16 ✅
|
||
GUI keys+shell recovery Ansible (parallel agent)
|
||
│ backend+PAM
|
||
│ │
|
||
↓ ↓
|
||
┌──────────────┬────────────┐
|
||
│ │ │
|
||
Phase 9 + 12-GUI Phase 11 Phase 10
|
||
policy + recovery TOTP first-login (needs 6 ✓ + 8 ✓)
|
||
tabs (open) (open) (open)
|
||
│ │ │
|
||
└──────────────┴────────────┘
|
||
↓
|
||
Phase 13
|
||
deb finalization (needs every binary)
|
||
↓
|
||
Phase 14
|
||
Launchpad PPA (VM smoke gate for "Code complete" phases)
|
||
↓
|
||
Phase 17
|
||
integration tests
|
||
↓
|
||
Phase 18
|
||
user docs
|
||
↓
|
||
R
|
||
v1.0 release
|
||
```
|
||
|
||
### What's parallel-safe right now (post-Phase 8 + Phase 12 backend)
|
||
|
||
Three lanes are open and touch disjoint file trees:
|
||
|
||
| Lane | Phases | Touches | Why parallel-safe |
|
||
|---|---|---|---|
|
||
| **GUI policy + recovery** | 9 + 12 GUI tab | `gui/src/{app_context,policy_form,policy_page,recovery_page}.rs` (new), additive bus.rs methods, ViewStack tabs in `main.rs` | Both views consume the typed D-Bus contract that landed with Phases 8 + 12 backend. AppContext refactor was foreseen by Phase 8's Task 7. Plan: [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md). |
|
||
| **TOTP** | 11 | `daemon/src/totp/` (new), `gui/src/totp_page.rs` (new), `debian/control` Recommends | Feature-flagged at build time. Phase 8 ✓ unblocked the GUI tab; daemon-side TOTP module is brand-new. Step-level plan still to be written. |
|
||
| **First-login** | 10 | `gui/src/firstrun.rs` (new), `gui/data/authforge-firstrun.desktop`, `--first-run` CLI flag in `gui/src/main.rs` | Both Phase 6 ✓ (PAM pending blocker) and Phase 8 ✓ (GUI shell) are in. Step-level plan still to be written. |
|
||
|
||
### Sequential tail (no parallelism wins)
|
||
|
||
Phase 13 (deb finalization) needs every binary built — opens once Phases 9, 10, 11 land. Phase 14 (PPA) chains off 13 — and is the **VM smoke gate** for everything currently in "Code complete" status (1, 3, 4, 6, 8, 12 — six phases with deferred real-hardware/root-write/libargon2-dev gates). Phase 17 (integration tests) chains off 14. Phase 18 (user docs) and R (release) follow.
|
||
|
||
### Lessons learned from this session's parallelism
|
||
|
||
- **Subagents in `isolation: "worktree"` work** when permissions cover `cd <worktree> && …`, `git -C <worktree> …`, and broad `cargo *` / `make *`. Two subagents (Phase 15 gnome + Phase 16 Ansible) finished cleanly in 7 minutes wall-clock for ~250 lines of YAML/desktop config combined.
|
||
- **They don't work** when the harness sandbox blocks `desktop-file-validate` / `dpkg-checkbuilddeps` / `ansible-lint` — the Phase 15+16 agents both reported these as "skipped, hand-checked." Verification slips through to manual review of the diff. Acceptable for data/config phases; risky for code phases without strong test coverage.
|
||
- **Phase 4+5 had to land together** because both modify the SetPolicy code path; splitting them creates a window where SetPolicy applies without a lockout pre-check.
|
||
- **Worktrees share `target/` if and only if you don't isolate** via `CARGO_TARGET_DIR`. We didn't need to — each `git worktree add` produces its own working tree, so cargo target dirs are per-worktree by default. Concurrent cargo runs across worktrees would still race on the global `~/.cargo/registry` cargo lock, but in practice that's a brief synchronization, not a hang.
|
||
|
||
---
|
||
|
||
### Phase 1 closeout notes (2026-04-27)
|
||
|
||
Phase 1 expanded to step granularity at [2026-04-26-phase-1-dbus.md](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 3 + 6 + 7 closeout notes (2026-04-27)
|
||
|
||
Bundled multi-lane execution against the plan at [2026-04-27-phase-3-6-7-multi-lane.md](2026-04-27-phase-3-6-7-multi-lane.md). Tagged `v0.3.0-multi-lane`. 12 commits (1 plan + 11 implementation).
|
||
|
||
**Phase 3 — FIDO2 enrollment backend:**
|
||
- `daemon/src/fido/format.rs` — `PamU2fCred` -> `kh,pk,es256,+presence` with `hex::encode`; `CoseType` matching COSE alg `-7`/`-8`.
|
||
- `daemon/src/fido/authenticator.rs` — `Authenticator` trait with `discover()` and `make_credential(rp_id, user, pin)`. `AuthnError::{NoDevice, Cancelled, PinRequired, Backend}`.
|
||
- `daemon/src/fido/mock.rs` — `MockAuthenticator::with_one_yubikey()` for tests; counter-bumped per call.
|
||
- `daemon/src/fido/ctap.rs` — `CtapAuthenticator` wrapping `ctap-hid-fido2 = "3.5.9"`. Hardware-dependent; compile-clean gate only.
|
||
- `AppState::enroll(user, nickname)` replaces the Phase 2 add-credential stub: calls `authn.make_credential`, writes pam_u2f line, records userdb enrollment, returns `Credential` with `hex(keyHandle)` as id.
|
||
- D-Bus signals `DeviceFound`, `TouchRequired`, `EnrollmentSucceeded`, `EnrollmentFailed` emitted from `enroll_own` / `enroll_other`.
|
||
|
||
**Phase 6 — `pam_authforge_pending.so`:**
|
||
- Replaces the Phase 0 stub. Reads `PAM_USER`, rejects unsafe usernames, `stat()`s `/var/lib/authforge/pending/<user>`. Present → user-facing message + `PAM_AUTH_ERR`. ENOENT → `PAM_IGNORE`. Other errno → fail closed.
|
||
- Builds clean against `libpam0g-dev` with `-Wall -Wextra -Werror -fPIC -O2`. 16KB ELF.
|
||
- `pam/TESTING.md` + `pam/test/authforge.pamd` document the manual `pamtester` smoke recipe.
|
||
|
||
**Phase 7 — `authforgectl` CLI:**
|
||
- 14 subcommands wired through D-Bus: `status`, `enroll`, `list`, `remove`, `policy {show,set,apply,validate}`, `pending {set,clear,list}`, `recovery {generate,list}`. Global `--json` flag.
|
||
- `cli/src/bus.rs` — owned-proxy wrapper via `zbus::Proxy::new_owned`.
|
||
- 5 clap-parser tests cover subcommand definition validity, status parse, policy-set with method list, enroll with user+nickname, `--json` global.
|
||
|
||
**Plan deviations:**
|
||
- `HidInfo` in ctap-hid-fido2 3.5.9 has `pid`, `vid`, `product_string`, `info`, `param` — no `serial_number` / `path`. Used `product_string` for the device name and `HidParam::Path`/`VidPid` for the path label.
|
||
- `FidoKeyHidFactory` and `LibCfg` live at the crate root, not under `fidokey::`.
|
||
- `fido/mod.rs` carries `#![allow(dead_code)]` until Phase 8 GUI consumes the `DeviceFound` signal — `discover()` and `DiscoveredDevice` fields are wired via the trait but not yet called from non-test code.
|
||
|
||
### Phase 4 + 5 closeout notes (2026-04-27)
|
||
|
||
Tagged `v0.4.0-parallel`. Phase 4 + Phase 5 land together because both modify the SetPolicy code path; splitting would create a window where SetPolicy applies without a lockout pre-check.
|
||
|
||
**Phase 4 — Policy apply via pam-auth-update:**
|
||
- `daemon/src/policy_apply.rs` — `PolicyApplier::new(profile_path, pam_auth_update)`. Renders the `pam-configs/authforge` profile (`Default: yes` when any stack requires fido2; `pam_u2f.so` + `pam_authforge_pending.so` chain when fido2 required, only the pending backstop otherwise) and runs `pam-auth-update --package`.
|
||
- **Stash-and-restore on failure:** prior profile contents are read before write, restored if `pam-auth-update` fails, and `pam-auth-update` is re-run to put the system back into its previous PAM state.
|
||
- 4 unit tests including a real-process rollback test against a failing `/bin/sh` shim.
|
||
|
||
**Phase 5 — Lockout simulator:**
|
||
- `daemon/src/lockout.rs` — pure `simulate(&Policy, &[UserEnrollment]) -> Vec<Violation>`. Iterates Required stacks, flags users with no enrolled credential of any required method.
|
||
- 5 unit tests: optional-mode skipped, required-with-unenrolled flagged, any-method-satisfies, empty registry, multi-stack violation count.
|
||
- `AppState::set_policy(p, force)` always runs the simulator first; if violations and `!force`, returns `PolicyApplyResult { applied: false, violations }` without writing or invoking pam-auth-update. Otherwise persists via `PolicyStore::save` and invokes `PolicyApplier::apply`.
|
||
|
||
**Wire-protocol breaking change (pre-alpha, OK):** `SetPolicy(p)` → `SetPolicy(p, force: bool)`. CLI's `policy set` runs with `force=false` and surfaces violations as a non-zero exit + stderr list pointing the user at `policy apply --force-i-know-what-im-doing`.
|
||
|
||
**StorageConfig grew two fields:** `pam_profile_path` and `pam_auth_update`, env-var driven (`AUTHFORGE_PAMCONF_PATH`, `AUTHFORGE_PAM_AUTH_UPDATE`). Tests inject a no-op `/bin/sh` shim into a tempdir.
|
||
|
||
### Phase 15 + 16 closeout notes (2026-04-27)
|
||
|
||
Both delivered by parallel subagents in `git worktree` isolation while Lane 1 (Phase 4+5) ran in the main session. ~7 minutes wall-clock for both, vs. ~15+ if I'd done them sequentially.
|
||
|
||
**Phase 15 — `authforge-gnome-integration`:**
|
||
- `gnome-integration/io.dangerousthings.AuthForge.UsersPanel.desktop` — `.desktop` overlay tagged `X-GNOME-Settings-Panel=user-accounts`, `NoDisplay=true`, `OnlyShowIn=GNOME;`. Adds "Configure security…" inside `gnome-control-center` Users panel.
|
||
- `debian/control` paragraph for the new binary deb (`Architecture: all`, `Depends: authforge-gui, gnome-control-center`).
|
||
- `debian/rules` install line for `debian/authforge-gnome-integration/usr/share/applications/`.
|
||
- `gnome-integration/README.md` documents the GNOME 46/47 settings-panel-overlay mechanism and notes JS-extension support is deferred until a stable cross-version mechanism exists.
|
||
|
||
**Phase 16 — Ansible role `dangerousthings.authforge`:**
|
||
- Full role at `ansible-role/`: meta, defaults, handlers, tasks, templates, README, examples/playbook.yml.
|
||
- Adds the PPA, installs `authforge-daemon authforge-pam authforge-cli` (gated `authforge-gui`), templates `policy.conf.j2` to `/etc/authforge/policy.d/90-fleet.conf`, runs `authforgectl pending set` for users in `authforge_pending_users`.
|
||
- Single restart handler for `authforge-daemon.service`.
|
||
- All 5 YAML files validated via `yaml.safe_load`. `ansible-lint` skipped — not installed in the harness sandbox.
|
||
- **Subagent-initiated divergences (kept):** added `authforge_ppa` override variable so internal-mirror users don't fork the role; pre-creates `/etc/authforge/policy.d/` defensively (idempotent); standard `ansible_managed` template header.
|
||
|
||
### Phase 8 closeout notes (2026-04-27)
|
||
|
||
Single-lane execution in a worktree at `.claude/worktrees/phase-8-gui-keys`. Plan at [2026-04-27-phase-8-gui-keys.md](2026-04-27-phase-8-gui-keys.md). Eight task commits + one plan correction + merge commit (`943d9b0`).
|
||
|
||
**What landed:**
|
||
- `gui/src/error.rs` — `classify(&zbus::Error)` + `user_message()` mapping zbus errors to `Disconnected` / `AccessDenied` / `Other`. 4 unit tests, all green.
|
||
- `gui/src/bus.rs` — `Daemon` wrapper (Clone via inner `Arc<ProxyInner>`) with `list_credentials`, `enroll_own`, `remove_own`, `subscribe_enrollment_failed`, plus `current_user()` helper. Mirrors [cli/src/bus.rs](../../cli/src/bus.rs) pattern.
|
||
- `gui/src/keys_page.rs` — `KeysPage` widget. Empty state with retry banner when daemon unreachable, otherwise lists each credential as an `adw::ActionRow` with a per-row trash button + "Enroll a new key" activatable row at the bottom. Refresh re-runs `ListCredentials` from scratch.
|
||
- `gui/src/enroll_dialog.rs` — modal `adw::AlertDialog` with spinner. Spawns one task that races `enroll_own` against the `EnrollmentFailed` signal stream via `futures_util::future::select`; success comes from the call's return (`Credential`), the signal lets a fast failure get a useful message before the call's typed `Err` traverses the bus.
|
||
- `gui/src/main.rs` — multi-thread tokio runtime + `runtime.enter()` guard held for `app.run()`. `adw::ApplicationWindow` containing `gtk::Box[HeaderBar, ViewStack]` wrapped in `adw::ToastOverlay`. `KeysPage` is the only visible page; the `ViewStack` is forward-looking for Phase 9.
|
||
- `gui/TESTING.md` — automated gate (`cargo build`, `clippy`, `test`) + manual smoke recipe for a Yubikey-equipped Ubuntu box.
|
||
|
||
**Plan deviations (recorded in commit messages):**
|
||
- The plan asserted `zbus = { features = ["glib"] }` for runtime integration — zbus 4.4 has no such feature. Switched to the workspace tokio config + a tokio runtime guard in `main`. Plan correction commit (`a17f70c`) ships alongside the implementation.
|
||
- `adw::ToolbarView` is libadwaita `v1_4`-gated; the project sticks with `gtk::Box` vertical layout (commit `c6a5e94` established this).
|
||
- `adw::AlertDialog` is libadwaita `v1_5`-gated; `gui/Cargo.toml` enables that feature. Targets Ubuntu 24.04+ (libadwaita 1.5).
|
||
- `Proxy::new(...).into_owned()` doesn't exist in zbus 4.4; used `Proxy::new_owned(...)` (the CLI's known-working pattern).
|
||
- Dropped the planned `subscribe_enrollment_succeeded` from `bus.rs` — the dialog only races against the failed signal because the call's return value carries the new `Credential` (source of truth for success). YAGNI.
|
||
|
||
**Deferred to Phase 14 PPA-build VM smoke:**
|
||
- All six steps in `gui/TESTING.md` (touch a real Yubikey through the modal; remove a credential; pull the key during enrollment to see the failure swap; kill the daemon to see the disconnected banner).
|
||
|
||
### Phase 12 backend closeout notes (2026-04-27)
|
||
|
||
Backend + PAM landed via the plan at [2026-04-27-phase-12-recovery-backend.md](2026-04-27-phase-12-recovery-backend.md). 9 commits on `feature/phase-12-recovery-backend`, merged to `main` as `4ec6911`. GUI tab is folded into the Phase 9 lane plan ([2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md)) — that lane is now open since Phase 8 ✓.
|
||
|
||
**Done:**
|
||
- `daemon/src/storage/safe_user.rs` — extracted shared username path-segment sanitizer; `PendingStore` migrated to it. 2 new unit tests.
|
||
- `daemon/src/recovery.rs` — pure logic: `generate_code` (8 digits), `hash_code` (Argon2id PHC via OS RNG salt), `verify_code` (constant-time). 4 unit tests.
|
||
- `daemon/src/storage/recovery.rs` — `RecoveryStore`: atomic 0600 writes (temp+rename), two-line file format `<expires_unix>\n<argon2id-PHC>\n`, one-shot `verify_and_consume` semantics, expiry handling, alphabetical `list()`. 7 unit tests.
|
||
- `StorageConfig` gains `recovery_dir` field with `AUTHFORGE_RECOVERY_DIR` env override (default `/var/lib/authforge/recovery`). `AppState` exposes `issue_recovery` / `list_recovery` / `revoke_recovery`.
|
||
- D-Bus: real `GenerateRecoveryCode` (replaced stub), new `ListRecoveryCodes` (returns `Vec<RecoveryCodeSummary>`), new `RevokeRecoveryCode`. polkit policy gains `…list-recovery` + `…revoke-recovery` actions (`auth_admin_keep`). 4 new D-Bus integration tests including a real on-disk `$argon2id$` PHC round-trip.
|
||
- PAM module gains a `mode=recovery` argv branch. Reads the two-line file, `argon2_verify`s against `PAM_AUTHTOK`; on match unlinks the recovery file (one-shot) and writes `pending(re_enroll=true)`. Always returns `PAM_IGNORE` on failure paths so a missing/wrong code never blocks normal auth. Linked against `libargon2`.
|
||
- `policy_apply::render_profile` renders the recovery line first in the auth stack with `[success=done default=ignore]` so a successful recovery short-circuits the rest of the auth chain.
|
||
- CLI: `authforgectl recovery list` (was placeholder) and `authforgectl recovery revoke <user>`. List takes no args (matches D-Bus signature).
|
||
|
||
**Test count:** 7 cli + 13 common + 60 daemon = **80 tests** (was 5+13+42=60 at end of Phase 4+5). +20 tests across the 7 implementation tasks.
|
||
|
||
**Plan deviations:** none of substance.
|
||
|
||
**Deferred until Phase 14 VM smoke:**
|
||
- `make -C pam` against a host with `libargon2-dev` installed (not in the dev sandbox).
|
||
- `pamtester` recovery walkthrough — recipe lives in `pam/TESTING.md` § Smoke test 4.
|
||
- End-to-end recovery flow against a real PAM stack: admin issues code via CLI → user logs in entering it at the password prompt → recovery file deleted → pending(re_enroll=true) flag present → re-enrollment modal triggers on next login.
|
||
|
||
---
|
||
|
||
# Phase 0: Repository Scaffolding (FULLY DETAILED)
|
||
|
||
**Goal:** Empty-but-valid repo that builds, passes a trivial test, and produces installable (no-op) deb packages from `dpkg-buildpackage`. Locks in the project structure so all later phases drop into known places.
|
||
|
||
**Deliverables:**
|
||
- Cargo workspace with crates: `daemon`, `cli`, `common` (shared types).
|
||
- Debian packaging skeleton in `debian/` that builds the metapackage + 5 component debs.
|
||
- C source tree for the PAM module under `pam/`.
|
||
- GUI placeholder under `gui/` (Meson + Rust target).
|
||
- GitHub Actions CI: `cargo build`, `cargo test`, `cargo clippy`, `debuild --no-sign`, `lintian`.
|
||
- `README.md` with build instructions.
|
||
|
||
### Task 0.1: Initialize git repo and license
|
||
|
||
**Files:**
|
||
- Create: `/home/work/VSCodeProjects/ubuntu_fido/.gitignore`
|
||
- Create: `/home/work/VSCodeProjects/ubuntu_fido/LICENSE` (Apache 2.0)
|
||
- Create: `/home/work/VSCodeProjects/ubuntu_fido/README.md`
|
||
|
||
**Step 1:** Run `git init` in the project directory.
|
||
|
||
```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 1–18 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?
|