Merge branch 'feature/phase-11-totp'

This commit is contained in:
michael
2026-04-27 12:22:44 -07:00
21 changed files with 861 additions and 13 deletions

8
Cargo.lock generated
View File

@@ -298,6 +298,7 @@ dependencies = [
"argon2",
"authforge-common",
"ctap-hid-fido2",
"data-encoding",
"futures-util",
"hex",
"nix 0.28.0",
@@ -325,6 +326,7 @@ dependencies = [
"futures-util",
"gtk4",
"libadwaita",
"qrcode",
"tokio",
"zbus",
]
@@ -1823,6 +1825,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quote"
version = "1.0.45"

View File

@@ -34,3 +34,5 @@ rusqlite = { version = "0.31", features = ["bundled"] }
futures-util = "0.3"
hex = "0.4"
argon2 = "0.5"
data-encoding = "2.6"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }

View File

@@ -4,7 +4,9 @@
use anyhow::{Context, Result};
use authforge_common::policy::Policy;
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
use authforge_common::types::{
Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary, TotpEnrollment,
};
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
@@ -64,4 +66,16 @@ impl Daemon {
pub async fn revoke_recovery_code(&self, user: &str) -> Result<bool> {
Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?)
}
pub async fn enroll_totp(&self, user: &str) -> Result<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?)
}
}

View File

@@ -241,6 +241,52 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> {
std::process::exit(1);
}
}
super::Cmd::Totp {
cmd: super::TotpCmd::Enroll { user },
} => {
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.");
}
}
super::Cmd::Totp {
cmd: super::TotpCmd::Status { user },
} => {
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");
}
}
super::Cmd::Totp {
cmd: super::TotpCmd::Revoke { user },
} => {
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(())
}

View File

@@ -56,6 +56,11 @@ enum Cmd {
#[command(subcommand)]
cmd: RecoveryCmd,
},
/// Manage TOTP enrollments.
Totp {
#[command(subcommand)]
cmd: TotpCmd,
},
}
#[derive(Subcommand)]
@@ -99,6 +104,16 @@ enum RecoveryCmd {
Revoke { user: String },
}
#[derive(Subcommand)]
enum TotpCmd {
/// Generate a fresh TOTP secret for a user.
Enroll { user: String },
/// Show whether a user has a TOTP secret on file.
Status { user: String },
/// Remove a user's TOTP enrollment.
Revoke { user: String },
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
@@ -197,4 +212,35 @@ mod tests {
_ => panic!("wrong subcommand"),
}
}
#[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 { .. }
}
));
}
}

View File

@@ -72,6 +72,15 @@ pub struct RecoveryCodeSummary {
pub expires_unix: u64,
}
#[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,
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -8,6 +8,10 @@ license.workspace = true
name = "authforged"
path = "src/main.rs"
[features]
default = ["totp"]
totp = ["dep:data-encoding"]
[dependencies]
authforge-common = { path = "../common" }
zbus = { workspace = true }
@@ -27,6 +31,7 @@ toml = { workspace = true }
ctap-hid-fido2 = { workspace = true }
hex = { workspace = true }
argon2 = { workspace = true }
data-encoding = { workspace = true, optional = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -1,6 +1,8 @@
use crate::polkit::Polkit;
use crate::state::AppState;
use authforge_common::policy::Policy;
#[cfg(feature = "totp")]
use authforge_common::types::TotpEnrollment;
use authforge_common::types::{
Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary,
};
@@ -174,6 +176,35 @@ impl AuthForge {
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
#[cfg(feature = "totp")]
async fn enroll_totp(&self, user: String) -> zbus::fdo::Result<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; daemon's pattern is gates on writes only.
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()))
}
/// Emitted by main.rs whenever the inotify watcher on the policy.d/ dir
/// reports any change. Subscribers (GUI) reload via `GetPolicy`.
#[zbus(signal)]
@@ -257,6 +288,8 @@ central_path = "{}"
policy_dir,
pending_dir: tmp.path().join("pending"),
recovery_dir: tmp.path().join("recovery"),
#[cfg(feature = "totp")]
totp_dir: tmp.path().join("totp"),
userdb_path: tmp.path().join("users.db"),
pam_profile_path: tmp.path().join("authforge-pamconf"),
pam_auth_update: shim,
@@ -518,4 +551,42 @@ central_path = "{}"
.expect("signal not received within 2s")
.expect("stream closed before signal");
}
#[cfg(feature = "totp")]
#[tokio::test]
async fn enroll_totp_returns_otpauth_uri() {
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 _: 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 _: 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);
}
}

View File

@@ -10,6 +10,8 @@ mod polkit;
mod recovery;
mod state;
mod storage;
#[cfg(feature = "totp")]
mod totp;
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";

