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",
|
"argon2",
|
||||||
"authforge-common",
|
"authforge-common",
|
||||||
"ctap-hid-fido2",
|
"ctap-hid-fido2",
|
||||||
|
"data-encoding",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"nix 0.28.0",
|
"nix 0.28.0",
|
||||||
@@ -325,6 +326,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
|
"qrcode",
|
||||||
"tokio",
|
"tokio",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
@@ -1823,6 +1825,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
|
|||||||
@@ -34,3 +34,5 @@ rusqlite = { version = "0.31", features = ["bundled"] }
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
argon2 = "0.5"
|
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 anyhow::{Context, Result};
|
||||||
use authforge_common::policy::Policy;
|
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 BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
||||||
const OBJECT_PATH: &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> {
|
pub async fn revoke_recovery_code(&self, user: &str) -> Result<bool> {
|
||||||
Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?)
|
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);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ enum Cmd {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: RecoveryCmd,
|
cmd: RecoveryCmd,
|
||||||
},
|
},
|
||||||
|
/// Manage TOTP enrollments.
|
||||||
|
Totp {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: TotpCmd,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -99,6 +104,16 @@ enum RecoveryCmd {
|
|||||||
Revoke { user: String },
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -197,4 +212,35 @@ mod tests {
|
|||||||
_ => panic!("wrong subcommand"),
|
_ => 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,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ license.workspace = true
|
|||||||
name = "authforged"
|
name = "authforged"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["totp"]
|
||||||
|
totp = ["dep:data-encoding"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
authforge-common = { path = "../common" }
|
authforge-common = { path = "../common" }
|
||||||
zbus = { workspace = true }
|
zbus = { workspace = true }
|
||||||
@@ -27,6 +31,7 @@ toml = { workspace = true }
|
|||||||
ctap-hid-fido2 = { workspace = true }
|
ctap-hid-fido2 = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
argon2 = { workspace = true }
|
argon2 = { workspace = true }
|
||||||
|
data-encoding = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::polkit::Polkit;
|
use crate::polkit::Polkit;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use authforge_common::policy::Policy;
|
use authforge_common::policy::Policy;
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
use authforge_common::types::TotpEnrollment;
|
||||||
use authforge_common::types::{
|
use authforge_common::types::{
|
||||||
Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary,
|
Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary,
|
||||||
};
|
};
|
||||||
@@ -174,6 +176,35 @@ impl AuthForge {
|
|||||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
.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
|
/// Emitted by main.rs whenever the inotify watcher on the policy.d/ dir
|
||||||
/// reports any change. Subscribers (GUI) reload via `GetPolicy`.
|
/// reports any change. Subscribers (GUI) reload via `GetPolicy`.
|
||||||
#[zbus(signal)]
|
#[zbus(signal)]
|
||||||
@@ -257,6 +288,8 @@ central_path = "{}"
|
|||||||
policy_dir,
|
policy_dir,
|
||||||
pending_dir: tmp.path().join("pending"),
|
pending_dir: tmp.path().join("pending"),
|
||||||
recovery_dir: tmp.path().join("recovery"),
|
recovery_dir: tmp.path().join("recovery"),
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
totp_dir: tmp.path().join("totp"),
|
||||||
userdb_path: tmp.path().join("users.db"),
|
userdb_path: tmp.path().join("users.db"),
|
||||||
pam_profile_path: tmp.path().join("authforge-pamconf"),
|
pam_profile_path: tmp.path().join("authforge-pamconf"),
|
||||||
pam_auth_update: shim,
|
pam_auth_update: shim,
|
||||||
@@ -518,4 +551,42 @@ central_path = "{}"
|
|||||||
.expect("signal not received within 2s")
|
.expect("signal not received within 2s")
|
||||||
.expect("stream closed before signal");
|
.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 recovery;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
mod totp;
|
||||||
|
|
||||||
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
||||||
const OBJECT_PATH: &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))
|
.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
|
// Recovery line runs first on every login attempt: PAM_IGNORE when
|
||||||
// there's no recovery file (cheap stat), PAM_SUCCESS via [success=done]
|
// 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
|
// when the user typed a valid recovery code (short-circuits the rest of
|
||||||
// the auth stack), PAM_IGNORE otherwise. `default=ignore` keeps a
|
// the auth stack), PAM_IGNORE otherwise. `default=ignore` keeps a
|
||||||
// missing recovery file from blocking normal auth.
|
// missing recovery file from blocking normal auth.
|
||||||
let recovery_line =
|
let mut lines: Vec<String> = Vec::new();
|
||||||
" [success=done default=ignore] pam_authforge_pending.so mode=recovery";
|
lines.push(
|
||||||
|
" [success=done default=ignore] pam_authforge_pending.so mode=recovery"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
let main_lines = if any_required_fido2 {
|
// TOTP slot (when present): runs between recovery and FIDO2. die-on-fail
|
||||||
// Per design doc § pam-auth-update profile.
|
// so a wrong code is fatal without falling through to other methods.
|
||||||
" [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()
|
#[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 {
|
} else {
|
||||||
// Policy doesn't require fido2 anywhere — only the pending-flag
|
// Policy doesn't require fido2 anywhere — only the pending-flag
|
||||||
// backstop is active. pam_u2f is left to the admin's own stack edits.
|
// 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!(
|
format!(
|
||||||
"Name: Dangerous Things authforge MFA\nDefault: {default}\nPriority: 192\nAuth-Type: Additional\nAuth:\n{auth_lines}\n"
|
"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"));
|
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]
|
#[test]
|
||||||
fn apply_rolls_back_on_failure() {
|
fn apply_rolls_back_on_failure() {
|
||||||
let d = tempdir().unwrap();
|
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::pending::{PendingError, PendingStore};
|
||||||
use crate::storage::policy::PolicyStore;
|
use crate::storage::policy::PolicyStore;
|
||||||
use crate::storage::recovery::{RecoveryEntry, RecoveryStore, RecoveryStoreError};
|
use crate::storage::recovery::{RecoveryEntry, RecoveryStore, RecoveryStoreError};
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
use crate::storage::totp::TotpStore;
|
||||||
use crate::storage::userdb::{UserDb, UserDbError};
|
use crate::storage::userdb::{UserDb, UserDbError};
|
||||||
use authforge_common::policy::{Policy, PolicyError};
|
use authforge_common::policy::{Policy, PolicyError};
|
||||||
use authforge_common::types::{
|
use authforge_common::types::{
|
||||||
@@ -32,12 +34,17 @@ pub enum StateError {
|
|||||||
Apply(#[from] ApplyError),
|
Apply(#[from] ApplyError),
|
||||||
#[error("authenticator: {0}")]
|
#[error("authenticator: {0}")]
|
||||||
Authn(String),
|
Authn(String),
|
||||||
|
#[allow(dead_code)] // wired through D-Bus in Task 5.
|
||||||
|
#[error("storage: {0}")]
|
||||||
|
Storage(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StorageConfig {
|
pub struct StorageConfig {
|
||||||
pub policy_dir: PathBuf,
|
pub policy_dir: PathBuf,
|
||||||
pub pending_dir: PathBuf,
|
pub pending_dir: PathBuf,
|
||||||
pub recovery_dir: PathBuf,
|
pub recovery_dir: PathBuf,
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
pub totp_dir: PathBuf,
|
||||||
pub userdb_path: PathBuf,
|
pub userdb_path: PathBuf,
|
||||||
/// Where pam-auth-update reads the AuthForge profile from. Defaults to
|
/// Where pam-auth-update reads the AuthForge profile from. Defaults to
|
||||||
/// `/usr/share/pam-configs/authforge`; tests / non-root runs override.
|
/// `/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"),
|
policy_dir: env("AUTHFORGE_POLICY_DIR", "/etc/authforge/policy.d"),
|
||||||
pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"),
|
pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"),
|
||||||
recovery_dir: env("AUTHFORGE_RECOVERY_DIR", "/var/lib/authforge/recovery"),
|
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"),
|
userdb_path: env("AUTHFORGE_USERDB", "/var/lib/authforge/users.db"),
|
||||||
pam_profile_path: env("AUTHFORGE_PAMCONF_PATH", "/usr/share/pam-configs/authforge"),
|
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"),
|
pam_auth_update: env("AUTHFORGE_PAM_AUTH_UPDATE", "/usr/sbin/pam-auth-update"),
|
||||||
@@ -70,6 +79,9 @@ pub struct AppState {
|
|||||||
pending: PendingStore,
|
pending: PendingStore,
|
||||||
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||||
recovery: RecoveryStore,
|
recovery: RecoveryStore,
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
#[allow(dead_code)] // wired through D-Bus in Task 5.
|
||||||
|
totp: TotpStore,
|
||||||
userdb: Mutex<UserDb>,
|
userdb: Mutex<UserDb>,
|
||||||
authn: Arc<dyn Authenticator>,
|
authn: Arc<dyn Authenticator>,
|
||||||
applier: PolicyApplier,
|
applier: PolicyApplier,
|
||||||
@@ -80,12 +92,16 @@ impl AppState {
|
|||||||
let policy = PolicyStore::new(cfg.policy_dir);
|
let policy = PolicyStore::new(cfg.policy_dir);
|
||||||
let pending = PendingStore::new(cfg.pending_dir);
|
let pending = PendingStore::new(cfg.pending_dir);
|
||||||
let recovery = RecoveryStore::new(cfg.recovery_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 userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?);
|
||||||
let applier = PolicyApplier::new(cfg.pam_profile_path, cfg.pam_auth_update);
|
let applier = PolicyApplier::new(cfg.pam_profile_path, cfg.pam_auth_update);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
policy,
|
policy,
|
||||||
pending,
|
pending,
|
||||||
recovery,
|
recovery,
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
totp,
|
||||||
userdb,
|
userdb,
|
||||||
authn,
|
authn,
|
||||||
applier,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -293,6 +340,8 @@ mod tests {
|
|||||||
policy_dir: d.join("policy.d"),
|
policy_dir: d.join("policy.d"),
|
||||||
pending_dir: d.join("pending"),
|
pending_dir: d.join("pending"),
|
||||||
recovery_dir: d.join("recovery"),
|
recovery_dir: d.join("recovery"),
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
totp_dir: d.join("totp"),
|
||||||
userdb_path: d.join("users.db"),
|
userdb_path: d.join("users.db"),
|
||||||
pam_profile_path: d.join("authforge-pamconf"),
|
pam_profile_path: d.join("authforge-pamconf"),
|
||||||
pam_auth_update: shim,
|
pam_auth_update: shim,
|
||||||
@@ -353,6 +402,8 @@ mod tests {
|
|||||||
policy_dir,
|
policy_dir,
|
||||||
pending_dir: d.path().join("pending"),
|
pending_dir: d.path().join("pending"),
|
||||||
recovery_dir: d.path().join("recovery"),
|
recovery_dir: d.path().join("recovery"),
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
totp_dir: d.path().join("totp"),
|
||||||
userdb_path: d.path().join("users.db"),
|
userdb_path: d.path().join("users.db"),
|
||||||
pam_profile_path: d.path().join("authforge-pamconf"),
|
pam_profile_path: d.path().join("authforge-pamconf"),
|
||||||
pam_auth_update: shim,
|
pam_auth_update: shim,
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ pub(crate) mod pending;
|
|||||||
pub(crate) mod policy;
|
pub(crate) mod policy;
|
||||||
pub(crate) mod recovery;
|
pub(crate) mod recovery;
|
||||||
pub(crate) mod safe_user;
|
pub(crate) mod safe_user;
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
pub(crate) mod totp;
|
||||||
pub(crate) mod userdb;
|
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
|
Package: authforge-daemon
|
||||||
Architecture: any
|
Architecture: any
|
||||||
Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-u2f, libfido2-1, dbus, policykit-1
|
Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-u2f, libfido2-1, dbus, policykit-1
|
||||||
|
Recommends: libpam-google-authenticator
|
||||||
Description: System daemon for authforge MFA management
|
Description: System daemon for authforge MFA management
|
||||||
Provides the privileged D-Bus service that orchestrates enrollment,
|
Provides the privileged D-Bus service that orchestrates enrollment,
|
||||||
policy edits, and lockout-prevention checks.
|
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>
|
</defaults>
|
||||||
</action>
|
</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>
|
</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` |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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** |
|
| 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 |
|
| 18 | User docs + onboarding site | 3 days | **Spec'd** — anytime slot |
|
||||||
| **R** | **v1.0 release** | 1 day | — |
|
| **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`.
|
**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)
|
# Phase 0: Repository Scaffolding (FULLY DETAILED)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ license.workspace = true
|
|||||||
name = "authforge"
|
name = "authforge"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["totp"]
|
||||||
|
totp = ["dep:qrcode"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gtk = { package = "gtk4", version = "0.8" }
|
gtk = { package = "gtk4", version = "0.8" }
|
||||||
adw = { package = "libadwaita", version = "0.6", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.6", features = ["v1_5"] }
|
||||||
@@ -16,3 +20,4 @@ anyhow = { workspace = true }
|
|||||||
zbus = { workspace = true }
|
zbus = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
futures-util = { 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.
|
#[allow(dead_code)] // wired through keys_page.rs in Task 4.
|
||||||
pub(crate) fn current_user() -> String {
|
pub(crate) fn current_user() -> String {
|
||||||
std::env::var("USER").unwrap_or_else(|_| "unknown".into())
|
std::env::var("USER").unwrap_or_else(|_| "unknown".into())
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ mod keys_page;
|
|||||||
mod policy_form;
|
mod policy_form;
|
||||||
mod policy_page;
|
mod policy_page;
|
||||||
mod recovery_page;
|
mod recovery_page;
|
||||||
|
#[cfg(feature = "totp")]
|
||||||
|
mod totp_page;
|
||||||
|
|
||||||
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
||||||
|
|
||||||
@@ -68,6 +70,12 @@ fn main() -> glib::ExitCode {
|
|||||||
let recovery = recovery_page::RecoveryPage::new(ctx.clone());
|
let recovery = recovery_page::RecoveryPage::new(ctx.clone());
|
||||||
stack.add_titled(&recovery.root, Some("recovery"), "Recovery");
|
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
|
// ToolbarView is libadwaita v1_4-gated; the project sticks with the
|
||||||
// simpler Box layout for portability (see commit c6a5e94).
|
// simpler Box layout for portability (see commit c6a5e94).
|
||||||
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
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