Files
authforge/docs/plans/2026-04-27-phase-11-totp.md
michael d20643cde5 docs(plan): coordinator + 3 step-level plans for parallel Phase 9/10/11 fan-out
Ships four documents:
  * 2026-04-27-parallel-fanout-9-10-11.md — coordinator. File-conflict map,
    prep-lane scope rationale, post-prep parallel-safety guarantee, merge
    order rule, and risks.
  * 2026-04-27-prep-shell-and-pending.md — small ~1hr prep lane: extract
    AppContext, add --first-run flag scaffold (with stub firstrun::run),
    add daemon GetPendingStatus D-Bus method + GUI client wrapper, new
    PendingStatus wire type. 6 tasks, single agent.
  * 2026-04-27-phase-10-firstrun.md — first-login flow. Fullscreen modal,
    60s idle watchdog (TDD'd as pure WatchdogState), enrollment via Phase
    8 enroll_dialog, ClearPendingFlag on success, gnome-session-quit on
    idle, autostart .desktop entry, debian install. 6 tasks.
  * 2026-04-27-phase-11-totp.md — TOTP support behind default-on Cargo
    feature. Daemon-side: 160-bit secret + base32 + otpauth URI + atomic
    0600 writes in pam_google_authenticator format. PAM profile renderer
    extension. D-Bus surface. CLI subcommand. GUI tab with QR modal.
    Deviation flagged: TOTP recovery codes reuse the Phase 12 recovery
    flow rather than introducing a parallel hashed-recovery file format.
    9 tasks.

Execution model: prep lane first (single agent ~1hr), then dispatch three
subagents in parallel worktrees for the three follow-on lanes. Per the
conflict map, the lanes are file-disjoint after prep merges; merge order
is any-order.

If all four lanes land cleanly, the roadmap jumps from 13/19 to 17/19
phases code-complete in a single session, leaving Phase 13 (deb finalization),
14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0
release tag.

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

1294 lines
44 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.

# Phase 11: TOTP Support — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
>
> **EXECUTION GATE:** Do **not** start until the prep lane ([2026-04-27-prep-shell-and-pending.md](2026-04-27-prep-shell-and-pending.md)) has merged to `main`. This lane uses the `AppContext` and the existing daemon `policy_apply::render_profile` surface that was extended in Phase 12.
**Goal:** Land RFC 6238 TOTP as a third authenticator method, gated behind a build-time Cargo feature `totp` (default-on). The daemon owns secret generation + persistence at `/etc/google-authenticator/<user>` (the path libpam-google-authenticator reads). PAM verification is delegated to `pam_google_authenticator.so` (apt: `libpam-google-authenticator`); we don't reimplement RFC 6238 verification. The GUI's TOTP tab shows the QR code + base32 secret on first enrollment and a re-issue / revoke button for already-enrolled users.
**Architecture:**
- **Secret generation** is daemon-side. `daemon/src/totp/mod.rs` produces a 160-bit cryptographic-random secret, base32-encodes it, and renders an `otpauth://` URI for QR display.
- **Persistence** is `daemon/src/storage/totp.rs`'s `TotpStore`. File at `/etc/google-authenticator/<user>` mode 0600 root — that's where `pam_google_authenticator.so` looks via `secret=/etc/google-authenticator/${USER}`. File format is the upstream pam_google_authenticator format (line 1: base32 secret; line 2: `" TOTP_AUTH"`). Atomic writes via temp+rename.
- **Recovery codes**: deviating from the master plan's "8 codes hashed via Argon2id" — we **reuse the Phase 12 recovery flow** instead. A user who loses their TOTP device asks an admin to run `authforgectl recovery generate alice`, gets a one-shot code, logs in via the existing PAM `mode=recovery` path. Avoids a second hashed-recovery file format and unifies the lost-credential UX. Documented in the closeout note.
- **PAM profile renderer** (`daemon/src/policy_apply.rs`) gains a TOTP branch: when any stack has `Mode::Required` + `Method::Totp` in its method list, the rendered profile includes `auth required pam_google_authenticator.so secret=/etc/google-authenticator/${USER}` before the existing FIDO2 line.
- **D-Bus** gains `EnrollTotp(user) -> TotpEnrollment`, `IsTotpEnrolled(user) -> bool`, `RevokeTotp(user) -> bool`. polkit gates `…enroll-totp`, `…revoke-totp`.
- **GUI tab** (`gui/src/totp_page.rs`) lists enrollment state, offers an enroll button that opens an `adw::AlertDialog` containing the QR code (rendered by the `qrcode` crate) plus the base32 secret string for manual-entry fallback.
- **Cargo feature** `totp` is default-on at the workspace level. Daemon code is `#[cfg(feature = "totp")]`-gated. GUI tab is `#[cfg(feature = "totp")]`-gated. Building with `--no-default-features` produces a usable workspace without TOTP.
**Tech Stack:**
- `data-encoding` 2.6 — base32 encoding (no padding) for the secret.
- `qrcode` 0.14 — QR generation in the GUI (feature-gated).
- `rand` (already in workspace) — secret RNG.
- `pam_google_authenticator.so` — PAM-side verification, runtime dep installed via `Recommends:` on the `authforge-daemon` package.
**Reference:**
- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 11 (line 1237).
- Sibling daemon module (mirror this style): [daemon/src/recovery.rs](../../daemon/src/recovery.rs).
- Sibling store (mirror this style): [daemon/src/storage/recovery.rs](../../daemon/src/storage/recovery.rs).
- Profile renderer to extend: [daemon/src/policy_apply.rs](../../daemon/src/policy_apply.rs).
- Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md).
- pam_google_authenticator file format: `man 8 pam_google_authenticator` (Ubuntu manpage).
---
## Conventions
- One logical change per commit. Conventional prefixes scoped to subsystem.
- Always commit with `--no-gpg-sign`.
- TDD for `daemon/src/totp/mod.rs` (pure logic — secret generation, base32, URI render) and `daemon/src/storage/totp.rs` (file roundtrip + path traversal). GUI tab ships with manual smoke; QR rendering is verified by hand.
- After every commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo build --workspace`. Add a `cargo build --workspace --no-default-features` check after Task 1 to verify the feature gate works.
---
## Task 1: Cargo `totp` feature + workspace deps
**Files:**
- Modify: `Cargo.toml` (workspace) — add `data-encoding`, `qrcode` to `[workspace.dependencies]`.
- Modify: `daemon/Cargo.toml` — add `[features]` block with `default = ["totp"]`, `totp = ["dep:data-encoding"]`.
- Modify: `gui/Cargo.toml` — add `[features]` with `default = ["totp"]`, `totp = ["dep:qrcode"]`.
- Modify: `debian/control` — add `Recommends: libpam-google-authenticator` to the `authforge-daemon` paragraph.
**Step 1: Workspace deps**
Append to `[workspace.dependencies]` in `/home/work/VSCodeProjects/authforge/Cargo.toml`:
```toml
data-encoding = "2.6"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
```
(`svg` feature lets the GUI render to an `gtk::Image` via `gdk::Texture::from_bytes`; the default `image` feature pulls in heavy raster deps we don't need.)
**Step 2: Daemon feature gate**
In `daemon/Cargo.toml`, append:
```toml
[features]
default = ["totp"]
totp = ["dep:data-encoding"]
[dependencies]
# ... existing ...
data-encoding = { workspace = true, optional = true }
```
**Step 3: GUI feature gate**
In `gui/Cargo.toml`, append:
```toml
[features]
default = ["totp"]
totp = ["dep:qrcode"]
[dependencies]
# ... existing ...
qrcode = { workspace = true, optional = true }
```
**Step 4: debian/control**
Open `debian/control`, find the `authforge-daemon` Package paragraph, append to its `Recommends:` line (creating it if absent):
```
Recommends: libpam-google-authenticator
```
**Step 5: Verify both build modes**
```bash
cargo build --workspace
cargo build --workspace --no-default-features
```
Both should compile clean (the second run drops the optional deps; nothing in main code references them yet).
**Step 6: Commit**
```bash
git add Cargo.toml daemon/Cargo.toml gui/Cargo.toml debian/control Cargo.lock
git commit --no-gpg-sign -m "chore: add totp feature gate (default-on) + data-encoding + qrcode deps"
```
---
## Task 2: `daemon/src/totp/mod.rs` — pure logic (TDD)
**Files:**
- Create: `daemon/src/totp/mod.rs`
- Modify: `daemon/src/main.rs``#[cfg(feature = "totp")] mod totp;`
**Step 1: Stub the module + register**
Add to `daemon/src/main.rs` next to the other `mod ...` declarations:
```rust
#[cfg(feature = "totp")]
mod totp;
```
**Step 2: Write the test + impl**
```rust
// daemon/src/totp/mod.rs
//! Pure TOTP logic: generate 160-bit secret, base32-encode, render
//! the otpauth:// URI for QR display. No verification — that's
//! pam_google_authenticator's job at PAM time.
#![allow(dead_code)] // wired through TotpStore (Task 3) and AppState (Task 4).
use data_encoding::BASE32_NOPAD;
use rand::RngCore;
/// 160 bits per RFC 6238 §5.1.
pub(crate) const SECRET_BYTES: usize = 20;
pub(crate) fn generate_secret() -> [u8; SECRET_BYTES] {
let mut buf = [0u8; SECRET_BYTES];
rand::rng().fill_bytes(&mut buf);
buf
}
pub(crate) fn encode_secret(secret: &[u8]) -> String {
BASE32_NOPAD.encode(secret)
}
/// Build an `otpauth://` URI suitable for QR encoding.
/// Format follows Google Authenticator's de-facto spec:
/// otpauth://totp/<Issuer>:<account>?secret=<b32>&issuer=<Issuer>
pub(crate) fn otpauth_uri(secret_b32: &str, account: &str, issuer: &str) -> String {
let acct = url_encode(account);
let iss = url_encode(issuer);
format!(
"otpauth://totp/{iss}:{acct}?secret={secret_b32}&issuer={iss}&algorithm=SHA1&digits=6&period=30"
)
}
/// Minimal RFC 3986 url-encoder for the small set of characters that show
/// up in usernames + the literal "AuthForge" issuer. Avoids pulling in a
/// percent-encoding crate just for this one call site.
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
_ => {
let mut buf = [0u8; 4];
for b in c.encode_utf8(&mut buf).bytes() {
out.push_str(&format!("%{b:02X}"));
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_secret_is_160_bits() {
let s = generate_secret();
assert_eq!(s.len(), 20);
// Two consecutive draws should differ with overwhelming probability.
let s2 = generate_secret();
assert_ne!(s, s2);
}
#[test]
fn base32_round_trips() {
let s = generate_secret();
let enc = encode_secret(&s);
let dec = BASE32_NOPAD.decode(enc.as_bytes()).unwrap();
assert_eq!(dec, s);
}
#[test]
fn base32_uses_no_padding_uppercase() {
let s = [0u8; 20];
let enc = encode_secret(&s);
assert!(enc.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
assert!(!enc.contains('='));
}
#[test]
fn otpauth_uri_includes_account_and_issuer() {
let uri = otpauth_uri("ABCDEFGH", "alice", "AuthForge");
assert!(uri.starts_with("otpauth://totp/AuthForge:alice?"));
assert!(uri.contains("secret=ABCDEFGH"));
assert!(uri.contains("issuer=AuthForge"));
}
#[test]
fn url_encode_handles_special_chars() {
// Realistic-ish: a username with a dot and a space (rare on Linux,
// but the encoder must not silently drop bytes).
let uri = otpauth_uri("S", "user name", "AuthForge");
assert!(uri.contains("user%20name"));
}
}
```
**Step 3: Run tests**
```bash
cargo test -p authforge-daemon totp::tests
```
Expected: 5 PASS.
**Step 4: Verify the no-default-features path still builds**
```bash
cargo build --workspace --no-default-features
```
Expected: clean (the `#[cfg(feature = "totp")]` keeps the module out).
**Step 5: Commit**
```bash
git add daemon/src/main.rs daemon/src/totp/mod.rs
git commit --no-gpg-sign -m "feat(daemon): TOTP secret generation + base32 + otpauth URI (feature-gated)"
```
---
## Task 3: `daemon/src/storage/totp.rs` — `TotpStore` (TDD)
**Files:**
- Create: `daemon/src/storage/totp.rs`
- Modify: `daemon/src/storage/mod.rs``#[cfg(feature = "totp")] pub(crate) mod totp;`
**Background:** Mirror `RecoveryStore`'s shape ([daemon/src/storage/recovery.rs](../../daemon/src/storage/recovery.rs)). File at `<dir>/<user>` mode 0600. Format is pam_google_authenticator's expected layout.
**Step 1: Write the impl**
```rust
// daemon/src/storage/totp.rs
//! Per-user TOTP secret persistence. File at `<dir>/<user>` mode 0600,
//! root-owned. Format is pam_google_authenticator's expected layout:
//! <base32 secret>\n " TOTP_AUTH"\n
//! That's the minimum valid file; more options can be appended later.
#![allow(dead_code)] // wired through AppState in Task 4.
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum TotpStoreError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("invalid username: {0:?}")]
InvalidUser(String),
}
pub(crate) struct TotpStore {
dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TotpEnrollment {
pub user: String,
pub secret_b32: String,
pub otpauth_uri: String,
}
impl TotpStore {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
fn user_path(&self, user: &str) -> Result<PathBuf, TotpStoreError> {
super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e {
super::safe_user::SegmentError::Invalid(s) => TotpStoreError::InvalidUser(s),
})
}
/// Generate a fresh secret, write the pam_google_authenticator file
/// atomically, return the enrollment payload.
pub fn enroll(&self, user: &str, issuer: &str) -> Result<TotpEnrollment, TotpStoreError> {
fs::create_dir_all(&self.dir)?;
fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o755))?;
let path = self.user_path(user)?;
let secret = crate::totp::generate_secret();
let secret_b32 = crate::totp::encode_secret(&secret);
let body = format!("{secret_b32}\n\" TOTP_AUTH\"\n");
let tmp = path.with_extension("tmp");
fs::write(&tmp, body.as_bytes())?;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
fs::rename(&tmp, &path)?;
let uri = crate::totp::otpauth_uri(&secret_b32, user, issuer);
Ok(TotpEnrollment {
user: user.to_string(),
secret_b32,
otpauth_uri: uri,
})
}
pub fn is_enrolled(&self, user: &str) -> Result<bool, TotpStoreError> {
let path = self.user_path(user)?;
match fs::metadata(&path) {
Ok(m) => Ok(m.is_file()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.into()),
}
}
pub fn revoke(&self, user: &str) -> Result<bool, TotpStoreError> {
let path = self.user_path(user)?;
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn store() -> (tempfile::TempDir, TotpStore) {
let d = tempdir().unwrap();
let s = TotpStore::new(d.path().to_path_buf());
(d, s)
}
#[test]
fn enroll_creates_file_with_mode_0600() {
let (_d, s) = store();
let e = s.enroll("alice", "AuthForge").unwrap();
assert_eq!(e.user, "alice");
assert!(e.secret_b32.len() >= 32); // 160 bits → 32 base32 chars
assert!(e.otpauth_uri.starts_with("otpauth://totp/AuthForge:alice?"));
let path = s.user_path("alice").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn enroll_writes_pam_google_authenticator_format() {
let (_d, s) = store();
s.enroll("alice", "AuthForge").unwrap();
let body = fs::read_to_string(s.user_path("alice").unwrap()).unwrap();
let mut lines = body.lines();
let secret = lines.next().unwrap();
let opt = lines.next().unwrap();
// Line 1: base32 secret only — no leading space, no leading quote.
assert!(secret.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
assert_eq!(opt, "\" TOTP_AUTH\"");
}
#[test]
fn is_enrolled_reflects_disk_state() {
let (_d, s) = store();
assert!(!s.is_enrolled("alice").unwrap());
s.enroll("alice", "AuthForge").unwrap();
assert!(s.is_enrolled("alice").unwrap());
}
#[test]
fn revoke_returns_false_on_missing_user() {
let (_d, s) = store();
assert!(!s.revoke("ghost").unwrap());
}
#[test]
fn revoke_then_is_enrolled_false() {
let (_d, s) = store();
s.enroll("alice", "AuthForge").unwrap();
assert!(s.revoke("alice").unwrap());
assert!(!s.is_enrolled("alice").unwrap());
}
#[test]
fn rejects_traversal_usernames() {
let (_d, s) = store();
for evil in ["", ".", "..", "a/b", "x\0y"] {
assert!(s.enroll(evil, "AuthForge").is_err());
}
}
}
```
**Step 2: Register the module under feature gate**
In `daemon/src/storage/mod.rs`:
```rust
pub(crate) mod credentials;
pub(crate) mod pending;
pub(crate) mod policy;
pub(crate) mod recovery;
pub(crate) mod safe_user;
#[cfg(feature = "totp")]
pub(crate) mod totp;
pub(crate) mod userdb;
```
**Step 3: Run tests**
```bash
cargo test -p authforge-daemon storage::totp
```
Expected: 6 PASS.
**Step 4: Commit**
```bash
git add daemon/src/storage/totp.rs daemon/src/storage/mod.rs
git commit --no-gpg-sign -m "feat(daemon): TotpStore with atomic 0600 writes in pam_google_authenticator format"
```
---
## Task 4: Wire into `AppState` + new wire type in `common`
**Files:**
- Modify: `common/src/types.rs` — add `TotpEnrollment` wire type.
- Modify: `daemon/src/state.rs``totp_dir` in `StorageConfig`, `totp` field on `AppState`, three new methods.
**Step 1: Add the wire type**
```rust
// common/src/types.rs — append before the existing `#[cfg(test)]` block
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct TotpEnrollment {
pub user: String,
/// Base32-encoded shared secret, no padding. ~32 chars for 160 bits.
pub secret_b32: String,
/// Full `otpauth://totp/...` URI suitable for QR encoding.
pub otpauth_uri: String,
}
```
(This is the same shape as `daemon/src/storage/totp::TotpEnrollment` — but lives in `common` for the D-Bus surface. The daemon-side struct is purely internal; consumers see this one.)
**Step 2: `StorageConfig` field + env override**
In `daemon/src/state.rs`, add to `StorageConfig`:
```rust
#[cfg(feature = "totp")]
pub totp_dir: PathBuf,
```
And in `from_env_or_defaults` (still inside the same impl):
```rust
#[cfg(feature = "totp")]
totp_dir: env("AUTHFORGE_TOTP_DIR", "/etc/google-authenticator"),
```
Add `#[cfg(feature = "totp")]` to the `AppState` `totp` field and `AppState::open` construction.
**Step 3: AppState methods (feature-gated)**
```rust
#[cfg(feature = "totp")]
impl AppState {
pub async fn enroll_totp(
&self,
user: &str,
) -> Result<authforge_common::types::TotpEnrollment, StateError> {
let inner = self
.totp
.enroll(user, "AuthForge")
.map_err(|e| StateError::Storage(e.to_string()))?;
Ok(authforge_common::types::TotpEnrollment {
user: inner.user,
secret_b32: inner.secret_b32,
otpauth_uri: inner.otpauth_uri,
})
}
pub async fn is_totp_enrolled(&self, user: &str) -> Result<bool, StateError> {
self.totp
.is_enrolled(user)
.map_err(|e| StateError::Storage(e.to_string()))
}
pub async fn revoke_totp(&self, user: &str) -> Result<bool, StateError> {
self.totp
.revoke(user)
.map_err(|e| StateError::Storage(e.to_string()))
}
}
```
(If `StateError` doesn't have a `Storage(String)` variant yet from Phase 12, look at how the recovery error is mapped and follow that pattern.)
**Step 4: Update test fixtures**
The two `open_in` / inline `AppState::open` test fixtures in `state.rs` and the `p2p_pair` helper in `dbus.rs` need a feature-gated `totp_dir: d.path().join("totp")` line:
```rust
StorageConfig {
policy_dir: ...,
pending_dir: ...,
recovery_dir: ...,
#[cfg(feature = "totp")]
totp_dir: d.path().join("totp"),
userdb_path: ...,
pam_profile_path: ...,
pam_auth_update: ...,
}
```
**Step 5: Run tests + both build modes**
```bash
cargo test -p authforge-daemon
cargo build --workspace --no-default-features
```
Both clean.
**Step 6: Commit**
```bash
git add common/src/types.rs daemon/src/state.rs daemon/src/dbus.rs
git commit --no-gpg-sign -m "feat(daemon): wire TotpStore into AppState behind feature gate"
```
---
## Task 5: D-Bus methods + polkit actions
**Files:**
- Modify: `daemon/src/dbus.rs` — add `EnrollTotp`, `IsTotpEnrolled`, `RevokeTotp`.
- Modify: `debian/io.dangerousthings.AuthForge.policy` — add two polkit actions.
**Step 1: Add the methods (feature-gated)**
In `daemon/src/dbus.rs`, inside the `impl AuthForge` (the `#[zbus::interface]` block):
```rust
#[cfg(feature = "totp")]
async fn enroll_totp(&self, user: String) -> zbus::fdo::Result<authforge_common::types::TotpEnrollment> {
self.authz("io.dangerousthings.AuthForge.enroll-totp").await?;
self.state
.enroll_totp(&user)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
#[cfg(feature = "totp")]
async fn is_totp_enrolled(&self, user: String) -> zbus::fdo::Result<bool> {
// No polkit gate — pure read.
self.state
.is_totp_enrolled(&user)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
#[cfg(feature = "totp")]
async fn revoke_totp(&self, user: String) -> zbus::fdo::Result<bool> {
self.authz("io.dangerousthings.AuthForge.revoke-totp").await?;
self.state
.revoke_totp(&user)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
```
Add `TotpEnrollment` to the import line at the top of `dbus.rs`:
```rust
use authforge_common::types::{Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary};
#[cfg(feature = "totp")]
use authforge_common::types::TotpEnrollment;
```
(Adjust depending on what's already imported.)
**Step 2: Add polkit actions**
In `debian/io.dangerousthings.AuthForge.policy`, before the closing `</policyconfig>`:
```xml
<action id="io.dangerousthings.AuthForge.enroll-totp">
<description>Enroll a TOTP secret</description>
<message>Authentication is required to enroll a TOTP secret.</message>
<defaults>
<allow_any>auth_self_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_self_keep</allow_active>
</defaults>
</action>
<action id="io.dangerousthings.AuthForge.revoke-totp">
<description>Revoke a TOTP enrollment</description>
<message>Administrator authentication is required to revoke a TOTP enrollment.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
```
(`enroll-totp` is `auth_self_keep` because it's a per-user operation; revoke is admin.)
**Step 3: D-Bus integration tests**
In `daemon/src/dbus.rs`'s test module:
```rust
#[cfg(feature = "totp")]
#[tokio::test]
async fn enroll_totp_returns_otpauth_uri() {
use authforge_common::types::TotpEnrollment;
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let e: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap();
assert_eq!(e.user, "alice");
assert!(e.otpauth_uri.starts_with("otpauth://totp/AuthForge:alice?"));
assert!(e.secret_b32.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
}
#[cfg(feature = "totp")]
#[tokio::test]
async fn is_totp_enrolled_round_trips() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let before: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap();
assert!(!before);
let _: authforge_common::types::TotpEnrollment =
p.call("EnrollTotp", &("alice",)).await.unwrap();
let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap();
assert!(after);
}
#[cfg(feature = "totp")]
#[tokio::test]
async fn revoke_totp_removes_enrollment() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let _: authforge_common::types::TotpEnrollment =
p.call("EnrollTotp", &("alice",)).await.unwrap();
let removed: bool = p.call("RevokeTotp", &("alice",)).await.unwrap();
assert!(removed);
let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap();
assert!(!after);
}
```
**Step 4: Run tests + clippy**
```bash
cargo test -p authforge-daemon
cargo clippy --workspace --all-targets -- -D warnings
cargo build --workspace --no-default-features
```
All clean.
**Step 5: Commit**
```bash
git add daemon/src/dbus.rs debian/io.dangerousthings.AuthForge.policy
git commit --no-gpg-sign -m "feat(daemon): EnrollTotp + IsTotpEnrolled + RevokeTotp D-Bus methods"
```
---
## Task 6: PAM profile renderer — TOTP line
**Files:**
- Modify: `daemon/src/policy_apply.rs`
**Background:** When any stack has `Mode::Required` + `Method::Totp` in its method list, prepend a `pam_google_authenticator.so` line to the auth chain. Place it **after** the recovery line (Phase 12) and **before** the FIDO2 line, so the order is: recovery → TOTP → FIDO2 → pending-backstop.
**Step 1: Extend `render_profile`**
Find the existing `let any_required_fido2 = ...` line and add a sibling:
```rust
let any_required_totp = p.stacks.values().any(|s| {
s.mode == Mode::Required
&& s.methods
.iter()
.any(|m| matches!(m, authforge_common::types::Method::Totp))
});
```
Then build the auth lines incrementally:
```rust
let mut lines: Vec<String> = Vec::new();
lines.push(
" [success=done default=ignore] pam_authforge_pending.so mode=recovery"
.to_string(),
);
#[cfg(feature = "totp")]
if any_required_totp {
lines.push(
" [success=ok default=die] pam_google_authenticator.so secret=/etc/google-authenticator/${USER}"
.to_string(),
);
}
// existing FIDO2 + pending-backstop lines
if any_required_fido2 {
lines.push(" [success=ok default=1 ignore=ignore] pam_u2f.so cue authfile=/etc/u2f_mappings".to_string());
lines.push(" [success=ok default=die] pam_authforge_pending.so".to_string());
} else {
lines.push(" [success=ok default=die] pam_authforge_pending.so".to_string());
}
let auth_lines = lines.join("\n");
```
(Without the `#[cfg(feature = "totp")]`, the TOTP line is suppressed at build time — workspace `--no-default-features` builds an authforged that can't render a TOTP profile.)
Adjust the `Default:` line: it should be `yes` if any stack requires fido2 OR totp:
```rust
let default = if any_required_fido2 {
"yes"
} else {
#[cfg(feature = "totp")]
{
if any_required_totp { "yes" } else { "no" }
}
#[cfg(not(feature = "totp"))]
{
"no"
}
};
```
**Step 2: New tests for the renderer**
In the existing `#[cfg(test)] mod tests`:
```rust
#[cfg(feature = "totp")]
#[test]
fn render_required_totp_includes_pam_google_authenticator() {
use authforge_common::policy::StackPolicy;
use authforge_common::types::Method;
use std::collections::BTreeMap;
let mut stacks = BTreeMap::new();
stacks.insert(
"sudo".to_string(),
StackPolicy {
mode: Mode::Required,
methods: vec![Method::Totp],
},
);
let p = Policy { stacks, ..Default::default() };
let body = render_profile(&p);
assert!(body.contains("pam_google_authenticator.so"));
assert!(body.contains("secret=/etc/google-authenticator/${USER}"));
assert!(body.contains("Default: yes"));
}
#[cfg(feature = "totp")]
#[test]
fn render_no_totp_when_only_fido2_required() {
use authforge_common::policy::StackPolicy;
use authforge_common::types::Method;
use std::collections::BTreeMap;
let mut stacks = BTreeMap::new();
stacks.insert(
"sudo".to_string(),
StackPolicy {
mode: Mode::Required,
methods: vec![Method::Fido2],
},
);
let p = Policy { stacks, ..Default::default() };
let body = render_profile(&p);
assert!(!body.contains("pam_google_authenticator.so"));
assert!(body.contains("pam_u2f.so"));
}
```
**Step 3: Run tests**
```bash
cargo test -p authforge-daemon policy_apply
cargo build --workspace --no-default-features # TOTP renderer absent in this mode
```
Expected: clean.
**Step 4: Commit**
```bash
git add daemon/src/policy_apply.rs
git commit --no-gpg-sign -m "feat(daemon): render pam_google_authenticator.so when TOTP-required stack present"
```
---
## Task 7: GUI tab — `gui/src/totp_page.rs`
**Files:**
- Create: `gui/src/totp_page.rs`
- Modify: `gui/src/main.rs``#[cfg(feature = "totp")] mod totp_page;` + tab registration line.
- Modify: `gui/src/bus.rs` — additive `enroll_totp`, `is_totp_enrolled`, `revoke_totp` methods (feature-gated).
**Background:** Single-user view (current user via `current_user()`). Shows enrollment state. "Enroll" button → calls `EnrollTotp` → modal dialog with QR code (rendered from `otpauth_uri` via `qrcode` crate's SVG output) + base32 secret displayed as a selectable label. "Revoke" button after enrollment → calls `RevokeTotp` → toast confirms.
**Step 1: Extend `bus.rs`**
```rust
// gui/src/bus.rs — append, feature-gated
#[cfg(feature = "totp")]
impl Daemon {
pub async fn enroll_totp(
&self,
user: &str,
) -> zbus::Result<authforge_common::types::TotpEnrollment> {
self.proxy.call("EnrollTotp", &(user,)).await
}
pub async fn is_totp_enrolled(&self, user: &str) -> zbus::Result<bool> {
self.proxy.call("IsTotpEnrolled", &(user,)).await
}
pub async fn revoke_totp(&self, user: &str) -> zbus::Result<bool> {
self.proxy.call("RevokeTotp", &(user,)).await
}
}
```
**Step 2: Create `totp_page.rs`**
```rust
// gui/src/totp_page.rs
//! "TOTP" preferences page. Single-user (current user via $USER).
//! Feature-gated: only built when --features=totp is on (default).
use crate::app_context::AppContext;
use crate::bus::{current_user, Daemon};
use crate::error::user_message;
use adw::prelude::*;
use gtk::glib;
use std::rc::Rc;
pub(crate) struct TotpPage {
pub root: adw::Bin,
ctx: AppContext,
}
impl TotpPage {
pub fn new(ctx: AppContext) -> Rc<Self> {
let page = Rc::new(Self {
root: adw::Bin::new(),
ctx,
});
let p = page.clone();
glib::MainContext::default().spawn_local(async move {
p.refresh().await;
});
page
}
async fn refresh(self: &Rc<Self>) {
let daemon = match self.ctx.daemon.borrow().clone() {
Some(d) => d,
None => {
self.render_disconnected("Connect via the Keys tab first.");
return;
}
};
let user = current_user();
let enrolled = match daemon.is_totp_enrolled(&user).await {
Ok(b) => b,
Err(e) => {
self.render_disconnected(&user_message(&e));
return;
}
};
self.render(daemon, &user, enrolled);
}
fn render_disconnected(self: &Rc<Self>, msg: &str) {
let status = adw::StatusPage::builder()
.icon_name("network-offline-symbolic")
.title("Daemon unavailable")
.description(msg)
.build();
self.root.set_child(Some(&status));
}
fn render(self: &Rc<Self>, daemon: Daemon, user: &str, enrolled: bool) {
let pref = adw::PreferencesPage::new();
let group = adw::PreferencesGroup::builder()
.title("Authenticator app (TOTP)")
.description(if enrolled {
"Your account is enrolled. Use your authenticator app's 6-digit code at the password prompt."
} else {
"Enroll a TOTP secret to use a phone authenticator app (Aegis, Authenticator, etc.) as a second factor."
})
.build();
let row = adw::ActionRow::builder()
.title(if enrolled { "Enrolled" } else { "Not enrolled" })
.subtitle(user)
.build();
let btn = if enrolled {
let b = gtk::Button::builder()
.label("Revoke")
.css_classes(["destructive-action"])
.valign(gtk::Align::Center)
.build();
let me = self.clone();
let user_owned = user.to_string();
let daemon_owned = daemon.clone();
b.connect_clicked(move |_| {
let me = me.clone();
let user = user_owned.clone();
let daemon = daemon_owned.clone();
glib::MainContext::default().spawn_local(async move {
match daemon.revoke_totp(&user).await {
Ok(true) => {
me.show_toast("TOTP enrollment revoked.");
me.refresh().await;
}
Ok(false) => me.show_toast("Was not enrolled."),
Err(e) => me.show_toast(&user_message(&e)),
}
});
});
b
} else {
let b = gtk::Button::builder()
.label("Enroll")
.css_classes(["pill", "suggested-action"])
.valign(gtk::Align::Center)
.build();
let me = self.clone();
let user_owned = user.to_string();
let daemon_owned = daemon.clone();
b.connect_clicked(move |_| {
let me = me.clone();
let user = user_owned.clone();
let daemon = daemon_owned.clone();
glib::MainContext::default().spawn_local(async move {
match daemon.enroll_totp(&user).await {
Ok(e) => {
me.show_qr_dialog(&e.otpauth_uri, &e.secret_b32);
me.refresh().await;
}
Err(err) => me.show_toast(&user_message(&err)),
}
});
});
b
};
row.add_suffix(&btn);
group.add(&row);
pref.add(&group);
self.root.set_child(Some(&pref));
}
fn show_qr_dialog(self: &Rc<Self>, otpauth_uri: &str, secret_b32: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Scan with your authenticator app")
.body(&format!(
"Or enter this secret manually: {secret_b32}"
))
.build();
dialog.add_response("close", "Done");
dialog.set_default_response(Some("close"));
dialog.set_close_response("close");
// Render the QR as SVG, then load into a gtk::Picture.
let qr = qrcode::QrCode::new(otpauth_uri.as_bytes())
.expect("otpauth URI is QR-encodable");
let svg = qr.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
// Wrap SVG bytes in a gio::Bytes → gdk::Texture.
let bytes = gtk::glib::Bytes::from(svg.as_bytes());
let texture = gtk::gdk::Texture::from_bytes(&bytes).ok();
if let Some(tex) = texture {
let pic = gtk::Picture::for_paintable(&tex);
pic.set_size_request(256, 256);
dialog.set_extra_child(Some(&pic));
}
dialog.present(Some(&self.ctx.parent_window));
}
fn show_toast(self: &Rc<Self>, msg: &str) {
self.ctx
.toast_overlay
.add_toast(adw::Toast::builder().title(msg).timeout(5).build());
}
}
```
**Step 3: Wire into `main.rs`**
```rust
// gui/src/main.rs — at the top of the mod block
#[cfg(feature = "totp")]
mod totp_page;
// inside connect_activate, after existing tab registrations:
#[cfg(feature = "totp")]
{
let totp = totp_page::TotpPage::new(ctx.clone());
stack.add_titled_with_icon(
&totp.root,
Some("totp"),
"TOTP",
"preferences-system-time-symbolic",
);
}
```
**Step 4: Build + clippy**
```bash
cargo build -p authforge-gui
cargo build -p authforge-gui --no-default-features
cargo clippy --workspace --all-targets -- -D warnings
```
All clean. The `--no-default-features` build excludes the TOTP tab and the `qrcode` dep entirely.
**Step 5: Commit**
```bash
git add gui/src/totp_page.rs gui/src/main.rs gui/src/bus.rs
git commit --no-gpg-sign -m "feat(gui): TOTP tab with QR code modal + revoke (feature-gated)"
```
---
## Task 8: CLI — `authforgectl totp` subcommand
**Files:**
- Modify: `cli/src/main.rs` — add `Totp` subcommand.
- Modify: `cli/src/bus.rs` — add three feature-gated methods.
- Modify: `cli/src/commands/mod.rs` — dispatch the new subcommand.
**Background:** Mirrors the `Recovery` subcommand pattern from Phase 12. CLI is built without feature gating (always present); when daemon is built `--no-default-features`, CLI invocations of TOTP methods get a `MethodNotFound` D-Bus error which the CLI surfaces verbatim.
**Step 1: Extend CLI enum**
```rust
// cli/src/main.rs
#[derive(Subcommand)]
enum Cmd {
// ... existing ...
Totp {
#[command(subcommand)]
cmd: TotpCmd,
},
}
#[derive(Subcommand)]
enum TotpCmd {
Enroll { user: String },
Status { user: String },
Revoke { user: String },
}
```
**Step 2: Extend `bus.rs`**
```rust
pub async fn enroll_totp(&self, user: &str) -> Result<authforge_common::types::TotpEnrollment> {
Ok(self.proxy.call("EnrollTotp", &(user,)).await?)
}
pub async fn is_totp_enrolled(&self, user: &str) -> Result<bool> {
Ok(self.proxy.call("IsTotpEnrolled", &(user,)).await?)
}
pub async fn revoke_totp(&self, user: &str) -> Result<bool> {
Ok(self.proxy.call("RevokeTotp", &(user,)).await?)
}
```
(No feature gate on the CLI side — these methods always compile; daemon decides whether the D-Bus method exists.)
**Step 3: Wire dispatch**
```rust
// cli/src/commands/mod.rs — append to the existing match
super::Cmd::Totp { cmd: super::TotpCmd::Enroll { user } } => {
let d = bus::Daemon::connect().await?;
let e = d.enroll_totp(&user).await?;
if json {
println!("{}", serde_json::to_string_pretty(&e)?);
} else {
println!("secret: {}", e.secret_b32);
println!("uri: {}", e.otpauth_uri);
println!();
println!("Scan the URI as a QR or enter the secret in your authenticator app.");
}
Ok(())
}
super::Cmd::Totp { cmd: super::TotpCmd::Status { user } } => {
let d = bus::Daemon::connect().await?;
let enrolled = d.is_totp_enrolled(&user).await?;
if json {
println!("{}", serde_json::json!({ "user": user, "enrolled": enrolled }));
} else if enrolled {
println!("{user}: enrolled");
} else {
println!("{user}: not enrolled");
}
Ok(())
}
super::Cmd::Totp { cmd: super::TotpCmd::Revoke { user } } => {
let d = bus::Daemon::connect().await?;
let removed = d.revoke_totp(&user).await?;
if removed {
if !json { println!("revoked TOTP for {user}"); }
} else {
if !json { eprintln!("not enrolled: {user}"); }
std::process::exit(1);
}
Ok(())
}
```
**Step 4: clap parser tests**
In `cli/src/main.rs`'s test module:
```rust
#[test]
fn parse_totp_enroll() {
let c = Cli::try_parse_from(["authforgectl", "totp", "enroll", "alice"]).unwrap();
match c.cmd {
Cmd::Totp { cmd: TotpCmd::Enroll { user } } => assert_eq!(user, "alice"),
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parse_totp_status_and_revoke() {
assert!(matches!(
Cli::try_parse_from(["authforgectl", "totp", "status", "alice"]).unwrap().cmd,
Cmd::Totp { cmd: TotpCmd::Status { .. } }
));
assert!(matches!(
Cli::try_parse_from(["authforgectl", "totp", "revoke", "alice"]).unwrap().cmd,
Cmd::Totp { cmd: TotpCmd::Revoke { .. } }
));
}
```
**Step 5: Build + test + clippy**
```bash
cargo test -p authforge-cli
cargo clippy --workspace --all-targets -- -D warnings
```
Both clean.
**Step 6: Commit**
```bash
git add cli/src/main.rs cli/src/bus.rs cli/src/commands/mod.rs
git commit --no-gpg-sign -m "feat(cli): authforgectl totp enroll/status/revoke"
```
---
## Task 9: Master plan closeout note
**Files:**
- Modify: `docs/plans/2026-04-26-authforge-implementation.md`
**Step 1: Flip Phase 11 to ✅ Code complete**
```
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | ✅ **Code complete** (yyyy-mm-dd) — `pam_google_authenticator` smoke deferred |
```
**Step 2: Append a closeout note**
```markdown
### Phase 11 closeout notes (yyyy-mm-dd)
Landed via [2026-04-27-phase-11-totp.md](2026-04-27-phase-11-totp.md). Built atop the prep lane's AppContext + the workspace `totp` Cargo feature.
**Done:**
- `daemon/src/totp/mod.rs` — pure logic: 160-bit secret generation, base32-no-pad encoding, otpauth URI rendering. 5 unit tests.
- `daemon/src/storage/totp.rs``TotpStore` with atomic 0600 writes in pam_google_authenticator's expected file format. 6 unit tests.
- `StorageConfig.totp_dir` (env: `AUTHFORGE_TOTP_DIR`, default `/etc/google-authenticator`). `AppState::{enroll_totp,is_totp_enrolled,revoke_totp}` (all feature-gated).
- D-Bus: `EnrollTotp` (auth_self_keep), `IsTotpEnrolled` (no gate), `RevokeTotp` (auth_admin_keep). 3 integration tests.
- `policy_apply::render_profile` renders `pam_google_authenticator.so secret=/etc/google-authenticator/${USER}` between the recovery line and the FIDO2 line when any stack has `Mode::Required + Method::Totp`. 2 new tests.
- GUI: `gui/src/totp_page.rs` with single-user enroll/revoke, modal QR dialog (rendered via `qrcode` crate's SVG output → `gdk::Texture`).
- CLI: `authforgectl totp enroll|status|revoke <user>`. 2 new clap parser tests.
- Cargo feature `totp` is default-on at workspace + daemon + GUI level. `cargo build --workspace --no-default-features` produces a TOTP-free build.
- `debian/control`: `Recommends: libpam-google-authenticator` on the daemon package.
**Plan deviations:**
- The master plan called for "8 recovery codes (8 digits each, stored hashed via Argon2id)." This plan deferred TOTP-specific scratch codes and **reuses the Phase 12 recovery flow** instead. A user who loses their TOTP device asks an admin for a one-shot code via `authforgectl recovery generate`, the same path that handles a lost FIDO2 key. Avoids a parallel hashed-recovery file format and unifies the lost-credential UX. Documented here for posterity; revisit if smoke testing reveals workflow gaps.
**Test count delta:** _(fill in)_ daemon (was 60 post-Phase 12, expect +14: 5 totp + 6 storage::totp + 3 dbus); +2 cli; +2 policy_apply renderer.
**Deferred until Phase 14 VM smoke:**
- `pam_google_authenticator.so` actually verifying a code — needs `libpam-google-authenticator` installed and a real PAM stack.
- `authforgectl totp enroll alice` → scan QR with Aegis → `sudo whoami` prompts for the 6-digit code → success.
- Verify the rendered profile loads in `pam-auth-update --package` without rejection on a clean Ubuntu VM.
```
**Step 3: Commit**
```bash
git add docs/plans/2026-04-26-authforge-implementation.md
git commit --no-gpg-sign -m "docs: phase 11 closeout notes"
```
---
## Phase 11 Acceptance Gate
Verify before merging:
- [ ] `cargo build --workspace --release` clean.
- [ ] `cargo build --workspace --release --no-default-features` clean (TOTP code compiled out).
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean.
- [ ] `cargo clippy --workspace --all-targets --no-default-features -- -D warnings` clean.
- [ ] `cargo test --workspace` — daemon test count up by ~14 (5 totp + 6 storage::totp + 3 dbus); cli +2; policy_apply +2.
- [ ] `cargo fmt --all -- --check` clean.
- [ ] Manual VM smoke (deferred to Phase 14): authforgectl totp enroll → scan → sudo prompts for code → success.
---
## Risks / known unknowns
| Risk | Mitigation |
|---|---|
| `qrcode` 0.14 SVG output → `gdk::Texture::from_bytes` chain may not work in all libadwaita 0.6 minors. | Falls back to a selectable label showing the otpauth:// URI as plain text. The base32 secret display is the universal fallback (manual entry into the authenticator app). |
| `pam_google_authenticator` rejects the file format if the daemon's `" TOTP_AUTH"` line has the wrong syntax. | Verified against the upstream manpage. If the smoke test reveals issues, the file is parseable line-by-line — easy to add diagnostic logging. |
| The `/etc/google-authenticator/<user>` file is mode 0600 root-owned but `pam_google_authenticator` may want it user-owned in some PAM stacks. | Default Ubuntu PAM stacks (sudo, gdm-password, sshd) run the auth chain as root before privilege drop — root reads the file fine. KDE / niche PAM stacks may differ; flagged for Phase 14 smoke. |
| `qrcode` crate's `min_dimensions` produces a tiny QR for short strings. | The otpauth URI is ~80-100 chars; `min_dimensions(256, 256)` gives a comfortable 25×25 module grid. |
| Workspace `--no-default-features` build breaks when a single `#[cfg(feature = "totp")]` is missing. | CI gets a `cargo build --workspace --no-default-features` job (add to `.github/workflows/...` if not already there — flag in Task 1's commit message). |
| `data-encoding` 2.6 vs `data-encoding` 2.x patch drift. | Pin at 2.6 in the workspace `[dependencies]`. Bumping is a separate PR. |
---
## Execution Handoff
Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using `superpowers:using-git-worktrees`, then drive task-by-task with `superpowers:executing-plans`. Do **not** start until the prep lane has merged to `main`.
After this lane lands, the coordinator doc lists the parallel-safe order: any of (Phase 9 + 12 GUI), Phase 10, Phase 11 can merge in any order.