# 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/` (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/` 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/:?secret=&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 `/` 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 `/` mode 0600, //! root-owned. Format is pam_google_authenticator's expected layout: //! \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 { 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 { 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 { 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 { 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 { 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 { self.totp .is_enrolled(user) .map_err(|e| StateError::Storage(e.to_string())) } pub async fn revoke_totp(&self, user: &str) -> Result { 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 { 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 { // 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 { 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 ``: ```xml Enroll a TOTP secret Authentication is required to enroll a TOTP secret. auth_self_keep auth_admin_keep auth_self_keep Revoke a TOTP enrollment Administrator authentication is required to revoke a TOTP enrollment. auth_admin_keep auth_admin_keep auth_admin_keep ``` (`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 = 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 { self.proxy.call("EnrollTotp", &(user,)).await } pub async fn is_totp_enrolled(&self, user: &str) -> zbus::Result { self.proxy.call("IsTotpEnrolled", &(user,)).await } pub async fn revoke_totp(&self, user: &str) -> zbus::Result { 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 { 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) { 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, 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, 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, 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::() .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, 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 { Ok(self.proxy.call("EnrollTotp", &(user,)).await?) } pub async fn is_totp_enrolled(&self, user: &str) -> Result { Ok(self.proxy.call("IsTotpEnrolled", &(user,)).await?) } pub async fn revoke_totp(&self, user: &str) -> Result { 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 `. 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/` 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.