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