feat(pam): recovery-code mode with Argon2id verify and pending re-enroll handoff
C-side: * New mode=recovery argv branch in pam_sm_authenticate. Reads the two-line /var/lib/authforge/recovery/<user> file, argon2_verify against PAM_AUTHTOK, on match unlinks the 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. * Makefile links -largon2 alongside -lpam. Daemon-side: * policy_apply::render_profile renders the recovery line first in the auth stack with [success=done default=ignore] — successful recovery short- circuits the rest, missing/wrong code falls through. * New policy_apply test asserts the recovery line precedes the default backstop. Doc: * pam/TESTING.md adds libargon2-dev to the build prereqs and a new Smoke test 4 walking through the manual recovery-code flow. C compile gate (make -C pam) requires libargon2-dev — flagged as a deferred verification step until a host with the dev package is available. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user