Files
authforge/pam/pam_authforge_pending.c
michael 56ba4dd924 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>
2026-04-27 09:49:44 -07:00

200 lines
6.9 KiB
C

/*
* pam_authforge_pending.so — backstop for AuthForge first-login enrollment
* and recovery-code verification.
*
* 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/.
* 3. stat() /var/lib/authforge/pending/<user>.
* - present -> emit user-facing message + return PAM_AUTH_ERR.
* - ENOENT -> return PAM_IGNORE (let the rest of the stack decide).
* - other -> log + return PAM_AUTH_ERR (fail closed).
*
* 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) {
if (u == NULL || u[0] == '\0') return 0;
if (strcmp(u, ".") == 0 || strcmp(u, "..") == 0) return 0;
for (const char *p = u; *p; ++p) {
if (*p == '/') return 0;
}
/* Reject any '..' substring as a defense-in-depth measure. */
if (strstr(u, "..") != NULL) return 0;
return 1;
}
static int pending_flag_present(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;
struct stat st;
if (stat(path, &st) == 0) return 1;
if (errno == ENOENT) return 0;
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;
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 recovery_mode ? PAM_IGNORE : PAM_AUTH_ERR;
}
if (!username_is_safe(user)) {
pam_syslog(pamh, LOG_ERR, "authforge_pending: rejected unsafe username");
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);
if (present < 0) {
pam_syslog(pamh, LOG_ERR, "authforge_pending: stat error for %s (errno=%d)", user, errno);
return PAM_AUTH_ERR; /* fail closed */
}
if (present == 0) {
return PAM_IGNORE;
}
pam_error(pamh, "%s", USER_MSG);
pam_syslog(pamh, LOG_INFO, "authforge_pending: denied %s (pending flag present)", user);
return PAM_AUTH_ERR;
}
PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags,
int argc, const char **argv) {
(void)pamh; (void)flags; (void)argc; (void)argv;
return PAM_SUCCESS;
}