diff --git a/Cargo.lock b/Cargo.lock index 4e91439..b672735 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ba1b197..f0c85f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/cli/src/bus.rs b/cli/src/bus.rs index b831579..7eafbdb 100644 --- a/cli/src/bus.rs +++ b/cli/src/bus.rs @@ -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 { Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?) } + + 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?) + } } diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 492f1bf..0eeeeaa 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -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(()) } diff --git a/cli/src/main.rs b/cli/src/main.rs index 5c31a1a..1850e6c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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 { .. } + } + )); + } } diff --git a/common/src/types.rs b/common/src/types.rs index 9e86f31..3d752e5 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -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::*; diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 5a85551..e0230fd 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -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 } diff --git a/daemon/src/dbus.rs b/daemon/src/dbus.rs index 2368559..d5b4527 100644 --- a/daemon/src/dbus.rs +++ b/daemon/src/dbus.rs @@ -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 { + 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; 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 { + 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); + } } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index bdb4397..8a9f326 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -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"; diff --git a/daemon/src/policy_apply.rs b/daemon/src/policy_apply.rs index cc903fe..14bc295 100644 --- a/daemon/src/policy_apply.rs +++ b/daemon/src/policy_apply.rs @@ -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 = 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(); diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 2691d6f..bae68e4 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -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, authn: Arc, 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 { + 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())) + } +} + #[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, diff --git a/daemon/src/storage/mod.rs b/daemon/src/storage/mod.rs index 8f189b0..62cb540 100644 --- a/daemon/src/storage/mod.rs +++ b/daemon/src/storage/mod.rs @@ -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; diff --git a/daemon/src/storage/totp.rs b/daemon/src/storage/totp.rs new file mode 100644 index 0000000..dc7b220 --- /dev/null +++ b/daemon/src/storage/totp.rs @@ -0,0 +1,153 @@ +//! 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()); + } + } +} diff --git a/daemon/src/totp/mod.rs b/daemon/src/totp/mod.rs new file mode 100644 index 0000000..20d50b7 --- /dev/null +++ b/daemon/src/totp/mod.rs @@ -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/:?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")); + } +} diff --git a/debian/control b/debian/control index da98765..305b77d 100644 --- a/debian/control +++ b/debian/control @@ -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. diff --git a/debian/io.dangerousthings.AuthForge.policy b/debian/io.dangerousthings.AuthForge.policy index 5708116..0a9394b 100644 --- a/debian/io.dangerousthings.AuthForge.policy +++ b/debian/io.dangerousthings.AuthForge.policy @@ -97,4 +97,24 @@ + + 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 + + + diff --git a/docs/plans/2026-04-26-authforge-implementation.md b/docs/plans/2026-04-26-authforge-implementation.md index 619aec0..1c0276b 100644 --- a/docs/plans/2026-04-26-authforge-implementation.md +++ b/docs/plans/2026-04-26-authforge-implementation.md @@ -38,7 +38,7 @@ | 8 | GUI: app shell + Security Keys tab | 6–8 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 `. 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`, 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) diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 04f8b0d..ff72d28 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -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 } diff --git a/gui/src/bus.rs b/gui/src/bus.rs index ac487ae..82185d4 100644 --- a/gui/src/bus.rs +++ b/gui/src/bus.rs @@ -88,6 +88,24 @@ impl Daemon { } } +#[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 + } +} + #[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()) diff --git a/gui/src/main.rs b/gui/src/main.rs index db38e94..338e5f9 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -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); diff --git a/gui/src/totp_page.rs b/gui/src/totp_page.rs new file mode 100644 index 0000000..5fb86d3 --- /dev/null +++ b/gui/src/totp_page.rs @@ -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 { + 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 => { + // 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, 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 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::>() + .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, msg: &str) { + self.ctx + .toast_overlay + .add_toast(adw::Toast::builder().title(msg).timeout(5).build()); + } +}