Merge branch 'feature/phase-12-recovery-backend'
# Conflicts: # docs/plans/2026-04-26-authforge-implementation.md
This commit is contained in:
46
Cargo.lock
generated
46
Cargo.lock
generated
@@ -90,6 +90,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -283,6 +295,7 @@ name = "authforge-daemon"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"authforge-common",
|
"authforge-common",
|
||||||
"ctap-hid-fido2",
|
"ctap-hid-fido2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -328,6 +341,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -340,6 +359,15 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -645,6 +673,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1697,6 +1726,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2129,6 +2169,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ notify = "6"
|
|||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
argon2 = "0.5"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
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};
|
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
|
||||||
|
|
||||||
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";
|
||||||
@@ -56,4 +56,12 @@ impl Daemon {
|
|||||||
pub async fn generate_recovery_code(&self, user: &str) -> Result<String> {
|
pub async fn generate_recovery_code(&self, user: &str) -> Result<String> {
|
||||||
Ok(self.proxy.call("GenerateRecoveryCode", &(user,)).await?)
|
Ok(self.proxy.call("GenerateRecoveryCode", &(user,)).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_recovery_codes(&self) -> Result<Vec<RecoveryCodeSummary>> {
|
||||||
|
Ok(self.proxy.call("ListRecoveryCodes", &()).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_recovery_code(&self, user: &str) -> Result<bool> {
|
||||||
|
Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,10 +212,33 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
super::Cmd::Recovery {
|
super::Cmd::Recovery {
|
||||||
cmd: super::RecoveryCmd::List { user: _ },
|
cmd: super::RecoveryCmd::List,
|
||||||
} => {
|
} => {
|
||||||
|
let entries = d.list_recovery_codes().await?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&entries)?);
|
||||||
|
} else if entries.is_empty() {
|
||||||
|
println!("(no active recovery codes)");
|
||||||
|
} else {
|
||||||
|
for e in &entries {
|
||||||
|
println!("{:<24} expires_unix={}", e.user, e.expires_unix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super::Cmd::Recovery {
|
||||||
|
cmd: super::RecoveryCmd::Revoke { user },
|
||||||
|
} => {
|
||||||
|
let removed = d.revoke_recovery_code(&user).await?;
|
||||||
|
if removed {
|
||||||
if !json {
|
if !json {
|
||||||
println!("(ListRecovery lands in Phase 12)");
|
println!("revoked recovery code for {user}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !json {
|
||||||
|
eprintln!("no recovery code for {user}");
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ enum PendingCmd {
|
|||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum RecoveryCmd {
|
enum RecoveryCmd {
|
||||||
Generate { user: String },
|
Generate { user: String },
|
||||||
List { user: String },
|
List,
|
||||||
|
Revoke { user: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -174,4 +175,26 @@ mod tests {
|
|||||||
let c = Cli::try_parse_from(["authforgectl", "--json", "status"]).unwrap();
|
let c = Cli::try_parse_from(["authforgectl", "--json", "status"]).unwrap();
|
||||||
assert!(c.json);
|
assert!(c.json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_recovery_list_takes_no_args() {
|
||||||
|
let c = Cli::try_parse_from(["authforgectl", "recovery", "list"]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
c.cmd,
|
||||||
|
Cmd::Recovery {
|
||||||
|
cmd: RecoveryCmd::List
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_recovery_revoke_with_user() {
|
||||||
|
let c = Cli::try_parse_from(["authforgectl", "recovery", "revoke", "alice"]).unwrap();
|
||||||
|
match c.cmd {
|
||||||
|
Cmd::Recovery {
|
||||||
|
cmd: RecoveryCmd::Revoke { user },
|
||||||
|
} => assert_eq!(user, "alice"),
|
||||||
|
_ => panic!("wrong subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ pub struct PolicyApplyResult {
|
|||||||
pub violations: Vec<Violation>,
|
pub violations: Vec<Violation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
|
||||||
|
pub struct RecoveryCodeSummary {
|
||||||
|
pub user: String,
|
||||||
|
pub expires_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ rusqlite = { workspace = true }
|
|||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
ctap-hid-fido2 = { workspace = true }
|
ctap-hid-fido2 = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
|
argon2 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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;
|
||||||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult};
|
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct AuthForge {
|
pub struct AuthForge {
|
||||||
@@ -129,13 +129,39 @@ impl AuthForge {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_recovery_code(&self, _user: String) -> zbus::fdo::Result<String> {
|
async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result<String> {
|
||||||
self.authz("io.dangerousthings.AuthForge.generate-recovery")
|
self.authz("io.dangerousthings.AuthForge.generate-recovery")
|
||||||
.await?;
|
.await?;
|
||||||
// Stub: 8 random digits. Phase 12 replaces with Argon2id-backed real flow.
|
self.state
|
||||||
use rand::Rng;
|
.issue_recovery(&user)
|
||||||
let n: u32 = rand::rng().random_range(0..100_000_000);
|
.await
|
||||||
Ok(format!("{n:08}"))
|
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_recovery_codes(&self) -> zbus::fdo::Result<Vec<RecoveryCodeSummary>> {
|
||||||
|
self.authz("io.dangerousthings.AuthForge.list-recovery")
|
||||||
|
.await?;
|
||||||
|
let entries = self
|
||||||
|
.state
|
||||||
|
.list_recovery()
|
||||||
|
.await
|
||||||
|
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||||
|
Ok(entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| RecoveryCodeSummary {
|
||||||
|
user: e.user,
|
||||||
|
expires_unix: e.expires_unix,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_recovery_code(&self, user: String) -> zbus::fdo::Result<bool> {
|
||||||
|
self.authz("io.dangerousthings.AuthForge.revoke-recovery")
|
||||||
|
.await?;
|
||||||
|
self.state
|
||||||
|
.revoke_recovery(&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
|
||||||
@@ -220,6 +246,7 @@ central_path = "{}"
|
|||||||
crate::state::StorageConfig {
|
crate::state::StorageConfig {
|
||||||
policy_dir,
|
policy_dir,
|
||||||
pending_dir: tmp.path().join("pending"),
|
pending_dir: tmp.path().join("pending"),
|
||||||
|
recovery_dir: tmp.path().join("recovery"),
|
||||||
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,
|
||||||
@@ -369,6 +396,53 @@ central_path = "{}"
|
|||||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_recovery_returns_issued_users() {
|
||||||
|
use authforge_common::types::RecoveryCodeSummary;
|
||||||
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||||
|
let p = proxy(&client).await;
|
||||||
|
let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
|
||||||
|
let _: String = p.call("GenerateRecoveryCode", &("bob",)).await.unwrap();
|
||||||
|
let entries: Vec<RecoveryCodeSummary> = p.call("ListRecoveryCodes", &()).await.unwrap();
|
||||||
|
let users: Vec<_> = entries.into_iter().map(|e| e.user).collect();
|
||||||
|
assert_eq!(users, vec!["alice".to_string(), "bob".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn revoke_recovery_removes_entry() {
|
||||||
|
use authforge_common::types::RecoveryCodeSummary;
|
||||||
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||||
|
let p = proxy(&client).await;
|
||||||
|
let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
|
||||||
|
let removed: bool = p.call("RevokeRecoveryCode", &("alice",)).await.unwrap();
|
||||||
|
assert!(removed);
|
||||||
|
let entries: Vec<RecoveryCodeSummary> = p.call("ListRecoveryCodes", &()).await.unwrap();
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn revoke_recovery_returns_false_on_missing_user() {
|
||||||
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||||
|
let p = proxy(&client).await;
|
||||||
|
let removed: bool = p.call("RevokeRecoveryCode", &("ghost",)).await.unwrap();
|
||||||
|
assert!(!removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn generate_recovery_code_persists_argon2id_hash_on_disk() {
|
||||||
|
let (_srv, client, _state, tmp) = p2p_pair().await;
|
||||||
|
let p = proxy(&client).await;
|
||||||
|
let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
|
||||||
|
let body = std::fs::read_to_string(tmp.path().join("recovery").join("alice")).unwrap();
|
||||||
|
let mut lines = body.lines();
|
||||||
|
// Line 1: expires_unix (numeric).
|
||||||
|
let expires: u64 = lines.next().unwrap().parse().unwrap();
|
||||||
|
assert!(expires > 0);
|
||||||
|
// Line 2: argon2id PHC string.
|
||||||
|
let phc = lines.next().unwrap();
|
||||||
|
assert!(phc.starts_with("$argon2id$"), "phc was {phc}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn enrollment_succeeded_signal_fires_on_enroll_own() {
|
async fn enrollment_succeeded_signal_fires_on_enroll_own() {
|
||||||
use futures_util::stream::StreamExt;
|
use futures_util::stream::StreamExt;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod fido;
|
|||||||
mod lockout;
|
mod lockout;
|
||||||
mod policy_apply;
|
mod policy_apply;
|
||||||
mod polkit;
|
mod polkit;
|
||||||
|
mod recovery;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,15 @@ 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))
|
||||||
});
|
});
|
||||||
|
|
||||||
let auth_lines = if any_required_fido2 {
|
// 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 main_lines = if any_required_fido2 {
|
||||||
// Per design doc § pam-auth-update profile.
|
// 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()
|
" [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()
|
||||||
} else {
|
} else {
|
||||||
@@ -102,6 +110,8 @@ pub(crate) fn render_profile(p: &Policy) -> String {
|
|||||||
" [success=ok default=die] pam_authforge_pending.so".to_string()
|
" [success=ok default=die] pam_authforge_pending.so".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let auth_lines = format!("{recovery_line}\n{main_lines}");
|
||||||
|
|
||||||
let default = if any_required_fido2 { "yes" } else { "no" };
|
let default = if any_required_fido2 { "yes" } else { "no" };
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
@@ -126,6 +136,20 @@ mod tests {
|
|||||||
assert!(!body.contains("pam_u2f.so"));
|
assert!(!body.contains("pam_u2f.so"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_always_includes_recovery_line_before_other_modules() {
|
||||||
|
let body = render_profile(&Policy::default());
|
||||||
|
let recovery_idx = body.find("mode=recovery").expect("recovery line present");
|
||||||
|
let backstop_idx = body
|
||||||
|
.rfind("pam_authforge_pending.so")
|
||||||
|
.expect("default backstop present");
|
||||||
|
// Recovery line lives BEFORE the default-mode backstop in the rendered profile.
|
||||||
|
assert!(
|
||||||
|
recovery_idx < backstop_idx,
|
||||||
|
"recovery line should precede the default backstop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_required_fido2_includes_pam_u2f_default_yes() {
|
fn render_required_fido2_includes_pam_u2f_default_yes() {
|
||||||
let mut stacks = BTreeMap::new();
|
let mut stacks = BTreeMap::new();
|
||||||
|
|||||||
80
daemon/src/recovery.rs
Normal file
80
daemon/src/recovery.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//! Pure recovery-code logic: generation, Argon2id hashing, verification.
|
||||||
|
//! No I/O — that lives in `storage::recovery::RecoveryStore`.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // wired through RecoveryStore (Task 4) and AppState (Task 5).
|
||||||
|
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{
|
||||||
|
rand_core::OsRng as PhcOsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
|
||||||
|
},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
/// Default validity window for a freshly generated recovery code. 7 days:
|
||||||
|
/// long enough for an admin to mail the code to a remote user, short enough
|
||||||
|
/// to limit blast radius if the file leaks.
|
||||||
|
pub(crate) const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub(crate) enum RecoveryError {
|
||||||
|
#[error("argon2: {0}")]
|
||||||
|
Argon2(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh 8-digit recovery code.
|
||||||
|
pub(crate) fn generate_code() -> String {
|
||||||
|
let n: u32 = rand::rng().random_range(0..100_000_000);
|
||||||
|
format!("{n:08}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a code with Argon2id, producing a PHC-format string.
|
||||||
|
pub(crate) fn hash_code(code: &str) -> Result<String, RecoveryError> {
|
||||||
|
let salt = SaltString::generate(&mut PhcOsRng);
|
||||||
|
let argon = Argon2::default();
|
||||||
|
let phc = argon
|
||||||
|
.hash_password(code.as_bytes(), &salt)
|
||||||
|
.map_err(|e| RecoveryError::Argon2(e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
Ok(phc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time verify a candidate code against a stored PHC hash.
|
||||||
|
pub(crate) fn verify_code(candidate: &str, phc: &str) -> Result<bool, RecoveryError> {
|
||||||
|
let parsed = PasswordHash::new(phc).map_err(|e| RecoveryError::Argon2(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(candidate.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_code_is_eight_digits() {
|
||||||
|
for _ in 0..50 {
|
||||||
|
let c = generate_code();
|
||||||
|
assert_eq!(c.len(), 8);
|
||||||
|
assert!(c.chars().all(|ch| ch.is_ascii_digit()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_roundtrips_correct_code() {
|
||||||
|
let phc = hash_code("12345678").unwrap();
|
||||||
|
assert!(phc.starts_with("$argon2id$"), "phc was {phc}");
|
||||||
|
assert!(verify_code("12345678", &phc).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_wrong_code() {
|
||||||
|
let phc = hash_code("12345678").unwrap();
|
||||||
|
assert!(!verify_code("87654321", &phc).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_errors_on_malformed_phc() {
|
||||||
|
assert!(verify_code("12345678", "not a phc string").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use crate::policy_apply::{ApplyError, PolicyApplier};
|
|||||||
use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver};
|
use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver};
|
||||||
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::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::{Credential, Method, PendingFlag, PolicyApplyResult, Transport};
|
use authforge_common::types::{Credential, Method, PendingFlag, PolicyApplyResult, Transport};
|
||||||
@@ -20,6 +21,8 @@ pub enum StateError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Pending(#[from] PendingError),
|
Pending(#[from] PendingError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
Recovery(#[from] RecoveryStoreError),
|
||||||
|
#[error(transparent)]
|
||||||
Creds(#[from] CredsError),
|
Creds(#[from] CredsError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
UserDb(#[from] UserDbError),
|
UserDb(#[from] UserDbError),
|
||||||
@@ -32,6 +35,7 @@ pub enum StateError {
|
|||||||
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 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.
|
||||||
@@ -51,6 +55,7 @@ impl StorageConfig {
|
|||||||
Self {
|
Self {
|
||||||
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"),
|
||||||
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"),
|
||||||
@@ -61,6 +66,8 @@ impl StorageConfig {
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
policy: PolicyStore,
|
policy: PolicyStore,
|
||||||
pending: PendingStore,
|
pending: PendingStore,
|
||||||
|
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||||
|
recovery: RecoveryStore,
|
||||||
userdb: Mutex<UserDb>,
|
userdb: Mutex<UserDb>,
|
||||||
authn: Arc<dyn Authenticator>,
|
authn: Arc<dyn Authenticator>,
|
||||||
applier: PolicyApplier,
|
applier: PolicyApplier,
|
||||||
@@ -70,11 +77,13 @@ impl AppState {
|
|||||||
pub fn open(cfg: StorageConfig, authn: Arc<dyn Authenticator>) -> Result<Self, StateError> {
|
pub fn open(cfg: StorageConfig, authn: Arc<dyn Authenticator>) -> Result<Self, StateError> {
|
||||||
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 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,
|
||||||
userdb,
|
userdb,
|
||||||
authn,
|
authn,
|
||||||
applier,
|
applier,
|
||||||
@@ -222,6 +231,23 @@ impl AppState {
|
|||||||
Ok(self.pending.clear(user)?)
|
Ok(self.pending.clear(user)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||||
|
pub async fn issue_recovery(&self, user: &str) -> Result<String, StateError> {
|
||||||
|
Ok(self
|
||||||
|
.recovery
|
||||||
|
.issue(user, crate::recovery::DEFAULT_TTL_SECS)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||||
|
pub async fn list_recovery(&self) -> Result<Vec<RecoveryEntry>, StateError> {
|
||||||
|
Ok(self.recovery.list()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||||
|
pub async fn revoke_recovery(&self, user: &str) -> Result<bool, StateError> {
|
||||||
|
Ok(self.recovery.revoke(user)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Tests assert pending flag round-trips through SetPendingFlag /
|
/// Tests assert pending flag round-trips through SetPendingFlag /
|
||||||
/// ClearPendingFlag via this read accessor. Production readers (PAM
|
/// ClearPendingFlag via this read accessor. Production readers (PAM
|
||||||
/// module, GUI status) read the on-disk file directly in later phases.
|
/// module, GUI status) read the on-disk file directly in later phases.
|
||||||
@@ -250,6 +276,7 @@ mod tests {
|
|||||||
StorageConfig {
|
StorageConfig {
|
||||||
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"),
|
||||||
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,
|
||||||
@@ -309,6 +336,7 @@ mod tests {
|
|||||||
StorageConfig {
|
StorageConfig {
|
||||||
policy_dir,
|
policy_dir,
|
||||||
pending_dir: d.path().join("pending"),
|
pending_dir: d.path().join("pending"),
|
||||||
|
recovery_dir: d.path().join("recovery"),
|
||||||
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,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
pub(crate) mod credentials;
|
pub(crate) mod credentials;
|
||||||
pub(crate) mod pending;
|
pub(crate) mod pending;
|
||||||
pub(crate) mod policy;
|
pub(crate) mod policy;
|
||||||
|
pub(crate) mod recovery;
|
||||||
|
pub(crate) mod safe_user;
|
||||||
pub(crate) mod userdb;
|
pub(crate) mod userdb;
|
||||||
|
|||||||
@@ -24,15 +24,9 @@ impl PendingStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user_path(&self, user: &str) -> Result<PathBuf, PendingError> {
|
fn user_path(&self, user: &str) -> Result<PathBuf, PendingError> {
|
||||||
if user.is_empty()
|
super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e {
|
||||||
|| user.contains('/')
|
super::safe_user::SegmentError::Invalid(s) => PendingError::InvalidUser(s),
|
||||||
|| user.contains('\0')
|
})
|
||||||
|| user == "."
|
|
||||||
|| user == ".."
|
|
||||||
{
|
|
||||||
return Err(PendingError::InvalidUser(user.to_string()));
|
|
||||||
}
|
|
||||||
Ok(self.dir.join(user))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&self, user: &str, flag: &PendingFlag) -> Result<(), PendingError> {
|
pub fn set(&self, user: &str, flag: &PendingFlag) -> Result<(), PendingError> {
|
||||||
|
|||||||
248
daemon/src/storage/recovery.rs
Normal file
248
daemon/src/storage/recovery.rs
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
//! On-disk persistence for recovery codes. One file per user under
|
||||||
|
//! `cfg.recovery_dir`, mode 0600, root-owned.
|
||||||
|
//!
|
||||||
|
//! File format (two lines, UTF-8, trailing LFs):
|
||||||
|
//! <expires_unix>\n<argon2id-PHC-string>\n
|
||||||
|
//! Picked over JSON so the C PAM module can parse it without linking json-c.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // wired through AppState in Task 5.
|
||||||
|
|
||||||
|
use crate::recovery::{hash_code, verify_code};
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub(crate) enum RecoveryStoreError {
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("invalid username: {0:?}")]
|
||||||
|
InvalidUser(String),
|
||||||
|
#[error("malformed recovery file for {0:?}")]
|
||||||
|
Malformed(String),
|
||||||
|
#[error("hash error: {0}")]
|
||||||
|
Hash(String),
|
||||||
|
#[error("system clock before unix epoch")]
|
||||||
|
Clock,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct RecoveryEntry {
|
||||||
|
pub user: String,
|
||||||
|
pub expires_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RecoveryStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecoveryStore {
|
||||||
|
pub fn new(dir: PathBuf) -> Self {
|
||||||
|
Self { dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_path(&self, user: &str) -> Result<PathBuf, RecoveryStoreError> {
|
||||||
|
super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e {
|
||||||
|
super::safe_user::SegmentError::Invalid(s) => RecoveryStoreError::InvalidUser(s),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate, hash, and persist a fresh code for `user`. The file is
|
||||||
|
/// written atomically (temp + rename) so a concurrent reader never sees
|
||||||
|
/// a half-written file. Mode is set to 0600 before rename. Returns the
|
||||||
|
/// plaintext code so the caller can show it to the admin once.
|
||||||
|
pub fn issue(&self, user: &str, ttl_secs: u64) -> Result<String, RecoveryStoreError> {
|
||||||
|
fs::create_dir_all(&self.dir)?;
|
||||||
|
// Tighten directory mode so a non-root snoop can't list users.
|
||||||
|
fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?;
|
||||||
|
|
||||||
|
let path = self.user_path(user)?;
|
||||||
|
let code = crate::recovery::generate_code();
|
||||||
|
let phc = hash_code(&code).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?;
|
||||||
|
let expires = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| RecoveryStoreError::Clock)?
|
||||||
|
.as_secs()
|
||||||
|
.saturating_add(ttl_secs);
|
||||||
|
|
||||||
|
let body = format!("{expires}\n{phc}\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)?;
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all users with a recovery code on file (expired or not). Sorted
|
||||||
|
/// alphabetically for stable display.
|
||||||
|
pub fn list(&self) -> Result<Vec<RecoveryEntry>, RecoveryStoreError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let rd = match fs::read_dir(&self.dir) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
for entry in rd {
|
||||||
|
let entry = entry?;
|
||||||
|
if !entry.file_type()?.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let user = entry.file_name().to_string_lossy().to_string();
|
||||||
|
// Skip atomic-rename leftovers.
|
||||||
|
if user.ends_with(".tmp") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(e) = self.entry_for(&user) {
|
||||||
|
out.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.user.cmp(&b.user));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry_for(&self, user: &str) -> Result<RecoveryEntry, RecoveryStoreError> {
|
||||||
|
let path = self.user_path(user)?;
|
||||||
|
let body = fs::read_to_string(&path)?;
|
||||||
|
let mut lines = body.lines();
|
||||||
|
let expires: u64 = lines
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?;
|
||||||
|
Ok(RecoveryEntry {
|
||||||
|
user: user.to_string(),
|
||||||
|
expires_unix: expires,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the recovery file for `user`. Returns true if a file was
|
||||||
|
/// removed, false if there was nothing there.
|
||||||
|
pub fn revoke(&self, user: &str) -> Result<bool, RecoveryStoreError> {
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a candidate code for `user`. On match: deletes the recovery
|
||||||
|
/// file (one-shot semantics) and returns Ok(true). On expired: deletes
|
||||||
|
/// the file as a side-effect cleanup and returns Ok(false).
|
||||||
|
pub fn verify_and_consume(
|
||||||
|
&self,
|
||||||
|
user: &str,
|
||||||
|
candidate: &str,
|
||||||
|
) -> Result<bool, RecoveryStoreError> {
|
||||||
|
let path = self.user_path(user)?;
|
||||||
|
let body = match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
let mut lines = body.lines();
|
||||||
|
let expires: u64 = lines
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| RecoveryStoreError::Malformed(user.to_string()))?;
|
||||||
|
let phc = lines
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| RecoveryStoreError::Malformed(user.to_string()))?;
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| RecoveryStoreError::Clock)?
|
||||||
|
.as_secs();
|
||||||
|
if now > expires {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok =
|
||||||
|
verify_code(candidate, phc).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?;
|
||||||
|
if ok {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
Ok(ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn store() -> (tempfile::TempDir, RecoveryStore) {
|
||||||
|
let d = tempdir().unwrap();
|
||||||
|
let s = RecoveryStore::new(d.path().to_path_buf());
|
||||||
|
(d, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_then_verify_consumes_file() {
|
||||||
|
let (_d, s) = store();
|
||||||
|
let code = s.issue("alice", 600).unwrap();
|
||||||
|
assert!(s.entry_for("alice").is_ok());
|
||||||
|
assert!(s.verify_and_consume("alice", &code).unwrap());
|
||||||
|
assert!(matches!(
|
||||||
|
s.entry_for("alice"),
|
||||||
|
Err(RecoveryStoreError::Io(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_with_wrong_code_does_not_consume() {
|
||||||
|
let (_d, s) = store();
|
||||||
|
s.issue("alice", 600).unwrap();
|
||||||
|
assert!(!s.verify_and_consume("alice", "00000000").unwrap());
|
||||||
|
assert!(s.entry_for("alice").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_entry_is_treated_as_absent_and_cleaned_up() {
|
||||||
|
let (_d, s) = store();
|
||||||
|
s.issue("alice", 0).unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||||
|
assert!(!s.verify_and_consume("alice", "anything").unwrap());
|
||||||
|
assert!(matches!(
|
||||||
|
s.entry_for("alice"),
|
||||||
|
Err(RecoveryStoreError::Io(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoke_returns_false_on_missing_user() {
|
||||||
|
let (_d, s) = store();
|
||||||
|
assert!(!s.revoke("ghost").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_sorts_users_alphabetically() {
|
||||||
|
let (_d, s) = store();
|
||||||
|
s.issue("charlie", 600).unwrap();
|
||||||
|
s.issue("alice", 600).unwrap();
|
||||||
|
s.issue("bob", 600).unwrap();
|
||||||
|
let users: Vec<_> = s.list().unwrap().into_iter().map(|e| e.user).collect();
|
||||||
|
assert_eq!(users, vec!["alice", "bob", "charlie"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_traversal_usernames() {
|
||||||
|
let (_d, s) = store();
|
||||||
|
for evil in ["", ".", "..", "a/b", "x\0y"] {
|
||||||
|
assert!(s.issue(evil, 600).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn issue_writes_file_with_mode_0600() {
|
||||||
|
let (_d, s) = store();
|
||||||
|
s.issue("alice", 600).unwrap();
|
||||||
|
let path = s.user_path("alice").unwrap();
|
||||||
|
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
daemon/src/storage/safe_user.rs
Normal file
40
daemon/src/storage/safe_user.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//! Shared username sanitizer for path-segment use across pending/recovery stores.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub(crate) enum SegmentError {
|
||||||
|
#[error("invalid username segment: {0:?}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject any username that could escape a single path segment. Centralized
|
||||||
|
/// so pending / recovery / future stores don't drift on what's "safe."
|
||||||
|
pub(crate) fn join_user_segment(dir: &Path, user: &str) -> Result<PathBuf, SegmentError> {
|
||||||
|
if user.is_empty() || user == "." || user == ".." || user.contains('/') || user.contains('\0') {
|
||||||
|
return Err(SegmentError::Invalid(user.to_string()));
|
||||||
|
}
|
||||||
|
Ok(dir.join(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_normal_username() {
|
||||||
|
let p = join_user_segment(Path::new("/tmp/x"), "alice").unwrap();
|
||||||
|
assert_eq!(p, PathBuf::from("/tmp/x/alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_traversal_and_separators() {
|
||||||
|
for evil in ["", ".", "..", "a/b", "../etc", "x\0y"] {
|
||||||
|
assert!(
|
||||||
|
join_user_segment(Path::new("/tmp/x"), evil).is_err(),
|
||||||
|
"{evil:?} should be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
debian/io.dangerousthings.AuthForge.policy
vendored
20
debian/io.dangerousthings.AuthForge.policy
vendored
@@ -77,4 +77,24 @@
|
|||||||
</defaults>
|
</defaults>
|
||||||
</action>
|
</action>
|
||||||
|
|
||||||
|
<action id="io.dangerousthings.AuthForge.list-recovery">
|
||||||
|
<description>List active recovery codes</description>
|
||||||
|
<message>Administrator authentication is required to list recovery codes.</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>
|
||||||
|
|
||||||
|
<action id="io.dangerousthings.AuthForge.revoke-recovery">
|
||||||
|
<description>Revoke a recovery code</description>
|
||||||
|
<message>Administrator authentication is required to revoke a recovery code.</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>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | **Spec'd** — opens after Phase 8 |
|
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | **Spec'd** — opens after Phase 8 |
|
||||||
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | **Spec'd** — needs Phase 6 ✓ + Phase 8 ✓ |
|
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | **Spec'd** — needs Phase 6 ✓ + Phase 8 ✓ |
|
||||||
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — opens now (needs Phase 2 ✓ + Phase 4 ✓) |
|
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — opens now (needs Phase 2 ✓ + Phase 4 ✓) |
|
||||||
| 12 | Recovery flow (codes + emergency unlock) | 4 days | **Spec'd** — opens now (needs Phase 6 ✓) |
|
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM landed; GUI tab queued in Phase 9 lane |
|
||||||
| 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** |
|
||||||
| 15 | `authforge-gnome-integration` (Users panel shortcut) | 3 days | ✅ **Done** (2026-04-27) — parallel subagent (gnome lane) |
|
| 15 | `authforge-gnome-integration` (Users panel shortcut) | 3 days | ✅ **Done** (2026-04-27) — parallel subagent (gnome lane) |
|
||||||
@@ -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: 12 of 19 phases code-complete (63%).** 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: 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -252,6 +252,29 @@ Single-lane execution in a worktree at `.claude/worktrees/phase-8-gui-keys`. Pla
|
|||||||
**Deferred to Phase 14 PPA-build VM smoke:**
|
**Deferred to Phase 14 PPA-build VM smoke:**
|
||||||
- All six steps in `gui/TESTING.md` (touch a real Yubikey through the modal; remove a credential; pull the key during enrollment to see the failure swap; kill the daemon to see the disconnected banner).
|
- All six steps in `gui/TESTING.md` (touch a real Yubikey through the modal; remove a credential; pull the key during enrollment to see the failure swap; kill the daemon to see the disconnected banner).
|
||||||
|
|
||||||
|
### Phase 12 backend closeout notes (2026-04-27)
|
||||||
|
|
||||||
|
Backend + PAM landed via the plan at [2026-04-27-phase-12-recovery-backend.md](2026-04-27-phase-12-recovery-backend.md). 9 commits on `feature/phase-12-recovery-backend`. GUI tab is queued in the Phase 9 lane (waiting on Phase 8 ✓).
|
||||||
|
|
||||||
|
**Done:**
|
||||||
|
- `daemon/src/storage/safe_user.rs` — extracted shared username path-segment sanitizer; `PendingStore` migrated to it. 2 new unit tests.
|
||||||
|
- `daemon/src/recovery.rs` — pure logic: `generate_code` (8 digits), `hash_code` (Argon2id PHC via OS RNG salt), `verify_code` (constant-time). 4 unit tests.
|
||||||
|
- `daemon/src/storage/recovery.rs` — `RecoveryStore`: atomic 0600 writes (temp+rename), two-line file format `<expires_unix>\n<argon2id-PHC>\n`, one-shot `verify_and_consume` semantics, expiry handling, alphabetical `list()`. 7 unit tests.
|
||||||
|
- `StorageConfig` gains `recovery_dir` field with `AUTHFORGE_RECOVERY_DIR` env override (default `/var/lib/authforge/recovery`). `AppState` exposes `issue_recovery` / `list_recovery` / `revoke_recovery`.
|
||||||
|
- D-Bus: real `GenerateRecoveryCode` (replaced stub), new `ListRecoveryCodes` (returns `Vec<RecoveryCodeSummary>`), new `RevokeRecoveryCode`. polkit policy gains `…list-recovery` + `…revoke-recovery` actions (`auth_admin_keep`). 4 new D-Bus integration tests including a real on-disk `$argon2id$` PHC round-trip.
|
||||||
|
- PAM module gains a `mode=recovery` argv branch. Reads the two-line file, `argon2_verify`s against `PAM_AUTHTOK`; on match unlinks the recovery file (one-shot) and writes `pending(re_enroll=true)`. Always returns `PAM_IGNORE` on failure paths so a missing/wrong code never blocks normal auth. Linked against `libargon2`.
|
||||||
|
- `policy_apply::render_profile` renders the recovery line first in the auth stack with `[success=done default=ignore]` so a successful recovery short-circuits the rest of the auth chain.
|
||||||
|
- CLI: `authforgectl recovery list` (was placeholder) and `authforgectl recovery revoke <user>`. List takes no args (matches D-Bus signature).
|
||||||
|
|
||||||
|
**Test count:** 7 cli + 13 common + 60 daemon = **80 tests** (was 5+13+42=60 at end of Phase 4+5). +20 tests across the 7 implementation tasks.
|
||||||
|
|
||||||
|
**Plan deviations:** none of substance.
|
||||||
|
|
||||||
|
**Deferred until Phase 14 VM smoke:**
|
||||||
|
- `make -C pam` against a host with `libargon2-dev` installed (not in the dev sandbox).
|
||||||
|
- `pamtester` recovery walkthrough — recipe lives in `pam/TESTING.md` § Smoke test 4.
|
||||||
|
- End-to-end recovery flow against a real PAM stack: admin issues code via CLI → user logs in entering it at the password prompt → recovery file deleted → pending(re_enroll=true) flag present → re-enrollment modal triggers on next login.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 0: Repository Scaffolding (FULLY DETAILED)
|
# Phase 0: Repository Scaffolding (FULLY DETAILED)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ CFLAGS ?= -Wall -Wextra -Werror -fPIC -O2
|
|||||||
LIBDIR ?= /usr/lib/$(shell dpkg-architecture -qDEB_HOST_MULTIARCH)/security
|
LIBDIR ?= /usr/lib/$(shell dpkg-architecture -qDEB_HOST_MULTIARCH)/security
|
||||||
|
|
||||||
pam_authforge_pending.so: pam_authforge_pending.c
|
pam_authforge_pending.so: pam_authforge_pending.c
|
||||||
$(CC) $(CFLAGS) -shared -o $@ $< -lpam
|
$(CC) $(CFLAGS) -shared -o $@ $< -lpam -largon2
|
||||||
|
|
||||||
install: pam_authforge_pending.so
|
install: pam_authforge_pending.so
|
||||||
install -D -m 0644 pam_authforge_pending.so $(DESTDIR)$(LIBDIR)/pam_authforge_pending.so
|
install -D -m 0644 pam_authforge_pending.so $(DESTDIR)$(LIBDIR)/pam_authforge_pending.so
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ needs root to install. The recipe below runs in a VM or container with
|
|||||||
## Build + install
|
## Build + install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt-get install -y libpam0g-dev pamtester
|
sudo apt-get install -y libpam0g-dev libargon2-dev pamtester
|
||||||
sudo make -C pam install
|
sudo make -C pam install
|
||||||
sudo install -D -m 0644 pam/test/authforge.pamd /etc/pam.d/authforge
|
sudo install -D -m 0644 pam/test/authforge.pamd /etc/pam.d/authforge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`libargon2-dev` is required for the recovery-mode verification path (Phase 12).
|
||||||
|
|
||||||
`make install` drops the `.so` into
|
`make install` drops the `.so` into
|
||||||
`/usr/lib/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/security/`.
|
`/usr/lib/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/security/`.
|
||||||
|
|
||||||
@@ -48,6 +50,42 @@ done
|
|||||||
Logs show up via `journalctl -t authforge_pending` (or wherever your
|
Logs show up via `journalctl -t authforge_pending` (or wherever your
|
||||||
distribution routes `pam_syslog` — typically `/var/log/auth.log`).
|
distribution routes `pam_syslog` — typically `/var/log/auth.log`).
|
||||||
|
|
||||||
|
## Smoke test 4: recovery-code path (Phase 12)
|
||||||
|
|
||||||
|
The module's recovery branch activates when invoked with `mode=recovery` in
|
||||||
|
the PAM stack. The pam-auth-update profile rendered by Phase 4 puts this
|
||||||
|
line first in the auth stack — a successful recovery code short-circuits
|
||||||
|
the rest of the auth chain via `[success=done default=ignore]`.
|
||||||
|
|
||||||
|
To exercise it manually with `pamtester` against a custom stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Issue a code via the daemon and stash the plaintext.
|
||||||
|
CODE=$(sudo authforgectl recovery generate "$USER" | tr -d '\n')
|
||||||
|
echo "issued $CODE"
|
||||||
|
|
||||||
|
# 2. Build a PAM stack file that runs ONLY the recovery-mode module.
|
||||||
|
sudo tee /etc/pam.d/authforge-recovery <<'EOF'
|
||||||
|
auth [success=done default=ignore] pam_authforge_pending.so mode=recovery
|
||||||
|
auth required pam_deny.so
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 3. Verify a matching code authenticates.
|
||||||
|
echo "$CODE" | pamtester -v authforge-recovery "$USER" authenticate
|
||||||
|
# Expect: PAM_SUCCESS, /var/lib/authforge/recovery/$USER deleted, and
|
||||||
|
# /var/lib/authforge/pending/$USER now contains re_enroll: true.
|
||||||
|
|
||||||
|
# 4. Verify a non-matching code falls through to pam_deny.
|
||||||
|
echo "$CODE" | pamtester -v authforge-recovery "$USER" authenticate
|
||||||
|
# Expect: failure (pam_deny) — the recovery file was already consumed.
|
||||||
|
|
||||||
|
# 5. Cleanup.
|
||||||
|
sudo rm /etc/pam.d/authforge-recovery /var/lib/authforge/pending/$USER
|
||||||
|
```
|
||||||
|
|
||||||
|
The recovery file is at `/var/lib/authforge/recovery/<user>` mode 0600.
|
||||||
|
The two-line format is `<expires_unix>\n<argon2id-PHC>\n`.
|
||||||
|
|
||||||
## CI status
|
## CI status
|
||||||
|
|
||||||
CI cannot run `pamtester` against a real PAM stack without root and a VM, so
|
CI cannot run `pamtester` against a real PAM stack without root and a VM, so
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
* pam_authforge_pending.so — backstop for AuthForge first-login enrollment.
|
* pam_authforge_pending.so — backstop for AuthForge first-login enrollment
|
||||||
|
* and recovery-code verification.
|
||||||
*
|
*
|
||||||
* Behavior:
|
* Modes (selected by argv):
|
||||||
|
* default — first-login pending-flag check (Phase 6).
|
||||||
|
* mode=recovery — verify a one-shot recovery code (Phase 12).
|
||||||
|
*
|
||||||
|
* Default-mode behavior:
|
||||||
* 1. Read PAM_USER.
|
* 1. Read PAM_USER.
|
||||||
* 2. Reject usernames that would let the path computation escape
|
* 2. Reject usernames that would let the path computation escape
|
||||||
* /var/lib/authforge/pending/.
|
* /var/lib/authforge/pending/.
|
||||||
@@ -10,20 +15,33 @@
|
|||||||
* - ENOENT -> return PAM_IGNORE (let the rest of the stack decide).
|
* - ENOENT -> return PAM_IGNORE (let the rest of the stack decide).
|
||||||
* - other -> log + return PAM_AUTH_ERR (fail closed).
|
* - other -> log + return PAM_AUTH_ERR (fail closed).
|
||||||
*
|
*
|
||||||
* Recovery codes (Phase 12) wire into a separate hook that's a no-op here.
|
* Recovery-mode behavior:
|
||||||
|
* 1. Read PAM_USER (same sanity checks).
|
||||||
|
* 2. stat() /var/lib/authforge/recovery/<user>; ENOENT -> PAM_IGNORE.
|
||||||
|
* 3. Read the file's two lines: <expires_unix>\n<argon2id-PHC>\n.
|
||||||
|
* 4. argon2_verify(phc, PAM_AUTHTOK). On match: unlink the recovery file,
|
||||||
|
* write a pending(re_enroll=true) flag for the user, return PAM_SUCCESS.
|
||||||
|
* On mismatch / expired / parse error: PAM_IGNORE (never fail closed in
|
||||||
|
* recovery mode — false negatives must not lock out normal login paths).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#define PAM_SM_AUTH
|
#define PAM_SM_AUTH
|
||||||
#include <security/pam_modules.h>
|
#include <security/pam_modules.h>
|
||||||
#include <security/pam_ext.h>
|
#include <security/pam_ext.h>
|
||||||
|
|
||||||
|
#include <argon2.h>
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <syslog.h>
|
#include <syslog.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#define PENDING_DIR "/var/lib/authforge/pending/"
|
#define PENDING_DIR "/var/lib/authforge/pending/"
|
||||||
|
#define RECOVERY_DIR "/var/lib/authforge/recovery/"
|
||||||
#define USER_MSG "Account setup incomplete. Please complete enrollment in the Authentication app."
|
#define USER_MSG "Account setup incomplete. Please complete enrollment in the Authentication app."
|
||||||
|
|
||||||
static int username_is_safe(const char *u) {
|
static int username_is_safe(const char *u) {
|
||||||
@@ -47,18 +65,117 @@ static int pending_flag_present(const char *user) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Read /var/lib/authforge/recovery/<user>. On success: *expires_out gets
|
||||||
|
* the UNIX timestamp from line 1, *phc_out is a heap-allocated copy of
|
||||||
|
* line 2 (caller frees). Returns 0 on success, -1 on any error. */
|
||||||
|
static int read_recovery_file(const char *user,
|
||||||
|
uint64_t *expires_out,
|
||||||
|
char **phc_out) {
|
||||||
|
*phc_out = NULL;
|
||||||
|
char path[1024];
|
||||||
|
int n = snprintf(path, sizeof(path), "%s%s", RECOVERY_DIR, user);
|
||||||
|
if (n < 0 || (size_t)n >= sizeof(path)) return -1;
|
||||||
|
FILE *f = fopen(path, "r");
|
||||||
|
if (!f) return -1;
|
||||||
|
char line1[64];
|
||||||
|
char line2[256];
|
||||||
|
if (!fgets(line1, sizeof(line1), f) || !fgets(line2, sizeof(line2), f)) {
|
||||||
|
fclose(f);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
line1[strcspn(line1, "\r\n")] = '\0';
|
||||||
|
line2[strcspn(line2, "\r\n")] = '\0';
|
||||||
|
char *end = NULL;
|
||||||
|
unsigned long long parsed = strtoull(line1, &end, 10);
|
||||||
|
if (end == NULL || *end != '\0' || end == line1) return -1;
|
||||||
|
*expires_out = (uint64_t)parsed;
|
||||||
|
*phc_out = strdup(line2);
|
||||||
|
return *phc_out ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Write a JSON pending(re_enroll=true) flag for `user`. Format mirrors
|
||||||
|
* authforge_common::types::PendingFlag's serde representation; the daemon
|
||||||
|
* round-trips it on next read. Returns 0 on success, -1 on error. */
|
||||||
|
static int write_pending_re_enroll(const char *user) {
|
||||||
|
char path[1024];
|
||||||
|
int n = snprintf(path, sizeof(path), "%s%s", PENDING_DIR, user);
|
||||||
|
if (n < 0 || (size_t)n >= sizeof(path)) return -1;
|
||||||
|
FILE *f = fopen(path, "w");
|
||||||
|
if (!f) return -1;
|
||||||
|
fprintf(f,
|
||||||
|
"{\n \"required_methods\": [\"fido2\"],\n"
|
||||||
|
" \"created_unix\": %ld,\n"
|
||||||
|
" \"deadline_unix\": 0,\n"
|
||||||
|
" \"re_enroll\": true\n}\n",
|
||||||
|
(long)time(NULL));
|
||||||
|
fclose(f);
|
||||||
|
chmod(path, 0644);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify a candidate code for `user` against the on-disk recovery file.
|
||||||
|
* On match: unlinks the recovery file (one-shot semantics), writes a
|
||||||
|
* pending(re_enroll=true) flag, returns 1. Otherwise returns 0. Never
|
||||||
|
* fails closed — returns 0 on any I/O / parse / expiry condition. */
|
||||||
|
static int recovery_code_matches(const char *user, const char *candidate) {
|
||||||
|
uint64_t expires = 0;
|
||||||
|
char *phc = NULL;
|
||||||
|
if (read_recovery_file(user, &expires, &phc) != 0) return 0;
|
||||||
|
|
||||||
|
int ok = 0;
|
||||||
|
if ((uint64_t)time(NULL) <= expires) {
|
||||||
|
if (argon2_verify(phc, candidate, strlen(candidate), Argon2_id) == ARGON2_OK) {
|
||||||
|
ok = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(phc);
|
||||||
|
if (ok) {
|
||||||
|
char path[1024];
|
||||||
|
int n = snprintf(path, sizeof(path), "%s%s", RECOVERY_DIR, user);
|
||||||
|
if (n > 0 && (size_t)n < sizeof(path)) {
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
write_pending_re_enroll(user);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags,
|
PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags,
|
||||||
int argc, const char **argv) {
|
int argc, const char **argv) {
|
||||||
(void)flags; (void)argc; (void)argv;
|
(void)flags;
|
||||||
|
|
||||||
|
int recovery_mode = 0;
|
||||||
|
for (int i = 0; i < argc; i++) {
|
||||||
|
if (strcmp(argv[i], "mode=recovery") == 0) {
|
||||||
|
recovery_mode = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const char *user = NULL;
|
const char *user = NULL;
|
||||||
if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || user == NULL) {
|
if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || user == NULL) {
|
||||||
pam_syslog(pamh, LOG_ERR, "authforge_pending: pam_get_user failed");
|
pam_syslog(pamh, LOG_ERR, "authforge_pending: pam_get_user failed");
|
||||||
return PAM_AUTH_ERR;
|
return recovery_mode ? PAM_IGNORE : PAM_AUTH_ERR;
|
||||||
}
|
}
|
||||||
if (!username_is_safe(user)) {
|
if (!username_is_safe(user)) {
|
||||||
pam_syslog(pamh, LOG_ERR, "authforge_pending: rejected unsafe username");
|
pam_syslog(pamh, LOG_ERR, "authforge_pending: rejected unsafe username");
|
||||||
return PAM_AUTH_ERR;
|
return recovery_mode ? PAM_IGNORE : PAM_AUTH_ERR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recovery_mode) {
|
||||||
|
const char *authtok = NULL;
|
||||||
|
if (pam_get_authtok(pamh, PAM_AUTHTOK, &authtok, NULL) != PAM_SUCCESS
|
||||||
|
|| authtok == NULL) {
|
||||||
|
return PAM_IGNORE;
|
||||||
|
}
|
||||||
|
if (recovery_code_matches(user, authtok)) {
|
||||||
|
pam_syslog(pamh, LOG_INFO,
|
||||||
|
"authforge_pending: recovery code accepted for %s", user);
|
||||||
|
return PAM_SUCCESS;
|
||||||
|
}
|
||||||
|
return PAM_IGNORE; /* let pam_unix / pam_u2f handle */
|
||||||
}
|
}
|
||||||
|
|
||||||
int present = pending_flag_present(user);
|
int present = pending_flag_present(user);
|
||||||
|
|||||||
Reference in New Issue
Block a user