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>
44 KiB
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 theAppContextand the existing daemonpolicy_apply::render_profilesurface 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.rsproduces a 160-bit cryptographic-random secret, base32-encodes it, and renders anotpauth://URI for QR display. - Persistence is
daemon/src/storage/totp.rs'sTotpStore. File at/etc/google-authenticator/<user>mode 0600 root — that's wherepam_google_authenticator.solooks viasecret=/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 PAMmode=recoverypath. 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 hasMode::Required+Method::Totpin its method list, the rendered profile includesauth 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 anadw::AlertDialogcontaining the QR code (rendered by theqrcodecrate) plus the base32 secret string for manual-entry fallback. - Cargo feature
totpis default-on at the workspace level. Daemon code is#[cfg(feature = "totp")]-gated. GUI tab is#[cfg(feature = "totp")]-gated. Building with--no-default-featuresproduces a usable workspace without TOTP.
Tech Stack:
data-encoding2.6 — base32 encoding (no padding) for the secret.qrcode0.14 — QR generation in the GUI (feature-gated).rand(already in workspace) — secret RNG.pam_google_authenticator.so— PAM-side verification, runtime dep installed viaRecommends:on theauthforge-daemonpackage.
Reference:
- Master plan: 2026-04-26-authforge-implementation.md § Phase 11 (line 1237).
- Sibling daemon module (mirror this style): daemon/src/recovery.rs.
- Sibling store (mirror this style): daemon/src/storage/recovery.rs.
- Profile renderer to extend: daemon/src/policy_apply.rs.
- Coordinator: 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) anddaemon/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 acargo build --workspace --no-default-featurescheck after Task 1 to verify the feature gate works.
Task 1: Cargo totp feature + workspace deps
Files:
- Modify:
Cargo.toml(workspace) — adddata-encoding,qrcodeto[workspace.dependencies]. - Modify:
daemon/Cargo.toml— add[features]block withdefault = ["totp"],totp = ["dep:data-encoding"]. - Modify:
gui/Cargo.toml— add[features]withdefault = ["totp"],totp = ["dep:qrcode"]. - Modify:
debian/control— addRecommends: libpam-google-authenticatorto theauthforge-daemonparagraph.
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.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). 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— addTotpEnrollmentwire type. - Modify:
daemon/src/state.rs—totp_dirinStorageConfig,totpfield onAppState, 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— addEnrollTotp,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— additiveenroll_totp,is_totp_enrolled,revoke_totpmethods (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— addTotpsubcommand. - 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 --releaseclean.cargo build --workspace --release --no-default-featuresclean (TOTP code compiled out).cargo clippy --workspace --all-targets -- -D warningsclean.cargo clippy --workspace --all-targets --no-default-features -- -D warningsclean.cargo test --workspace— daemon test count up by ~14 (5 totp + 6 storage::totp + 3 dbus); cli +2; policy_apply +2.cargo fmt --all -- --checkclean.- 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.