From 56ba4dd924b4f26608c17d561154c6b8a01ec386 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 09:49:44 -0700 Subject: [PATCH] feat(pam): recovery-code mode with Argon2id verify and pending re-enroll handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-side: * New mode=recovery argv branch in pam_sm_authenticate. Reads the two-line /var/lib/authforge/recovery/ 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) --- daemon/src/policy_apply.rs | 26 +++++++- pam/Makefile | 2 +- pam/TESTING.md | 40 ++++++++++- pam/pam_authforge_pending.c | 129 ++++++++++++++++++++++++++++++++++-- 4 files changed, 188 insertions(+), 9 deletions(-) diff --git a/daemon/src/policy_apply.rs b/daemon/src/policy_apply.rs index 9b7de84..cc903fe 100644 --- a/daemon/src/policy_apply.rs +++ b/daemon/src/policy_apply.rs @@ -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(); diff --git a/pam/Makefile b/pam/Makefile index 39fd1f5..3f638b8 100644 --- a/pam/Makefile +++ b/pam/Makefile @@ -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 diff --git a/pam/TESTING.md b/pam/TESTING.md index 830a7ca..993a378 100644 --- a/pam/TESTING.md +++ b/pam/TESTING.md @@ -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/` mode 0600. +The two-line format is `\n\n`. + ## CI status CI cannot run `pamtester` against a real PAM stack without root and a VM, so diff --git a/pam/pam_authforge_pending.c b/pam/pam_authforge_pending.c index 795e8ed..95263f8 100644 --- a/pam/pam_authforge_pending.c +++ b/pam/pam_authforge_pending.c @@ -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/; ENOENT -> PAM_IGNORE. + * 3. Read the file's two lines: \n\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 #include +#include #include +#include #include +#include #include #include #include +#include +#include #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/. 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);