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