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:
michael
2026-04-27 09:49:44 -07:00
parent a420aa5f75
commit 56ba4dd924
4 changed files with 188 additions and 9 deletions

View File

@@ -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();