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"
|
||||
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]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.1"
|
||||
@@ -283,6 +295,7 @@ name = "authforge-daemon"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"authforge-common",
|
||||
"ctap-hid-fido2",
|
||||
"futures-util",
|
||||
@@ -328,6 +341,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -340,6 +359,15 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -645,6 +673,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1697,6 +1726,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2129,6 +2169,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
||||
@@ -33,3 +33,4 @@ notify = "6"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
futures-util = "0.3"
|
||||
hex = "0.4"
|
||||
argon2 = "0.5"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
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 OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
|
||||
@@ -56,4 +56,12 @@ impl Daemon {
|
||||
pub async fn generate_recovery_code(&self, user: &str) -> Result<String> {
|
||||
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 {
|
||||
cmd: super::RecoveryCmd::List { user: _ },
|
||||
cmd: super::RecoveryCmd::List,
|
||||
} => {
|
||||
if !json {
|
||||
println!("(ListRecovery lands in Phase 12)");
|
||||
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 {
|
||||
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)]
|
||||
enum RecoveryCmd {
|
||||
Generate { user: String },
|
||||
List { user: String },
|
||||
List,
|
||||
Revoke { user: String },
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -174,4 +175,26 @@ mod tests {
|
||||
let c = Cli::try_parse_from(["authforgectl", "--json", "status"]).unwrap();
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
|
||||
pub struct RecoveryCodeSummary {
|
||||
pub user: String,
|
||||
pub expires_unix: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -26,6 +26,7 @@ rusqlite = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
ctap-hid-fido2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::polkit::Polkit;
|
||||
use crate::state::AppState;
|
||||
use authforge_common::policy::Policy;
|
||||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult};
|
||||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AuthForge {
|
||||
@@ -129,13 +129,39 @@ impl AuthForge {
|
||||
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")
|
||||
.await?;
|
||||
// Stub: 8 random digits. Phase 12 replaces with Argon2id-backed real flow.
|
||||
use rand::Rng;
|
||||
let n: u32 = rand::rng().random_range(0..100_000_000);
|
||||
Ok(format!("{n:08}"))
|
||||
self.state
|
||||
.issue_recovery(&user)
|
||||
.await
|
||||
.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
|
||||
@@ -220,6 +246,7 @@ central_path = "{}"
|
||||
crate::state::StorageConfig {
|
||||
policy_dir,
|
||||
pending_dir: tmp.path().join("pending"),
|
||||
recovery_dir: tmp.path().join("recovery"),
|
||||
userdb_path: tmp.path().join("users.db"),
|
||||
pam_profile_path: tmp.path().join("authforge-pamconf"),
|
||||
pam_auth_update: shim,
|
||||
@@ -369,6 +396,53 @@ central_path = "{}"
|
||||
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]
|
||||
async fn enrollment_succeeded_signal_fires_on_enroll_own() {
|
||||
use futures_util::stream::StreamExt;
|
||||
|
||||
@@ -7,6 +7,7 @@ mod fido;
|
||||
mod lockout;
|
||||
mod policy_apply;
|
||||
mod polkit;
|
||||
mod recovery;
|
||||
mod state;
|
||||
mod storage;
|
||||
|
||||
|
||||
@@ -93,7 +93,15 @@ pub(crate) fn render_profile(p: &Policy) -> String {
|
||||
.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.
|
||||
" [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 {
|
||||
@@ -102,6 +110,8 @@ pub(crate) fn render_profile(p: &Policy) -> 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" };
|
||||
|
||||
format!(
|
||||
@@ -126,6 +136,20 @@ mod tests {
|
||||
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]
|
||||
fn render_required_fido2_includes_pam_u2f_default_yes() {
|
||||
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::pending::{PendingError, PendingStore};
|
||||
use crate::storage::policy::PolicyStore;
|
||||
use crate::storage::recovery::{RecoveryEntry, RecoveryStore, RecoveryStoreError};
|
||||
use crate::storage::userdb::{UserDb, UserDbError};
|
||||
use authforge_common::policy::{Policy, PolicyError};
|
||||
use authforge_common::types::{Credential, Method, PendingFlag, PolicyApplyResult, Transport};
|
||||
@@ -20,6 +21,8 @@ pub enum StateError {
|
||||
#[error(transparent)]
|
||||
Pending(#[from] PendingError),
|
||||
#[error(transparent)]
|
||||
Recovery(#[from] RecoveryStoreError),
|
||||
#[error(transparent)]
|
||||
Creds(#[from] CredsError),
|
||||
#[error(transparent)]
|
||||
UserDb(#[from] UserDbError),
|
||||
@@ -32,6 +35,7 @@ pub enum StateError {
|
||||
pub struct StorageConfig {
|
||||
pub policy_dir: PathBuf,
|
||||
pub pending_dir: PathBuf,
|
||||
pub recovery_dir: PathBuf,
|
||||
pub userdb_path: PathBuf,
|
||||
/// Where pam-auth-update reads the AuthForge profile from. Defaults to
|
||||
/// `/usr/share/pam-configs/authforge`; tests / non-root runs override.
|
||||
@@ -51,6 +55,7 @@ impl StorageConfig {
|
||||
Self {
|
||||
policy_dir: env("AUTHFORGE_POLICY_DIR", "/etc/authforge/policy.d"),
|
||||
pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"),
|
||||
recovery_dir: env("AUTHFORGE_RECOVERY_DIR", "/var/lib/authforge/recovery"),
|
||||
userdb_path: env("AUTHFORGE_USERDB", "/var/lib/authforge/users.db"),
|
||||
pam_profile_path: env("AUTHFORGE_PAMCONF_PATH", "/usr/share/pam-configs/authforge"),
|
||||
pam_auth_update: env("AUTHFORGE_PAM_AUTH_UPDATE", "/usr/sbin/pam-auth-update"),
|
||||
@@ -61,6 +66,8 @@ impl StorageConfig {
|
||||
pub struct AppState {
|
||||
policy: PolicyStore,
|
||||
pending: PendingStore,
|
||||
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||
recovery: RecoveryStore,
|
||||
userdb: Mutex<UserDb>,
|
||||
authn: Arc<dyn Authenticator>,
|
||||
applier: PolicyApplier,
|
||||
@@ -70,11 +77,13 @@ impl AppState {
|
||||
pub fn open(cfg: StorageConfig, authn: Arc<dyn Authenticator>) -> Result<Self, StateError> {
|
||||
let policy = PolicyStore::new(cfg.policy_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 applier = PolicyApplier::new(cfg.pam_profile_path, cfg.pam_auth_update);
|
||||
Ok(Self {
|
||||
policy,
|
||||
pending,
|
||||
recovery,
|
||||
userdb,
|
||||
authn,
|
||||
applier,
|
||||
@@ -222,6 +231,23 @@ impl AppState {
|
||||
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 /
|
||||
/// ClearPendingFlag via this read accessor. Production readers (PAM
|
||||
/// module, GUI status) read the on-disk file directly in later phases.
|
||||
@@ -250,6 +276,7 @@ mod tests {
|
||||
StorageConfig {
|
||||
policy_dir: d.join("policy.d"),
|
||||
pending_dir: d.join("pending"),
|
||||
recovery_dir: d.join("recovery"),
|
||||
userdb_path: d.join("users.db"),
|
||||
pam_profile_path: d.join("authforge-pamconf"),
|
||||
pam_auth_update: shim,
|
||||
@@ -309,6 +336,7 @@ mod tests {
|
||||
StorageConfig {
|
||||
policy_dir,
|
||||
pending_dir: d.path().join("pending"),
|
||||
recovery_dir: d.path().join("recovery"),
|
||||
userdb_path: d.path().join("users.db"),
|
||||
pam_profile_path: d.path().join("authforge-pamconf"),
|
||||
pam_auth_update: shim,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub(crate) mod credentials;
|
||||
pub(crate) mod pending;
|
||||
pub(crate) mod policy;
|
||||
pub(crate) mod recovery;
|
||||
pub(crate) mod safe_user;
|
||||
pub(crate) mod userdb;
|
||||
|
||||
@@ -24,15 +24,9 @@ impl PendingStore {
|
||||
}
|
||||
|
||||
fn user_path(&self, user: &str) -> Result<PathBuf, PendingError> {
|
||||
if user.is_empty()
|
||||
|| user.contains('/')
|
||||
|| user.contains('\0')
|
||||
|| user == "."
|
||||
|| user == ".."
|
||||
{
|
||||
return Err(PendingError::InvalidUser(user.to_string()));
|
||||
}
|
||||
Ok(self.dir.join(user))
|
||||
super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e {
|
||||
super::safe_user::SegmentError::Invalid(s) => PendingError::InvalidUser(s),
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
</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>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
| 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 ✓ |
|
||||
| 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 |
|
||||
| 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) |
|
||||
@@ -48,7 +48,7 @@
|
||||
| 18 | User docs + onboarding site | 3 days | **Spec'd** — anytime slot |
|
||||
| **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:**
|
||||
- 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)
|
||||
|
||||
@@ -2,7 +2,7 @@ CFLAGS ?= -Wall -Wextra -Werror -fPIC -O2
|
||||
LIBDIR ?= /usr/lib/$(shell dpkg-architecture -qDEB_HOST_MULTIARCH)/security
|
||||
|
||||
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 -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
|
||||
|
||||
```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 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
|
||||
`/usr/lib/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/security/`.
|
||||
|
||||
@@ -48,6 +50,42 @@ done
|
||||
Logs show up via `journalctl -t authforge_pending` (or wherever your
|
||||
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 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.
|
||||
* 2. Reject usernames that would let the path computation escape
|
||||
* /var/lib/authforge/pending/.
|
||||
@@ -10,20 +15,33 @@
|
||||
* - ENOENT -> return PAM_IGNORE (let the rest of the stack decide).
|
||||
* - 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
|
||||
#include <security/pam_modules.h>
|
||||
#include <security/pam_ext.h>
|
||||
|
||||
#include <argon2.h>
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <syslog.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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."
|
||||
|
||||
static int username_is_safe(const char *u) {
|
||||
@@ -47,18 +65,117 @@ static int pending_flag_present(const char *user) {
|
||||
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,
|
||||
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;
|
||||
if (pam_get_user(pamh, &user, NULL) != PAM_SUCCESS || user == NULL) {
|
||||
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)) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user