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

44 KiB
Raw Blame History

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) 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:


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:

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:

[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:

[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

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

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:

#[cfg(feature = "totp")]
mod totp;

Step 2: Write the test + impl

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

cargo test -p authforge-daemon totp::tests

Expected: 5 PASS.

Step 4: Verify the no-default-features path still builds

cargo build --workspace --no-default-features

Expected: clean (the #[cfg(feature = "totp")] keeps the module out).

Step 5: Commit

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.rsTotpStore (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). File at <dir>/<user> mode 0600. Format is pam_google_authenticator's expected layout.

Step 1: Write the impl

// 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:

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

cargo test -p authforge-daemon storage::totp

Expected: 6 PASS.

Step 4: Commit

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.rstotp_dir in StorageConfig, totp field on AppState, three new methods.

Step 1: Add the wire type

// 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:

#[cfg(feature = "totp")]
pub totp_dir: PathBuf,

And in from_env_or_defaults (still inside the same impl):

#[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)

#[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:

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

cargo test -p authforge-daemon
cargo build --workspace --no-default-features

Both clean.

Step 6: Commit

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):

#[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:

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

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

#[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

cargo test -p authforge-daemon
cargo clippy --workspace --all-targets -- -D warnings
cargo build --workspace --no-default-features

All clean.

Step 5: Commit

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:

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:

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:

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:

#[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

cargo test -p authforge-daemon policy_apply
cargo build --workspace --no-default-features  # TOTP renderer absent in this mode

Expected: clean.

Step 4: Commit

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

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

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

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

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

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

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

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

// 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:

#[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

cargo test -p authforge-cli
cargo clippy --workspace --all-targets -- -D warnings

Both clean.

Step 6: Commit

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

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

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.