/* * 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/. * - 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/; 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) { 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/. 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; }