View File

@@ -93,27 +93,72 @@ pub(crate) fn render_profile(p: &Policy) -> String {
.any(|m| matches!(m, authforge_common::types::Method::Fido2))
});
#[cfg(feature = "totp")]
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))
});
// Recovery line runs first on every login attempt: PAM_IGNORE when
// there's no recovery file (cheap stat), PAM_SUCCESS via [success=done]
// when the user typed a valid recovery code (short-circuits the rest of
// the auth stack), PAM_IGNORE otherwise. `default=ignore` keeps a
// missing recovery file from blocking normal auth.
let recovery_line =
" [success=done default=ignore] pam_authforge_pending.so mode=recovery";
let mut lines: Vec<String> = Vec::new();
lines.push(
" [success=done default=ignore] pam_authforge_pending.so mode=recovery"
.to_string(),
);
let main_lines = if any_required_fido2 {
// Per design doc § pam-auth-update profile.
" [success=ok default=1 ignore=ignore] pam_u2f.so cue authfile=/etc/u2f_mappings\n [success=ok default=die] pam_authforge_pending.so".to_string()
// TOTP slot (when present): runs between recovery and FIDO2. die-on-fail
// so a wrong code is fatal without falling through to other methods.
#[cfg(feature = "totp")]
if any_required_totp {
lines.push(
" [success=ok default=die] pam_google_authenticator.so secret=/etc/google-authenticator/${USER}"
.to_string(),
);
}
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 {
// Policy doesn't require fido2 anywhere — only the pending-flag
// backstop is active. pam_u2f is left to the admin's own stack edits.
" [success=ok default=die] pam_authforge_pending.so".to_string()
lines.push(
" [success=ok default=die] pam_authforge_pending.so".to_string(),
);
}
let auth_lines = lines.join("\n");
// `Default: yes` whenever any factor is required; auto-enable the profile
// so admins don't have to run pam-auth-update manually.
let default = if any_required_fido2 {
"yes"
} else {
#[cfg(feature = "totp")]
{
if any_required_totp {
"yes"
} else {
"no"
}
}
#[cfg(not(feature = "totp"))]
{
"no"
}
};
let auth_lines = format!("{recovery_line}\n{main_lines}");
let default = if any_required_fido2 { "yes" } else { "no" };
format!(
"Name: Dangerous Things authforge MFA\nDefault: {default}\nPriority: 192\nAuth-Type: Additional\nAuth:\n{auth_lines}\n"
)
@@ -195,6 +240,51 @@ mod tests {
assert!(body.starts_with("Name: Dangerous Things authforge MFA"));
}
#[cfg(feature = "totp")]
#[test]
fn render_required_totp_includes_pam_google_authenticator() {
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"));
// TOTP line lives after recovery, before fido2 backstop.
let recovery_idx = body.find("mode=recovery").unwrap();
let totp_idx = body.find("pam_google_authenticator.so").unwrap();
assert!(recovery_idx < totp_idx);
}
#[cfg(feature = "totp")]
#[test]
fn render_no_totp_when_only_fido2_required() {
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"));
}
#[test]
fn apply_rolls_back_on_failure() {
let d = tempdir().unwrap();

View File

@@ -5,6 +5,8 @@ use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, Creds
use crate::storage::pending::{PendingError, PendingStore};
use crate::storage::policy::PolicyStore;
use crate::storage::recovery::{RecoveryEntry, RecoveryStore, RecoveryStoreError};
#[cfg(feature = "totp")]
use crate::storage::totp::TotpStore;
use crate::storage::userdb::{UserDb, UserDbError};
use authforge_common::policy::{Policy, PolicyError};
use authforge_common::types::{
@@ -32,12 +34,17 @@ pub enum StateError {
Apply(#[from] ApplyError),
#[error("authenticator: {0}")]
Authn(String),
#[allow(dead_code)] // wired through D-Bus in Task 5.
#[error("storage: {0}")]
Storage(String),
}
pub struct StorageConfig {
pub policy_dir: PathBuf,
pub pending_dir: PathBuf,
pub recovery_dir: PathBuf,
#[cfg(feature = "totp")]
pub totp_dir: PathBuf,
pub userdb_path: PathBuf,
/// Where pam-auth-update reads the AuthForge profile from. Defaults to
/// `/usr/share/pam-configs/authforge`; tests / non-root runs override.
@@ -58,6 +65,8 @@ impl StorageConfig {
policy_dir: env("AUTHFORGE_POLICY_DIR", "/etc/authforge/policy.d"),
pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"),
recovery_dir: env("AUTHFORGE_RECOVERY_DIR", "/var/lib/authforge/recovery"),
#[cfg(feature = "totp")]
totp_dir: env("AUTHFORGE_TOTP_DIR", "/etc/google-authenticator"),
userdb_path: env("AUTHFORGE_USERDB", "/var/lib/authforge/users.db"),
pam_profile_path: env("AUTHFORGE_PAMCONF_PATH", "/usr/share/pam-configs/authforge"),
pam_auth_update: env("AUTHFORGE_PAM_AUTH_UPDATE", "/usr/sbin/pam-auth-update"),
@@ -70,6 +79,9 @@ pub struct AppState {
pending: PendingStore,
#[allow(dead_code)] // wired through D-Bus in Task 6.
recovery: RecoveryStore,
#[cfg(feature = "totp")]
#[allow(dead_code)] // wired through D-Bus in Task 5.
totp: TotpStore,
userdb: Mutex<UserDb>,
authn: Arc<dyn Authenticator>,
applier: PolicyApplier,
@@ -80,12 +92,16 @@ impl AppState {
let policy = PolicyStore::new(cfg.policy_dir);
let pending = PendingStore::new(cfg.pending_dir);
let recovery = RecoveryStore::new(cfg.recovery_dir);
#[cfg(feature = "totp")]
let totp = TotpStore::new(cfg.totp_dir);
let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?);
let applier = PolicyApplier::new(cfg.pam_profile_path, cfg.pam_auth_update);
Ok(Self {
policy,
pending,
recovery,
#[cfg(feature = "totp")]
totp,
userdb,
authn,
applier,
@@ -273,6 +289,37 @@ impl AppState {
}
}
#[cfg(feature = "totp")]
#[allow(dead_code)] // wired through D-Bus in Task 5.
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()))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -293,6 +340,8 @@ mod tests {
policy_dir: d.join("policy.d"),
pending_dir: d.join("pending"),
recovery_dir: d.join("recovery"),
#[cfg(feature = "totp")]
totp_dir: d.join("totp"),
userdb_path: d.join("users.db"),
pam_profile_path: d.join("authforge-pamconf"),
pam_auth_update: shim,
@@ -353,6 +402,8 @@ mod tests {
policy_dir,
pending_dir: d.path().join("pending"),
recovery_dir: d.path().join("recovery"),
#[cfg(feature = "totp")]
totp_dir: d.path().join("totp"),
userdb_path: d.path().join("users.db"),
pam_profile_path: d.path().join("authforge-pamconf"),
pam_auth_update: shim,

View File

@@ -3,4 +3,6 @@ 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;

153
daemon/src/storage/totp.rs Normal file
View File

@@ -0,0 +1,153 @@
//! 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());
}
}
}

99
daemon/src/totp/mod.rs Normal file
View File

@@ -0,0 +1,99 @@
//! 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"));
}
}

1
debian/control vendored
View File

@@ -31,6 +31,7 @@ Description: Turnkey FIDO2/U2F/TOTP MFA for Linux desktops (metapackage)
Package: authforge-daemon
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-u2f, libfido2-1, dbus, policykit-1
Recommends: libpam-google-authenticator
Description: System daemon for authforge MFA management
Provides the privileged D-Bus service that orchestrates enrollment,
policy edits, and lockout-prevention checks.

View File

@@ -97,4 +97,24 @@
</defaults>
</action>
<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>
</policyconfig>

View File

@@ -38,7 +38,7 @@
| 8 | GUI: app shell + Security Keys tab | 68 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` |
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | ✅ **Code complete** (2026-04-27) — bundles Phase 12 GUI tab; 3 unit tests in `policy_form` |
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (2026-04-27) — VM smoke deferred |
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) |
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Code complete** (2026-04-27) — `pam_google_authenticator` smoke deferred |
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM + GUI tab all landed |
| 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built |
| 14 | Launchpad PPA build setup | 2 days | **Spec'd** — sequential tail after 13; **VM smoke gate for all "Code complete" phases** |
@@ -48,7 +48,7 @@
| 18 | User docs + onboarding site | 3 days | **Spec'd** — anytime slot |
| **R** | **v1.0 release** | 1 day | — |
**Progress: 13 of 19 phases code-complete (68%).** Total original estimate was ~14 weeks of focused work for v1.0. Subsequent phases (Debian packaging, KDE port, RPM) are scoped in the design doc.
**Progress: 14 of 19 phases code-complete (74%).** Total original estimate was ~14 weeks of focused work for v1.0. Subsequent phases (Debian packaging, KDE port, RPM) are scoped in the design doc.
---
@@ -313,6 +313,34 @@ Landed via [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md). B
**Deferred until Phase 14 VM smoke:** the full Flow C walkthrough (`useradd -m testuser; chage -d 0 testuser; sudo authforgectl pending set testuser --methods fido2; logout; login; see modal; enroll; see desktop`). Recipe lives in `gui/FIRSTRUN-TESTING.md`.
### Phase 11 closeout notes (2026-04-27)
Landed via [2026-04-27-phase-11-totp.md](2026-04-27-phase-11-totp.md). Built atop the prep lane's `AppContext` and 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 and a modal QR dialog (rendered via `qrcode` crate's SVG output → `gdk_pixbuf::Pixbuf::from_stream``gdk::Texture::for_pixbuf`).
- 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 (verified clippy-clean in both modes).
- `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 lane defers 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. Revisit if smoke testing reveals workflow gaps.
- Task 7's `dialog.present(Some(&parent))` corrected to `dialog.present(&parent)``adw::Dialog::present` in libadwaita 0.6.0 takes `&impl IsA<Widget>`, not `Option<…>`. Same correction Phase 9 + 12 GUI lane noted.
- Task 7's `gtk::gdk::Texture::from_bytes(&bytes)` (not present in `gdk4` 0.8.2) routed through `gtk::gio::MemoryInputStream::from_bytes``gdk_pixbuf::Pixbuf::from_stream``gdk::Texture::for_pixbuf`. The `if let Ok(_)` guard preserves the silent-fallback behaviour: when the SVG loader is missing, the dialog still shows the secret + URI for manual entry.
- Task 7's tab registration uses `add_titled` (no icon), matching the existing convention in `main.rs` from the Phase 9 + 12 GUI closeout.
**Test count delta:** workspace 96 → 112 (+16). Breakdown: cli 7 → 9 (+2), daemon 60 → 78 (+18: 5 totp + 6 storage::totp + 3 dbus TOTP + 2 policy_apply TOTP + 2 ancillary policy_apply assertions); common +0; gui +0 (TOTP tab is manual-smoke).
**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.
---
# Phase 0: Repository Scaffolding (FULLY DETAILED)

View File

@@ -8,6 +8,10 @@ license.workspace = true
name = "authforge"
path = "src/main.rs"
[features]
default = ["totp"]
totp = ["dep:qrcode"]
[dependencies]
gtk = { package = "gtk4", version = "0.8" }
adw = { package = "libadwaita", version = "0.6", features = ["v1_5"] }
@@ -16,3 +20,4 @@ anyhow = { workspace = true }
zbus = { workspace = true }
tokio = { workspace = true }
futures-util = { workspace = true }
qrcode = { workspace = true, optional = true }

View File

@@ -88,6 +88,24 @@ impl Daemon {
}
}
#[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
}
}
#[allow(dead_code)] // wired through keys_page.rs in Task 4.
pub(crate) fn current_user() -> String {
std::env::var("USER").unwrap_or_else(|_| "unknown".into())

View File

@@ -10,6 +10,8 @@ mod keys_page;
mod policy_form;
mod policy_page;
mod recovery_page;
#[cfg(feature = "totp")]
mod totp_page;
const APP_ID: &str = "io.dangerousthings.AuthForge";
@@ -68,6 +70,12 @@ fn main() -> glib::ExitCode {
let recovery = recovery_page::RecoveryPage::new(ctx.clone());
stack.add_titled(&recovery.root, Some("recovery"), "Recovery");
#[cfg(feature = "totp")]
{
let totp = totp_page::TotpPage::new(ctx.clone());
stack.add_titled(&totp.root, Some("totp"), "TOTP");
}
// ToolbarView is libadwaita v1_4-gated; the project sticks with the
// simpler Box layout for portability (see commit c6a5e94).
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);

170
gui/src/totp_page.rs Normal file
View File

@@ -0,0 +1,170 @@
//! "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 => {
// The Keys page is the connect entry point — it populates
// ctx.daemon. If it hasn't run / failed, prompt the user there.
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 attempt to load it as a gdk::Texture.
// gdk4 0.8.2 has no `Texture::from_bytes`; route the SVG through
// gdk-pixbuf's stream loader (librsvg-backed) and wrap the resulting
// Pixbuf as a Texture via `for_pixbuf`. If the SVG loader isn't
// installed, Pixbuf creation fails and we silently skip the visual —
// the secret_b32 + URI in the body remain a working manual-entry
// fallback.
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();
let bytes = gtk::glib::Bytes::from(svg.as_bytes());
let stream = gtk::gio::MemoryInputStream::from_bytes(&bytes);
if let Ok(pixbuf) =
gtk::gdk_pixbuf::Pixbuf::from_stream(&stream, gtk::gio::Cancellable::NONE)
{
let tex = gtk::gdk::Texture::for_pixbuf(&pixbuf);
let pic = gtk::Picture::for_paintable(&tex);
pic.set_size_request(256, 256);
dialog.set_extra_child(Some(&pic));
}
dialog.present(&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());
}
}