Merge branch 'feature/phase-11-totp'
This commit is contained in:
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 { .. }
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
153
daemon/src/storage/totp.rs
Normal 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
99
daemon/src/totp/mod.rs
Normal 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
1
debian/control
vendored
@@ -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.
|
||||
|
||||
20
debian/io.dangerousthings.AuthForge.policy
vendored
20
debian/io.dangerousthings.AuthForge.policy
vendored
@@ -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>
|
||||
|
||||
@@ -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 <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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
170
gui/src/totp_page.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user