Merge pull request #9873 from Wheel-Smith/fix/polkit-faillock-system-auth

Defer to system-auth in the polkit stack written by fingerprint/FIDO2 setup
This commit is contained in:
David Heinemeier Hansson authored and GitHub committed 2026-10-04 07:37:24 -04:00
commit cb865c2fa4
5 files changed
+296 -8

No files matched your search

+4 -4
View File
@@ -31,11 +31,11 @@ setup_pam_config() {
echo "Creating polkit configuration with FIDO2 authentication..."
sudo tee /etc/pam.d/polkit-1 >/dev/null <<'EOF'
auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2
auth required pam_unix.so
auth include system-auth
account required pam_unix.so
password required pam_unix.so
session required pam_unix.so
account include system-auth
password include system-auth
session include system-auth
EOF
fi
}
+4 -4
View File
@@ -47,11 +47,11 @@ setup_pam_config() {
sudo tee /etc/pam.d/polkit-1 >/dev/null <<EOF
$fprintd_gate
auth sufficient pam_fprintd.so
auth required pam_unix.so
auth include system-auth
account required pam_unix.so
password required pam_unix.so
session required pam_unix.so
account include system-auth
password include system-auth
session include system-auth
EOF
fi
}
+77
View File
@@ -0,0 +1,77 @@
echo "Restore polkit brute-force protection on machines that enabled fingerprint or FIDO2 auth"
polkit=/etc/pam.d/polkit-1
# When fingerprint or FIDO2 auth was set up while /etc/pam.d/polkit-1 did not yet
# exist -- the normal case on Arch, where the polkit package ships its stack in
# /usr/lib/pam.d/polkit-1 -- the setup commands hand-rolled an /etc/pam.d/polkit-1
# that lists pam_unix directly instead of `include system-auth`. That override
# dropped pam_faillock, so polkit prompts had no brute-force lockout and their
# failures never counted toward the shared tally. The setup commands now defer to
# system-auth; this repairs the files the old ones already wrote.
#
# Only the exact stack those commands produced is touched. Every non-blank,
# non-comment line must be one of the known hardware-auth auth lines (the
# clamshell gate, pam_fprintd, or the FIDO2 pam_u2f line) or a bare
# `X required pam_unix.so`; all four bare pam_unix lines must be present; and
# system-auth must not already be included. This matches the fingerprint, FIDO2,
# combined, and post-removal (markerless, both remove commands strip only their
# own marker lines) layouts, and refuses any administrator-authored stack that
# carries other directives. The repair replaces only the bare pam_unix lines, so
# comments and the hardware-auth lines are preserved verbatim.
is_omarchy_vulnerable_stack() {
local file=$1 line
local re_gate='^auth[[:space:]]+\[success=1 default=ignore\][[:space:]]+pam_exec\.so quiet /usr/bin/omarchy-hw-laptop-closed[[:space:]]*$'
local re_fprintd='^auth[[:space:]]+sufficient[[:space:]]+pam_fprintd\.so[[:space:]]*$'
local re_u2f='^auth[[:space:]]+sufficient[[:space:]]+pam_u2f\.so cue authfile=/etc/fido2/fido2[[:space:]]*$'
local re_bare='^(auth|account|password|session)[[:space:]]+required[[:space:]]+pam_unix\.so[[:space:]]*$'
local phase
# Already fixed, or partially converted: leave it alone (also makes reruns no-op).
if grep -qE '^(auth|account|password|session)[[:space:]]+include[[:space:]]+system-auth' "$file"; then
return 1
fi
# The vulnerable signature: all four phases delegated to a bare pam_unix.
for phase in auth account password session; do
grep -qE "^${phase}[[:space:]]+required[[:space:]]+pam_unix\.so[[:space:]]*\$" "$file" || return 1
done
# Every meaningful line must be one Omarchy itself wrote; anything else means
# an administrator has edited this file, so it is not ours to rewrite.
while IFS= read -r line || [[ -n $line ]]; do
[[ -z ${line//[[:space:]]/} ]] && continue
[[ $line == \#* ]] && continue
[[ $line =~ $re_gate || $line =~ $re_fprintd || $line =~ $re_u2f || $line =~ $re_bare ]] && continue
return 1
done <"$file"
return 0
}
if [[ -f $polkit ]] && is_omarchy_vulnerable_stack "$polkit"; then
echo "Rewriting $polkit to defer to system-auth (restores pam_faillock lockout)..."
backup="$polkit.omarchy-bak.$(date +%s)"
if ! sudo cp -a "$polkit" "$backup"; then
echo "Could not back up $polkit; leaving it unchanged so the migration retries." >&2
exit 1
fi
# Replace only the bare pam_unix lines; keep comments, blank lines, and the
# hardware-auth (gate / pam_fprintd / pam_u2f) lines exactly as they are.
# sed -i renames a complete file into place, so an interrupted run never leaves polkit-1 truncated.
if ! sudo sed -i -E 's/^(auth|account|password|session)([[:space:]]+)required[[:space:]]+pam_unix\.so[[:space:]]*$/\1\2include system-auth/' "$polkit"; then
echo "Could not rewrite $polkit; leaving it unchanged so the migration retries." >&2
exit 1
fi
if grep -qE '^auth[[:space:]]+include[[:space:]]+system-auth' "$polkit"; then
echo "Restored polkit brute-force protection. Previous file saved at $backup."
else
echo "polkit repair could not be verified; restoring the original file." >&2
sudo cp -a "$backup" "$polkit"
exit 1
fi
fi
@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
# The fingerprint and FIDO2 setup commands each create /etc/pam.d/polkit-1 from
# scratch when the file does not already exist -- which is the normal case on
# Arch, where the polkit package ships its PAM stack in /usr/lib/pam.d/polkit-1
# and /etc/pam.d/polkit-1 is absent. A hand-rolled stack that lists pam_unix
# directly instead of `include system-auth` silently drops pam_faillock, so
# polkit prompts would have no brute-force lockout and their failures would not
# count toward the shared tally. Assert the created stack defers to system-auth.
for setup in omarchy-setup-security-fingerprint omarchy-setup-security-fido2; do
script="$ROOT/bin/$setup"
# Pull the here-doc body the setup writes to /etc/pam.d/polkit-1.
body=$(sed -n "/tee \/etc\/pam.d\/polkit-1/,/^EOF\$/p" "$script")
[[ -n $body ]] || fail "$setup writes a polkit-1 stack"
for phase in auth account password session; do
grep -qE "^${phase}[[:space:]]+include[[:space:]]+system-auth" <<<"$body" ||
fail "$setup polkit-1 $phase defers to system-auth (keeps faillock)" "$body"
done
! grep -qE "^(account|password|session)[[:space:]]+required[[:space:]]+pam_unix" <<<"$body" ||
fail "$setup polkit-1 does not hand-roll a bare pam_unix stack" "$body"
pass "$setup creates a polkit-1 stack that includes system-auth"
done
@@ -0,0 +1,179 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
# Exercises migrations/1788256455.sh, which repairs an Omarchy-created
# /etc/pam.d/polkit-1 that lists pam_unix directly (dropping pam_faillock)
# instead of including system-auth. The migration keeps its production path
# fixed; as in sshd-hardening-migration-test.sh, this test rewrites that one
# assignment in the input fed to bash and stubs sudo so nothing touches the
# host's /etc.
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
migration="$ROOT/migrations/1788256455.sh"
stub_bin="$test_dir/bin"
mkdir -p "$stub_bin"
# sudo stub: log, optionally refuse (SUDO_ALLOWED=0), and optionally make the
# `sed` rewrite empty the file and report success (WRITE_BREAKS=1) so the
# verification/restore path is exercised. Otherwise run the real command so
# cp/sed act on the temp file.
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
printf 'sudo %s\n' "$*" >>"${CALL_LOG:?}"
if [[ ${SUDO_ALLOWED:-1} != "1" ]]; then
exit 1
elif [[ ${WRITE_BREAKS:-0} == "1" && $1 == "sed" ]]; then
: >"${!#}"
else
exec "$@"
fi
STUB
chmod +x "$stub_bin"/*
# Run the migration against a polkit-1 file seeded with $1; leaves the result in
# "$test_dir/<scenario>/polkit-1" and records the exit status in migrate_rc.
migrate_rc=0
run_migration() {
local scenario=$1 content=$2
local dir="$test_dir/$scenario"
local polkit="$dir/polkit-1"
mkdir -p "$dir"
printf '%s' "$content" >"$polkit"
: >"$test_dir/$scenario.calls"
migrate_rc=0
sed "s|^polkit=/etc/pam.d/polkit-1\$|polkit=$polkit|" "$migration" |
CALL_LOG="$test_dir/$scenario.calls" PATH="$stub_bin:$PATH" \
SUDO_ALLOWED="${SUDO_ALLOWED:-1}" WRITE_BREAKS="${WRITE_BREAKS:-0}" \
bash -euo pipefail >/dev/null 2>&1 || migrate_rc=$?
}
result() { cat "$test_dir/$1/polkit-1"; }
# The four layouts the old setup / remove commands leave behind.
fingerprint_stack='auth [success=1 default=ignore] pam_exec.so quiet /usr/bin/omarchy-hw-laptop-closed
auth sufficient pam_fprintd.so
auth required pam_unix.so
account required pam_unix.so
password required pam_unix.so
session required pam_unix.so
'
fido2_stack='auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2
auth required pam_unix.so
account required pam_unix.so
password required pam_unix.so
session required pam_unix.so
'
both_stack='auth [success=1 default=ignore] pam_exec.so quiet /usr/bin/omarchy-hw-laptop-closed
auth sufficient pam_fprintd.so
auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2
auth required pam_unix.so
account required pam_unix.so
password required pam_unix.so
session required pam_unix.so
'
# What both remove commands leave: their own marker lines stripped, the bare
# pam_unix stack (and no marker) behind.
markerless_stack='auth required pam_unix.so
account required pam_unix.so
password required pam_unix.so
session required pam_unix.so
'
fixed_stack='auth sufficient pam_fprintd.so
auth include system-auth
account include system-auth
password include system-auth
session include system-auth
'
# An administrator's own stack that happens to use pam_fprintd but carries an
# extra directive Omarchy never writes.
admin_stack='auth sufficient pam_fprintd.so
auth required pam_unix.so
auth optional pam_permit.so
account required pam_unix.so
password required pam_unix.so
session required pam_unix.so
'
# Every phase of a repaired stack must defer to system-auth and no bare pam_unix
# may remain in the account/password/session block.
assert_repaired() {
local scenario=$1 phase
for phase in auth account password session; do
grep -qE "^${phase}[[:space:]]+include[[:space:]]+system-auth" <<<"$(result "$scenario")" ||
fail "$scenario: $phase defers to system-auth" "$(result "$scenario")"
done
! grep -qE '^(account|password|session)[[:space:]]+required[[:space:]]+pam_unix' <<<"$(result "$scenario")" ||
fail "$scenario: no bare pam_unix remains" "$(result "$scenario")"
}
run_migration fingerprint "$fingerprint_stack"
(( migrate_rc == 0 )) || fail "fingerprint stack migrates cleanly"
assert_repaired fingerprint
grep -qF 'pam_fprintd.so' <<<"$(result fingerprint)" || fail "fingerprint line is preserved"
grep -qF 'omarchy-hw-laptop-closed' <<<"$(result fingerprint)" || fail "clamshell gate is preserved"
pass "migration repairs the fingerprint stack and keeps its hardware-auth lines"
run_migration fido2 "$fido2_stack"
(( migrate_rc == 0 )) || fail "fido2 stack migrates cleanly"
assert_repaired fido2
grep -qF 'pam_u2f.so cue authfile=/etc/fido2/fido2' <<<"$(result fido2)" || fail "FIDO2 line is preserved"
pass "migration repairs the FIDO2 stack and keeps its hardware-auth line"
run_migration both "$both_stack"
(( migrate_rc == 0 )) || fail "combined stack migrates cleanly"
assert_repaired both
grep -qF 'pam_fprintd.so' <<<"$(result both)" && grep -qF 'pam_u2f.so' <<<"$(result both)" ||
fail "both hardware-auth lines are preserved"
pass "migration repairs a combined fingerprint+FIDO2 stack"
run_migration markerless "$markerless_stack"
(( migrate_rc == 0 )) || fail "markerless stack migrates cleanly"
assert_repaired markerless
pass "migration repairs the markerless post-removal stack"
run_migration comment "# managed by omarchy
$fingerprint_stack"
(( migrate_rc == 0 )) || fail "commented stack migrates cleanly"
assert_repaired comment
grep -qxF '# managed by omarchy' <<<"$(result comment)" || fail "comments are preserved through the rewrite"
pass "migration repairs a commented stack and preserves the comment"
run_migration commented-include "# auth include system-auth
$fingerprint_stack"
(( migrate_rc == 0 )) || fail "a stack with a commented-out include migrates cleanly"
assert_repaired commented-include
pass "migration repairs a stack whose only include is commented out"
run_migration fixed "$fixed_stack"
[[ "$(result fixed)" == "$(printf '%s' "$fixed_stack")" ]] || fail "an already-fixed stack is left byte-for-byte unchanged"
! grep -q '^sudo ' "$test_dir/fixed.calls" || fail "an already-fixed stack triggers no privileged writes"
pass "migration is idempotent: an already-fixed stack is untouched"
run_migration admin "$admin_stack"
[[ "$(result admin)" == "$(printf '%s' "$admin_stack")" ]] || fail "an administrator-authored stack is left unchanged"
! grep -q '^sudo ' "$test_dir/admin.calls" || fail "an administrator-authored stack triggers no privileged writes"
pass "migration refuses a stack carrying non-Omarchy directives"
# Privilege failure is the retryable case: the migration must exit non-zero so
# omarchy-migrate does not record it complete, and must leave the file unchanged.
SUDO_ALLOWED=0 run_migration no-sudo "$fingerprint_stack"
(( migrate_rc != 0 )) || fail "the migration stays pending when privileges are unavailable"
[[ "$(result no-sudo)" == "$(printf '%s' "$fingerprint_stack")" ]] || fail "a failed repair leaves the original file intact"
pass "migration exits non-zero and preserves the file when sudo is refused"
# A write that does not take effect must be caught by verification, restored,
# and reported as a failure rather than silently marked complete.
WRITE_BREAKS=1 run_migration verify-fail "$fingerprint_stack"
(( migrate_rc != 0 )) || fail "a failed verification exits non-zero"
[[ "$(result verify-fail)" == "$(printf '%s' "$fingerprint_stack")" ]] || fail "a failed verification restores the original file"
pass "migration exits non-zero and restores when the write cannot be verified"