Files
authforge/pam/TESTING.md
michael 22653ff4ff docs(pam): clarify safe_user contract — pre-filter, not authoritative boundary
x..y is correctly NOT rejected by the slash/dot/empty/NUL guard; the
recipe now documents this as an intentional regression test against
over-rejection. Frames canonicalize-then-verify as Phase 17 follow-up
hardening.
2026-04-27 13:00:11 -07:00

124 lines
4.7 KiB
Markdown

# Testing pam_authforge_pending.so
This module gates first-login enrollment. Its full behavior is impossible to
exercise from a unit test because PAM modules run inside a real PAM stack that
needs root to install. The recipe below runs in a VM or container with
`libpam0g-dev` and `pamtester` installed.
## Build + install
```bash
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/`.
## Smoke test 1: pending flag present → denied
```bash
sudo mkdir -p /var/lib/authforge/pending
sudo touch /var/lib/authforge/pending/$USER
pamtester -v authforge "$USER" authenticate
# Expect: "Account setup incomplete..." printed and a non-zero exit code.
```
## Smoke test 2: pending flag cleared → falls through
```bash
sudo rm /var/lib/authforge/pending/$USER
pamtester -v authforge "$USER" authenticate
# Expect: success (the test PAM stack file falls through to pam_unix after
# our PAM_IGNORE; pamtester will prompt for the user's password).
```
## Smoke test 3: path-traversal regression
The guard in `safe_user` rejects names that break the path-as-identifier
invariant: empty strings, `.`, `..`, anything containing `/` or NUL.
Anything else passes through — including unusual but legitimate names that
contain `..` as a substring (LDAP / federated identities can produce
these). The loop below mixes both classes; the assertions live in the
comments:
```bash
for u in "../etc" "a/b" "" "." ".." "x..y"; do
pamtester -v authforge "$u" authenticate 2>&1 | head -3
echo "---"
done
# Expect rejected (logged "rejected unsafe username"): ../etc, a/b, "", ., ..
# Expect passed through (no rejection log; pam_unix runs and prompts): x..y
```
Keeping `x..y` in the loop is intentional — it documents the boundary and
acts as a regression test against accidental over-rejection if the guard
is ever tightened.
**Future hardening (deferred — not blocking v1):** the guard is a cheap
pre-filter, not the authoritative boundary. A belt-and-suspenders pattern
would be canonicalize-then-verify: build the per-user path, resolve it
via `fs::canonicalize` (Rust) / `realpath` (C), and confirm the resolved
path is still under the expected parent directory. That covers symlink
shenanigans and any future `..`-substring attack surface uniformly.
Track as a Phase 17 item.
The module logs via `pam_syslog` without setting its own `openlog` ident,
so journal entries inherit the calling process's syslog identifier:
`pamtester` here, `sudo` / `sshd` / `gdm` / `login` in production. The
module name appears in the message body, so the always-works query is to
grep for the literal string:
```bash
journalctl --since "10 minutes ago" --no-pager | grep authforge_pending
```
For pamtester runs specifically, `journalctl -t pamtester` works too.
Older systems that route `pam_syslog` to `/var/log/auth.log` show the
same lines there.
## 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 cannot run `pamtester` against a real PAM stack without root and a VM, so
this is the last manual gate before the Phase 14 PPA build. The CI pipeline
only verifies that `make -C pam` produces a valid ELF.