Backport of the FIDO2 authfile fix (PR #7904 by @mdisec, merged to quattro as
23dab9ec) onto the v4-0-1 release branch.
pamu2fcfg wrote to /tmp/fido2 and the registration was then moved into place
with sudo mv. Any other local user can pre-create /tmp/fido2, and rename(2) does
not dereference the final component, so the privileged move installed the
attacker's symlink itself as pam_u2f's global authfile -- a file consulted by
sufficient lines in /etc/pam.d/sudo and /etc/pam.d/polkit-1.
The same move also carried the staged file's ownership into /etc, so on every
install to date /etc/fido2/fido2 is owned by the invoking user at mode 0644.
That needs no attacker, no race and no second account: anything running as that
uid can append its own credential and satisfy the machine's sudo prompt without
knowing the password.
The setup now creates a unique staging file as root beside the final authfile
and pipes pamu2fcfg into it, so root never reopens a caller-owned pathname,
rejects failed or empty enrollment output, publishes with an atomic mv -Tf,
cleans the exact staging file after every failure, refuses non-regular authfile
states, and installs root:root 0644. A migration repairs machines set up by
earlier versions by replacing the inode rather than chowning in place: a process
that already holds a writable descriptor on the legacy user-owned mapping keeps
it, so the repair has to leave that inode behind where PAM no longer reads it.
Clean cherry-pick: all six files are byte-identical to quattro, so merging
v4-0-1 into quattro resolves without a conflict. The migration's timestamp
(1787494718) is older than others already on this branch, which is harmless:
omarchy-migrate marks each migration by filename rather than tracking a
watermark, so this one runs on every machine that has not run this exact file.
test/shell passes: 195 files, including the three this adds -- the setup's
staging and failure paths, the migration's repair and unrepairable states, and
the removal's symlink handling. test/cli passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DEMYa9UWtroz93DhMTtcV
127 lines
3.7 KiB
Bash
Executable File
127 lines
3.7 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
remove="$ROOT/bin/omarchy-remove-security-fido2"
|
|
|
|
test_tmp=$(mktemp -d)
|
|
stub_bin="$test_tmp/bin"
|
|
calls="$test_tmp/calls.log"
|
|
authdir="$test_tmp/etc-fido2"
|
|
elsewhere="$test_tmp/elsewhere"
|
|
remove_copy="$test_tmp/remove.sh"
|
|
mkdir -p "$stub_bin"
|
|
|
|
cleanup() {
|
|
rm -rf "$test_tmp"
|
|
return 0
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# The same seam the setup and migration suites use: the removal deletes an
|
|
# absolute path no unprivileged suite can own, and an environment override in
|
|
# the shipped command would hand a privileged rm -rf an operand the caller
|
|
# chooses. Retarget a copy instead, and fail if the path is not named exactly
|
|
# once so this seam cannot quietly stop standing for the command it copies.
|
|
occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$remove") || occurrences=0
|
|
(( occurrences == 1 )) ||
|
|
fail "the removal names its FIDO2 directory exactly once" "found $occurrences occurrences"
|
|
pass "removal names its FIDO2 directory once, and the test drives a retargeted copy"
|
|
|
|
sed "s|^authdir=/etc/fido2$|authdir=$authdir|" "$remove" >"$remove_copy"
|
|
|
|
cat >"$stub_bin/sudo" <<'SH'
|
|
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
reject() {
|
|
printf 'refusing unexpected sudo invocation:' >&2
|
|
printf ' %q' "$@" >&2
|
|
printf '\n' >&2
|
|
exit 97
|
|
}
|
|
|
|
if [[ ${TEST_AUTHDIR:-} != /* || ${TEST_LOG:-} != /* ]]; then
|
|
reject "$@"
|
|
fi
|
|
|
|
printf 'sudo' >>"$TEST_LOG"
|
|
printf '\t%s' "$@" >>"$TEST_LOG"
|
|
printf '\n' >>"$TEST_LOG"
|
|
|
|
case "${1:-}" in
|
|
rm)
|
|
if (( $# != 3 )) || [[ $2 != "-rf" || $3 != "$TEST_AUTHDIR" ]]; then
|
|
reject "$@"
|
|
fi
|
|
|
|
exec /usr/bin/rm -rf "$TEST_AUTHDIR"
|
|
;;
|
|
sed)
|
|
if (( $# != 4 )) || [[ $2 != "-i" ]]; then
|
|
reject "$@"
|
|
fi
|
|
;;
|
|
*)
|
|
reject "$@"
|
|
;;
|
|
esac
|
|
SH
|
|
|
|
cat >"$stub_bin/omarchy-pkg-drop" <<'SH'
|
|
#!/bin/bash
|
|
SH
|
|
|
|
chmod +x "$stub_bin/sudo" "$stub_bin/omarchy-pkg-drop"
|
|
|
|
invoke_remove() {
|
|
: >"$calls"
|
|
TEST_AUTHDIR="$authdir" TEST_LOG="$calls" \
|
|
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
|
bash "$remove_copy" </dev/null >/dev/null
|
|
}
|
|
|
|
# The ordinary case: a real directory holding a registration.
|
|
rm -rf "$authdir"
|
|
mkdir -p "$authdir"
|
|
printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authdir/fido2"
|
|
invoke_remove
|
|
grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" ||
|
|
fail "removal deletes the FIDO2 directory" "$(cat "$calls")"
|
|
[[ ! -e $authdir ]] || fail "the FIDO2 directory is gone"
|
|
pass "removal deletes a real FIDO2 directory"
|
|
|
|
# -d is false for a dangling link, so the guard it replaced left one sitting
|
|
# there for the next setup to install an authfile through.
|
|
rm -rf "$authdir"
|
|
ln -s "$test_tmp/missing" "$authdir"
|
|
invoke_remove
|
|
grep -Fxq $'sudo\trm\t-rf\t'"$authdir" "$calls" ||
|
|
fail "removal deletes a dangling symlink at the FIDO2 directory" "$(cat "$calls")"
|
|
[[ ! -e $authdir && ! -L $authdir ]] ||
|
|
fail "the dangling symlink is gone"
|
|
pass "removal deletes a dangling symlink where -d would have skipped it"
|
|
|
|
# rm -rf on a symlink unlinks the link. Whatever it pointed at is not ours.
|
|
rm -rf "$authdir"
|
|
rm -rf "$elsewhere"
|
|
mkdir -p "$elsewhere"
|
|
printf 'keep me\n' >"$elsewhere/canary"
|
|
ln -s "$elsewhere" "$authdir"
|
|
invoke_remove
|
|
[[ ! -e $authdir && ! -L $authdir ]] ||
|
|
fail "the symlink at the FIDO2 directory is gone"
|
|
[[ -d $elsewhere && -f $elsewhere/canary ]] ||
|
|
fail "removal takes the symlink, never the directory it points at"
|
|
pass "removal takes a symlink itself and leaves its target intact"
|
|
|
|
# Nothing there at all: no escalation, so removing FIDO2 twice costs no prompt.
|
|
rm -rf "$authdir"
|
|
invoke_remove
|
|
! grep -Fq $'sudo\trm\t' "$calls" ||
|
|
fail "removal escalates no rm when there is no FIDO2 directory" "$(cat "$calls")"
|
|
pass "removal escalates nothing when there is no FIDO2 directory"
|