[Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path (#7904)

* [Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path

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: anything running as that uid can add its own credential
and satisfy the machine's sudo prompt without root.

Stage under mktemp and hand the bytes to `install` instead, so the authfile is
always a fresh root-owned regular file rather than an inode a non-root user
still controls. Guard the already-registered check with -L, which -f would
otherwise follow, and reject a symlinked /etc/fido2 in the remove path for the
same reason. A migration takes ownership of authfiles left behind by the old
code; it reports a symlink rather than repairing one, since chown would follow
it and removing it would strip sudo from anyone whose only credential is the
token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6WagC5iUigCGoK1tQeFz

* Install the FIDO2 authfile with -T and refuse a non-regular path

`install SRC DEST` without -T treats an existing directory at DEST as a
destination directory: it drops the credential inside as
/etc/fido2/fido2/tmp.XXXX, exits 0, and setup reports a successful registration
while PAM goes on reading a path that is not a file. -T makes that an error.

The already-registered check has the same blind spot from the other side. -f
follows symlinks, so it reads a symlinked authfile as a registration and leaves
it in place, and is false for a directory, so it tries to register over one.
Only a regular file is a pam_u2f authfile; anything else is now refused with the
same advice to remove it and set FIDO2 up again.

The test deleted every staged path that fell outside its scratch directory,
taking the path from the script under test and already resolved through any
symlink -- so a script staging through one would have named a file of the user's
and had it unlinked. It now unlinks only a file its own stub wrote into.

On a machine that already has /etc/fido2/fido2 the staging assertions cannot run
at all, and the file used to pass without exercising one of them. That branch now
asserts what the host state promises instead: a regular authfile still has to be
recognised as a registration and left alone, and anything else has to be refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Replace the FIDO2 authfile inode rather than chowning it in place

Permission is checked at open(2), not at write(2), so a descriptor the
registering user opened on the authfile while it was still theirs stays writable
through chown and chmod alike. pam_u2f resolves /etc/fido2/fido2 to that same
inode, so the repair left the account it authenticates able to append a
credential it controls -- the exact state the migration exists to end, now
recorded as migrated and never revisited.

Installing a fresh root-owned copy and renaming it over the path leaves any such
descriptor writing to a file nothing reads. Credit to #7703, which reached the
same conclusion independently. An interrupted run heals: the staged copy is
root-owned 600 and inert, no marker is written, and the next run replaces it.

A directory or device at the authfile path is no more ours to rewrite than a
symlink is, and chmod 600 on a directory would only make it untraversable, so
both are now reported rather than repaired.

The repair had no test, because it names an absolute path no unprivileged suite
can write. It is exercised through a scratch copy with that one literal
retargeted, rather than by reading the path from the environment: the migration
hands `install` and `mv` root, and an operand the caller can choose is a
privileged write to anywhere. The copy is only as honest as the substitution, so
the test fails if the migration stops naming the path exactly once.

Covered: the no-op on a machine that never registered a key, which must not cost
a password prompt; the repair itself; the new inode; the absence of a staged copy
afterwards; a second account finding it done; and the symlink and non-regular
cases. Each assertion was checked against a mutation that defeats it -- notably a
repair with the right install call, mode, content and cleanup that writes through
the old inode, which only the inode assertion catches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Finish hardening FIDO2 authfile installation

* Guard the FIDO2 directory and the stage path the setup writes through

install -d follows a symlink at /etc/fido2 and applies the mode and ownership to whatever it points at, so the credential would be staged and published inside the link target and that directory silently reopened to root:root 755. The leaf guard above it only covered fido2 itself, and this is the same threat omarchy-remove-security-fido2 already names on its side.

mktemp's output is an operand for a privileged tee, chmod, mv and rm. The migration validates it before any of them run; the setup did not, so take only the name it asked for there too.

The suite was guarded on the host's own /etc/fido2/fido2 and exited early when one existed, which meant the staging assertions asserted nothing on exactly the machines that use FIDO2. Drive a retargeted copy the way the migration suite already does, so every branch is a fixture and all of them run everywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop the FIDO2 migration recording a repair it never made

omarchy-migrate writes the per-user completion marker on any zero exit, so the two states this migration cannot repair got one line in the update terminal and were then silenced for good: no login notice, no re-run, the migration recorded as done having repaired nothing. Those are precisely the machines where the authfile may already be under someone else's control, so raise them through omarchy-notification-send as well, where they outlive the scrollback. Delivery is best-effort: a machine with no user bus or no notification server must not abort the migration and take every later one with it.

The early exit had the same shape of problem. It read the authfile unprivileged, and the old setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the caller's umask and sudoers' 0022 — so registering under `umask 077` left the directory mode 0700 with the user-owned authfile still inside. Absence and "cannot look" are the same answer to those tests, and the migration exited 0 and marked itself complete. Ask root whether a registration is actually behind an untraversable directory before reopening it, so an aborted setup that left an empty directory, or one an administrator keeps private, does not have its mode widened and its group and special bits discarded for a repair it does not need. A machine that never set FIDO2 up has no directory here and still reaches exit 0 without a password prompt.

The notification assertion checks argument shape rather than a substring of the command line. The glyph is a private-use codepoint, and losing it shifts every argument left: -g swallows the headline, the body becomes the title, and the message goes out with no description — which a substring match reads as a pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Cover the FIDO2 removal's symlink guard

The -d to -e || -L change is load-bearing for the threat its own comment names — a dangling link at /etc/fido2 that -d reads as absent, left for the next setup to install an authfile through — and it was the one part of this work with no test behind it. Name the directory once so the suite can retarget a copy, the same seam the setup and migration suites use, and assert both halves: the link goes, and the directory it pointed at does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
This commit is contained in:
Mehmet INCE
2026-08-25 12:09:17 +02:00
committed by GitHub
co-authored by Claude Opus 5 Codex XHigh David Heinemeier Hansson
parent 9285b19d6a
commit 23dab9ec4d
6 changed files with 1357 additions and 6 deletions
+7 -2
View File
@@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m"
remove_pam_config
if [[ -d /etc/fido2 ]]; then
authdir=/etc/fido2
# -d follows symlinks, so a dangling link at /etc/fido2 would survive this and
# a later setup would install the authfile through it. rm -rf on a symlink
# removes the link itself, never the directory it points at.
if [[ -e $authdir || -L $authdir ]]; then
echo "Removing FIDO2 configuration..."
sudo rm -rf /etc/fido2
sudo rm -rf "$authdir"
fi
echo "Removing FIDO2 packages..."
+71 -4
View File
@@ -4,6 +4,7 @@
# omarchy:requires-sudo=true
set -e
set -o pipefail
check_fido2_hardware() {
@@ -50,13 +51,79 @@ if ! check_fido2_hardware; then
fi
# Create the pamu2fcfg file
if [[ ! -f /etc/fido2/fido2 ]]; then
sudo mkdir -p /etc/fido2
authdir=/etc/fido2
authfile=/etc/fido2/fido2
# install -d follows a symlink here and applies the mode and ownership to
# whatever it points at, so the credential would be staged and published inside
# the link target and that directory reopened to root:root 755. This is the
# threat omarchy-remove-security-fido2 already names on its side.
if [[ -L $authdir || ( -e $authdir && ! -d $authdir ) ]]; then
echo -e "\e[31m\n$authdir is not a FIDO2 configuration directory.\e[0m"
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
exit 1
fi
# -f follows symlinks, so the already-registered check below reads a symlinked
# authfile as a registration and leaves it in place, and is false for a
# directory, so it tries to register over one. Only a regular file is a valid
# pam_u2f authfile.
if [[ -L $authfile || ( -e $authfile && ! -f $authfile ) ]]; then
echo -e "\e[31m\n$authfile is not a FIDO2 registration file.\e[0m"
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
exit 1
fi
if [[ ! -f $authfile ]]; then
sudo install -d -m 755 -o root -g root "$authdir"
echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m"
echo -e "Touch your FIDO2 key when it lights up...\n"
if pamu2fcfg >/tmp/fido2; then
sudo mv /tmp/fido2 /etc/fido2/fido2
# A unique sibling created by root cannot be replaced by another process
# running as this user. Stream pamu2fcfg into it instead of asking root to
# reopen a caller-owned path: an observed temporary name could otherwise be
# replaced with a symlink before the privileged copy. The final rename is
# atomic, and -T refuses a directory at the destination. Mode 644 keeps the
# root-owned global authfile readable when pam_u2f uses openasuser; only root
# can still rewrite it.
stage=""
# mktemp's output is an operand for four privileged commands below, one of
# them an rm. Take only the name this script asked for rather than whatever
# came back on stdout.
safe_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
cleanup_stage() {
local status=$?
if safe_stage_path "$stage"; then
sudo rm -f -- "$stage" || true
fi
return "$status"
}
trap cleanup_stage EXIT
stage=$(sudo mktemp "$authfile.new.XXXXXX")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
echo -e "\e[31m\nCould not create a safe staging file beside $authfile.\e[0m"
exit 1
fi
if pamu2fcfg | sudo tee "$stage" >/dev/null && [[ -s $stage ]]; then
sudo chmod 644 "$stage"
sudo mv -Tf "$stage" "$authfile"
stage=""
trap - EXIT
echo -e "\e[32mFIDO2 device registered successfully!\e[0m"
else
echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m"
+116
View File
@@ -0,0 +1,116 @@
echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root"
authfile="/etc/fido2/fido2"
# omarchy-migrate records this migration as complete whenever it exits zero, so
# a line printed here scrolls past once in the update terminal and is never
# shown again. The states below cannot be repaired without deciding what to do
# with a file we do not own, and they are exactly the ones where the authfile
# may already be under someone else's control, so say so where it outlives the
# scrollback as well.
report_unrepairable() {
echo " $1"
echo " $2"
omarchy-notification-send -u critical -g  "FIDO2 authfile needs attention" "$1 $2" || true
}
# Nothing to repair on any machine that never set FIDO2 up, which is almost all
# of them. Checked before any sudo so those machines never see a password
# prompt. -L as well as -e: a dangling symlink is invisible to -e.
if [[ ! -L $authfile && ! -e $authfile ]]; then
# Absence and "cannot look" are the same answer to the tests above. The old
# setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the
# caller's umask and sudoers' 0022, so anyone registering under `umask 077`
# left it mode 0700 with the user-owned authfile still inside. Escalate for
# that case alone -- a machine that never set FIDO2 up has no directory here
# and still reaches exit 0 without a password prompt. Not through a symlink:
# chmod would act on whatever it points at.
authdir=${authfile%/*}
if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then
exit 0
fi
# Ask root whether a registration is behind it before touching the directory
# itself. An aborted setup that left an empty 0700 directory, or one an
# administrator deliberately keeps private, must not have its mode widened
# and its group and special bits discarded for a repair it does not need.
if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then
exit 0
fi
sudo chmod 755 "$authdir"
fi
# The old privileged move could install a symlink here if its fixed staging path
# was redirected. Reported, not repaired: chown follows symlinks and would take
# ownership of the target instead, and removing it would strip sudo and polkit
# from anyone whose only credential is the token.
if [[ -L $authfile ]]; then
report_unrepairable "$authfile is a symlink, not a regular file." \
"Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2."
exit 0
fi
# A directory or a device here is no more ours to rewrite than a symlink is,
# and changing a directory's mode would alter an object we do not own.
if [[ ! -f $authfile ]]; then
report_unrepairable "$authfile is not a regular file." \
"Leaving it alone. Remove it and re-run Setup > Security > Fido2."
exit 0
fi
# Migration state is per-user, so every account re-runs this. The file's own
# ownership is the state check: the second account finds the repair already
# done and exits without escalating.
owner=$(stat -c %U "$authfile" 2>/dev/null) || owner=""
group=$(stat -c %G "$authfile" 2>/dev/null) || group=""
mode=$(stat -c %a "$authfile" 2>/dev/null) || mode=""
if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then
exit 0
fi
# Setup used to `mv` this in from /tmp, which carried the invoking user's
# ownership into /etc. Root ownership stops that user from rewriting their own
# PAM credential without root. Mode 644 keeps the public credential mapping
# readable when pam_u2f opens an absolute authfile as the authenticating user.
#
# Rename a fresh copy over the path rather than chowning in place. A descriptor
# opened while the file was still the user's own stays writable on that inode
# through any later chmod or chown, since permission is checked at open(2), and
# pam_u2f resolving the path would keep landing on it. Replacing the inode
# leaves that descriptor writing to a file nothing reads.
stage=""
safe_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
cleanup_stage() {
local status=$?
if safe_stage_path "$stage"; then
sudo rm -f -- "$stage" || true
fi
return "$status"
}
trap cleanup_stage EXIT
stage=$(sudo mktemp "$authfile.new.XXXXXX")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
echo " Could not create a safe staging file beside $authfile."
exit 1
fi
sudo install -T -m 644 -o root -g root "$authfile" "$stage"
sudo mv -Tf "$stage" "$authfile"
stage=""
trap - EXIT
+557
View File
@@ -0,0 +1,557 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
migration="$ROOT/migrations/1787494718.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
calls="$test_tmp/calls.log"
stages="$test_tmp/stages.log"
notifications="$test_tmp/notifications.log"
# A directory of its own, not $test_tmp: the migration derives the FIDO2
# directory from the authfile, and the case below where that directory is
# untraversable has to be able to take the permissions off it.
authdir="$test_tmp/etc-fido2"
authfile="$authdir/fido2"
migration_copy="$test_tmp/migration.sh"
mkdir -p "$stub_bin" "$authdir"
: >"$stages"
: >"$notifications"
# The migration repairs an absolute path no unprivileged suite can write, and an
# environment override in the shipped file would hand a root install and mv an
# operand the caller chooses. Retarget a scratch copy instead, and fail if the
# path is not named exactly once, so this seam cannot quietly stop standing for
# the file it copies.
occurrences=$(grep -Fo /etc/fido2/fido2 "$migration" | wc -l) || occurrences=0
(( occurrences == 1 )) ||
fail "the migration names its authfile exactly once, so the test can retarget a copy" \
"found $occurrences occurrences"
grep -Fxq 'authfile="/etc/fido2/fido2"' "$migration" ||
fail "the production authfile path is a fixed literal, not caller-controlled"
pass "migration names its authfile once, and the test drives a retargeted copy"
# Log every escalation, then execute only the expected bare sudo forms. Each
# operand is matched against the scratch authfile or a stage this stub created.
# This contains malformed calls made through that interface; arbitrary direct
# privileged commands in the migration are outside this harness.
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_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
safe_stage_path() {
local candidate=$1
local prefix="$TEST_AUTHFILE.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
recorded_stage() {
local candidate=$1
safe_stage_path "$candidate" || return 1
[[ -f $candidate && ! -L $candidate ]] || return 1
/usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES"
}
case "$1" in
mktemp)
if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then
reject "$@"
fi
case ${TEST_MKTEMP_MODE:-normal} in
normal)
stage=$(/usr/bin/mktemp -- "$2")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
reject "$@"
fi
printf '%s\n' "$stage" >>"$TEST_STAGES"
printf '%s\n' "$stage"
;;
malformed)
stage="$TEST_AUTHFILE.new.A/BCDE"
/usr/bin/mkdir -- "${stage%/*}"
: >"$stage"
printf '%s\n' "$stage"
;;
nonregular)
stage="$TEST_AUTHFILE.new.BAD123"
/usr/bin/mkdir -- "$stage"
printf '%s\n' "$stage"
;;
*)
reject "$@"
;;
esac
;;
install)
if (( $# != 10 )) || [[ $2 != "-T" || $3 != "-m" || $4 != "644" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHFILE" ]] || ! recorded_stage "${10}"; then
reject "$@"
fi
if [[ ${TEST_FAIL_INSTALL:-0} == "1" ]]; then
exit 71
fi
if (( EUID == 0 )); then
exec /usr/bin/install -T -m 644 -o root -g root "$9" "${10}"
else
exec /usr/bin/install -T -m 644 "$9" "${10}"
fi
;;
mv)
if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ ${TEST_FAIL_MV:-0} == "1" ]]; then
exit 72
fi
exec /usr/bin/mv -Tf -- "$3" "$4"
;;
chmod)
# Only ever the FIDO2 directory, and only back to the mode the setup
# installs. Nothing here may reopen the authfile itself.
if (( $# != 3 )) || [[ $2 != "755" || $3 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
exec /usr/bin/chmod 755 "$TEST_AUTHDIR"
;;
test)
# Looking behind an untraversable directory, never a write. This stub is not
# really root, so open the directory just long enough to answer the way root
# would and put its mode straight back -- the suite then still sees whether
# production left the mode alone.
if (( $# != 3 )) || [[ $2 != "-e" && $2 != "-L" ]] || [[ $3 != "$TEST_AUTHFILE" ]]; then
reject "$@"
fi
saved_mode=$(/usr/bin/stat -c %a "$TEST_AUTHDIR")
/usr/bin/chmod 755 "$TEST_AUTHDIR"
probe_status=0
/usr/bin/test "$2" "$3" || probe_status=$?
/usr/bin/chmod "$saved_mode" "$TEST_AUTHDIR"
exit "$probe_status"
;;
rm)
if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]]; then
reject "$@"
fi
if [[ ${TEST_MKTEMP_MODE:-normal} == "nonregular" && $4 == "$TEST_AUTHFILE.new.BAD123" && -d $4 && ! -L $4 ]]; then
exit 73
fi
recorded_stage "$4" || reject "$@"
exec /usr/bin/rm -f -- "$4"
;;
*)
reject "$@"
;;
esac
SH
chmod +x "$stub_bin/sudo"
cat >"$stub_bin/stat" <<'SH'
#!/bin/bash
set -euo pipefail
if [[ ${TEST_FAKE_STAT:-0} == "1" && ${TEST_AUTHFILE:-} == "${TEST_AUTHDIR:-}/fido2" ]] &&
(( $# == 3 )) && [[ $1 == "-c" && $3 == "$TEST_AUTHFILE" ]]; then
case "$2" in
%U) printf '%s\n' "$TEST_STAT_OWNER" ;;
%G) printf '%s\n' "$TEST_STAT_GROUP" ;;
%a) printf '%s\n' "$TEST_STAT_MODE" ;;
*) exec /usr/bin/stat "$@" ;;
esac
else
exec /usr/bin/stat "$@"
fi
SH
chmod +x "$stub_bin/stat"
# omarchy-migrate records this migration complete on any zero exit, so the
# states it cannot repair have to reach the user somewhere that outlives the
# update terminal's scrollback.
cat >"$stub_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf 'notify' >>"$TEST_NOTIFICATIONS"
printf '\t%s' "$@" >>"$TEST_NOTIFICATIONS"
printf '\n' >>"$TEST_NOTIFICATIONS"
exit "${TEST_NOTIFY_STATUS:-0}"
SH
chmod +x "$stub_bin/omarchy-notification-send"
run_migration() {
local fail_install="${1:-0}"
local fail_mv="${2:-0}"
local stat_owner="${3:-}"
local stat_group="${4:-}"
local stat_mode="${5:-}"
local mktemp_mode="${6:-normal}"
local notify_status="${7:-0}"
local fake_stat=0
if [[ -n $stat_owner || -n $stat_group || -n $stat_mode ]]; then
[[ -n $stat_owner && -n $stat_group && -n $stat_mode ]] ||
fail "a fake stat fixture supplies owner, group and mode together"
fake_stat=1
fi
: >"$calls"
: >"$notifications"
sed "s|/etc/fido2/fido2|$authfile|" "$migration" >"$migration_copy"
PATH="$stub_bin:$PATH" TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" \
TEST_FAIL_INSTALL="$fail_install" TEST_FAIL_MV="$fail_mv" TEST_FAKE_STAT="$fake_stat" \
TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_NOTIFICATIONS="$notifications" \
TEST_NOTIFY_STATUS="$notify_status" TEST_STAGES="$stages" TEST_STAT_GROUP="$stat_group" \
TEST_STAT_MODE="$stat_mode" TEST_STAT_OWNER="$stat_owner" TEST_TMP="$test_tmp" \
bash -euo pipefail "$migration_copy" >/dev/null
}
safe_fixture_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
# Every repair case is about an authfile its own user can still rewrite. The
# calls below give stat an explicit caller-owned state, so the same assertions
# work as an ordinary user, as real root, and in a namespace mapping only UID 0.
write_authfile() {
printf 'tester:credential-handle,public-key,es256,+presence\n' >"$authfile"
chmod "$1" "$authfile"
}
# Almost every machine has never registered a key, and establishing that must
# not cost those users a password prompt.
rm -f "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a machine with no authfile escalates nothing" "$(cat "$calls")"
pass "migration skips a machine that never set FIDO2 up"
# What the old `sudo mv` left behind on every machine that did: the authfile PAM
# consults for sudo, owned by the account it authenticates, at the caller's umask.
write_authfile 644 || fail "the test can stage a non-root-owned authfile"
before_inode=$(stat -c %i "$authfile")
run_migration 0 0 caller caller 644
grep -Fq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" ||
fail "the repair asks root for a unique sibling stage" "$(cat "$calls")"
grep -Fq $'sudo\tinstall\t-T\t-m\t644\t-o\troot\t-g\troot\t'"$authfile"$'\t' "$calls" ||
fail "a user-owned authfile is reinstalled root:root and mode 644" "$(cat "$calls")"
grep -Fq $'sudo\tmv\t-Tf\t' "$calls" ||
fail "the staged authfile is atomically renamed over the live path" "$(cat "$calls")"
if grep -Fq $'sudo\tchown\t' "$calls"; then
fail "the repair replaces the authfile rather than chowning it" "$(cat "$calls")"
fi
if grep -Fq $'sudo\trm\t' "$calls"; then
fail "a successful repair disarms its EXIT cleanup" "$(cat "$calls")"
fi
pass "migration stages and atomically installs a root-owned authfile"
[[ $(stat -c %a "$authfile") == "644" ]] ||
fail "the repaired authfile is mode 644" "got: $(stat -c %a "$authfile")"
[[ $(cat "$authfile") == "tester:credential-handle,public-key,es256,+presence" ]] ||
fail "the repaired authfile keeps its credential" "got: $(cat "$authfile")"
if (( EUID == 0 )) && [[ $(stat -c %U:%G "$authfile") != "root:root" ]]; then
fail "the repaired authfile is root:root" "got: $(stat -c %U:%G "$authfile")"
fi
pass "migration preserves the credential with its PAM-readable mode"
# The whole point of replacing rather than chowning. Permission is checked at
# open(2), so a descriptor the registering user opened before the update stays
# writable on the old inode through any chmod or chown -- and pam_u2f resolving
# the authfile path would keep reading exactly that inode.
[[ $(stat -c %i "$authfile") != "$before_inode" ]] ||
fail "the repair lands on a new inode, orphaning any descriptor already open on the old one"
pass "migration replaces the inode a pre-existing writer would still hold"
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 1 )) ||
fail "the first repair creates exactly one stage" "got: ${staged_paths[*]}"
first_stage=${staged_paths[0]}
safe_fixture_stage_path "$first_stage" ||
fail "the stage is a unique sibling of the authfile" "got: $first_stage"
[[ ! -e $first_stage && ! -L $first_stage ]] ||
fail "the staged copy does not outlive the repair" "left behind: $first_stage"
pass "migration uses a unique sibling and leaves no staged copy behind"
# Treat mktemp's output as untrusted even though sudo normally resolves the
# system binary. This existing regular path has a six-character suffix only if
# `/` is accepted as one of the characters, as the old ?????? glob did. The
# strict shape check must reject it before any privileged write or cleanup.
write_authfile 644 || fail "the test can stage the malformed-output fixture"
before_inode=$(stat -c %i "$authfile")
malformed_parent="$authfile.new.A"
malformed_stage="$malformed_parent/BCDE"
if run_migration 0 0 caller caller 644 malformed; then
fail "malformed mktemp output fails the migration"
fi
grep -Fq $'sudo\tmktemp\t' "$calls" ||
fail "the malformed-output fixture reaches mktemp" "$(cat "$calls")"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls" || grep -Fq $'sudo\trm\t' "$calls"; then
fail "malformed mktemp output reaches no install, rename or cleanup" "$(cat "$calls")"
fi
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "malformed mktemp output leaves the live authfile inode alone"
[[ -f $malformed_stage && ! -L $malformed_stage ]] ||
fail "the malformed-output fixture remains a regular scratch file" "got: $malformed_stage"
/usr/bin/rm -- "$malformed_stage"
/usr/bin/rmdir -- "$malformed_parent"
pass "migration rejects malformed mktemp output before any privileged write"
# A name can have the right prefix and six-character suffix but still name an
# object mktemp would never return. Production must reject that object before
# install/mv; its cleanup may address only that validated scratch sibling and
# must not recursively remove the unexpected directory.
write_authfile 644 || fail "the test can stage the nonregular-output fixture"
before_inode=$(stat -c %i "$authfile")
nonregular_stage="$authfile.new.BAD123"
if run_migration 0 0 caller caller 644 nonregular; then
fail "nonregular mktemp output fails the migration"
fi
safe_fixture_stage_path "$nonregular_stage" ||
fail "the nonregular fixture uses a syntactically valid stage name" "got: $nonregular_stage"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then
fail "nonregular mktemp output is rejected before install or rename" "$(cat "$calls")"
fi
grep -Fq $'sudo\trm\t-f\t--\t'"$nonregular_stage" "$calls" ||
fail "cleanup addresses only the validated nonregular sibling" "$(cat "$calls")"
[[ -d $nonregular_stage && ! -L $nonregular_stage ]] ||
fail "cleanup does not recursively remove a nonregular stage" "got: $nonregular_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "nonregular mktemp output leaves the live authfile inode alone"
/usr/bin/rmdir -- "$nonregular_stage"
pass "migration rejects and safely handles nonregular mktemp output"
# A caller-owned file still needs a fresh inode and root ownership whatever its
# current mode.
write_authfile 600 || fail "the test can restage a non-root-owned authfile"
run_migration 0 0 caller caller 600
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a mode-600 authfile the user still owns is repaired" "$(cat "$calls")"
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 2 )) ||
fail "two repairs create two stages" "got: ${staged_paths[*]}"
second_stage=${staged_paths[1]}
[[ ! -e $second_stage && ! -L $second_stage ]] ||
fail "the second staged copy does not outlive the repair" "left behind: $second_stage"
pass "migration repairs a user-owned authfile whatever its mode and cleans its stage"
# A failure after mktemp must remove only the exact stage the stub created. The
# live authfile stays on its original inode because mv was never reached.
write_authfile 644 || fail "the test can stage the cleanup fixture"
before_inode=$(stat -c %i "$authfile")
if run_migration 1 0 caller caller 644; then
fail "an install failure propagates out of the migration"
fi
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 3 )) ||
fail "the failed repair creates one stage" "got: ${staged_paths[*]}"
failed_stage=${staged_paths[2]}
grep -Fq $'sudo\trm\t-f\t--\t'"$failed_stage" "$calls" ||
fail "the EXIT trap removes the failed repair's exact stage" "$(cat "$calls")"
[[ ! -e $failed_stage && ! -L $failed_stage ]] ||
fail "the failed stage is cleaned up" "left behind: $failed_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "a failed repair leaves the live authfile inode alone"
pass "migration cleans its unique stage after a failed repair"
# A failure after install has the same cleanup obligation. In particular, the
# EXIT trap must still be armed when mv fails.
write_authfile 644 || fail "the test can stage the mv-failure fixture"
before_inode=$(stat -c %i "$authfile")
if run_migration 0 1 caller caller 644; then
fail "an mv failure propagates out of the migration"
fi
mapfile -t staged_paths <"$stages"
(( ${#staged_paths[@]} == 4 )) ||
fail "the mv-failed repair creates one stage" "got: ${staged_paths[*]}"
failed_mv_stage=${staged_paths[3]}
grep -Fq $'sudo\tmv\t-Tf\t'"$failed_mv_stage"$'\t'"$authfile" "$calls" ||
fail "the injected mv failure occurs after install" "$(cat "$calls")"
grep -Fq $'sudo\trm\t-f\t--\t'"$failed_mv_stage" "$calls" ||
fail "the EXIT trap removes the mv-failed repair's exact stage" "$(cat "$calls")"
[[ ! -e $failed_mv_stage && ! -L $failed_mv_stage ]] ||
fail "the mv-failed stage is cleaned up" "left behind: $failed_mv_stage"
[[ $(stat -c %i "$authfile") == "$before_inode" ]] ||
fail "an mv failure leaves the live authfile inode alone"
pass "migration cleans its unique stage after a failed rename"
# The state a completed repair leaves, which is also where every machine that
# registers after this fix starts. A second account, and a second run for the
# same account, must find it done and escalate nothing. Fake only stat's view of
# the scratch authfile so this stays deterministic without borrowing a host
# file or requiring the suite itself to run as root.
write_authfile 644 || fail "the test can stage the settled-state fixture"
run_migration 0 0 root root 644
[[ ! -s $calls ]] ||
fail "an already root:root mode-644 authfile escalates nothing" "$(cat "$calls")"
pass "migration deterministically no-ops on its settled state"
# Owner, group and mode are independent parts of that state check. Hold two at
# their settled values while making each third value wrong, and require repair.
write_authfile 644 || fail "the test can stage the wrong-owner fixture"
run_migration 0 0 nobody root 644
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a non-root-owned authfile is repaired even when group and mode are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong owner"
write_authfile 644 || fail "the test can stage the wrong-group fixture"
run_migration 0 0 root nobody 644
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a non-root-group authfile is repaired even when owner and mode are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong group"
write_authfile 644 || fail "the test can stage the wrong-mode fixture"
run_migration 0 0 root root 600
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "a mode-600 authfile is repaired even when owner and group are settled" "$(cat "$calls")"
pass "migration repairs an authfile with the wrong mode"
# Neither of these is ours to rewrite, and both must say so without escalating:
# chown follows a symlink and would take the target instead, while changing a
# directory's mode would alter an object the migration does not own.
rm -rf "$authfile"
ln -s "$test_tmp/elsewhere" "$authfile"
: >"$test_tmp/elsewhere"
run_migration
[[ ! -s $calls ]] || fail "a symlinked authfile escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] ||
fail "a symlinked authfile is raised where the update terminal cannot swallow it"
rm -f "$authfile"
ln -s "$test_tmp/missing" "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a dangling symlink escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] || fail "a dangling symlink is raised the same way"
pass "migration reports a symlinked authfile and repairs nothing"
rm -f "$authfile"
mkdir -p "$authfile"
run_migration
[[ ! -s $calls ]] || fail "a directory at the authfile path escalates nothing" "$(cat "$calls")"
[[ -s $notifications ]] || fail "a non-regular authfile is raised the same way"
pass "migration reports a non-regular authfile and repairs nothing"
# omarchy-migrate writes this migration's completion marker on any zero exit, so
# a machine it cannot repair gets one shot at telling the user. The states above
# are exactly the ones where the authfile may already be under someone else's
# control, and a line in the update terminal scrolls past.
# Assert the argument shape rather than a substring. The glyph is a private-use
# codepoint that an edit can silently drop, and losing it shifts every argument
# left: -g swallows the headline, the body becomes the title, and the message
# goes out with no description. A substring match sees all of that as fine.
awk -F'\t' '
$1 == "notify" && NF == 7 && $2 == "-u" && $3 == "critical" && $4 == "-g" &&
$5 != "" && $6 == "FIDO2 authfile needs attention" && $7 != "" { found = 1 }
END { exit !found }
' "$notifications" ||
fail "the notification passes a glyph, headline and body as separate arguments" \
"$(cat -A "$notifications")"
pass "migration raises its unrepairable states as a desktop notification"
# The old setup created the FIDO2 directory with `sudo mkdir -p`, which took the
# caller's umask: registering under `umask 077` left it mode 0700 with the
# user-owned authfile still inside. Absence and "cannot look" are the same
# answer to an unprivileged test, so keying the early exit on the authfile
# recorded a repair on exactly the machines that still needed one.
rm -rf "$authfile"
write_authfile 644 || fail "the test can stage the untraversable-directory fixture"
before_inode=$(stat -c %i "$authfile")
chmod 000 "$authdir"
run_migration 0 0 caller caller 644
[[ $(stat -c %a "$authdir") == "755" ]] ||
fail "the migration reopens the directory the old umask closed" "got: $(stat -c %a "$authdir")"
grep -Fxq $'sudo\tchmod\t755\t'"$authdir" "$calls" ||
fail "the migration asks root to reopen the FIDO2 directory" "$(cat "$calls")"
grep -Fq $'sudo\tinstall\t-T\t' "$calls" ||
fail "an authfile hidden behind an untraversable directory is still repaired" "$(cat "$calls")"
[[ $(stat -c %i "$authfile") != "$before_inode" ]] ||
fail "the repair behind an untraversable directory still replaces the inode"
pass "migration repairs an authfile an unreadable directory hid from it"
# The narrow escalation above must not reach a machine that never registered a
# key, which is almost all of them.
rm -f "$authfile"
rm -rf "$authdir"
run_migration
[[ ! -s $calls ]] ||
fail "a machine with no FIDO2 directory still escalates nothing" "$(cat "$calls")"
mkdir -p "$authdir"
run_migration
[[ ! -s $calls ]] ||
fail "an empty readable FIDO2 directory escalates nothing" "$(cat "$calls")"
pass "migration still costs no password prompt on a machine that never set FIDO2 up"
# An aborted setup can leave the directory behind with nothing in it, and an
# administrator may keep one deliberately private. Looking costs a probe, but
# neither may have its mode widened, or its group and special bits discarded,
# for a repair that is not needed.
rm -f "$authfile"
chmod 000 "$authdir"
run_migration
[[ $(stat -c %a "$authdir") == "0" ]] ||
fail "an empty inaccessible FIDO2 directory keeps its mode" "got: $(stat -c %a "$authdir")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "an empty inaccessible FIDO2 directory is never reopened" "$(cat "$calls")"
if grep -Fq $'sudo\tinstall\t' "$calls" || grep -Fq $'sudo\tmv\t' "$calls"; then
fail "an empty inaccessible FIDO2 directory is never repaired" "$(cat "$calls")"
fi
chmod 755 "$authdir"
pass "migration looks behind an inaccessible FIDO2 directory without widening it"
# Notification delivery fails on a machine with no user bus or no notification
# server. That must not abort the migration under `bash -euo pipefail` and take
# every later migration with it.
rm -f "$authfile"
ln -s "$test_tmp/missing" "$authfile"
run_migration 0 0 "" "" "" normal 1
[[ -s $notifications ]] ||
fail "the failing notification was still attempted" "$(cat "$notifications")"
pass "migration survives a notification it could not deliver"
rm -f "$authfile"
+126
View File
@@ -0,0 +1,126 @@
#!/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"
+480
View File
@@ -0,0 +1,480 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
setup="$ROOT/bin/omarchy-setup-security-fido2"
test_tmp=$(mktemp -d)
stub_bin="$test_tmp/bin"
stages="$test_tmp/stages.log"
calls="$test_tmp/calls.log"
pamu_targets="$test_tmp/pamu-targets.log"
bare_mktemp="$test_tmp/bare-mktemp.log"
credential="tester:credential-handle,public-key,es256,+presence"
authdir="$test_tmp/etc-fido2"
authfile="$authdir/fido2"
setup_copy="$test_tmp/setup.sh"
mkdir -p "$stub_bin"
cleanup() {
rm -rf "$test_tmp"
return 0
}
trap cleanup EXIT
# The setup installs to an absolute path no unprivileged suite can write, and an
# environment override in the shipped command would hand its privileged install
# and mv an operand the caller chooses. Retarget a scratch copy instead, and
# fail if either path is not named exactly once, so this seam cannot quietly
# stop standing for the command it copies. Keying the suite on the host's own
# /etc/fido2 instead is what let the staging checks below pass without asserting
# anything on the machines that actually use FIDO2.
occurrences=$(grep -Fxc 'authdir=/etc/fido2' "$setup") || occurrences=0
(( occurrences == 1 )) ||
fail "the setup names its FIDO2 directory exactly once" "found $occurrences occurrences"
occurrences=$(grep -Fxc 'authfile=/etc/fido2/fido2' "$setup") || occurrences=0
(( occurrences == 1 )) ||
fail "the setup names its authfile exactly once" "found $occurrences occurrences"
pass "setup names its FIDO2 paths once each, and the test drives a retargeted copy"
sed -e "s|^authdir=/etc/fido2$|authdir=$authdir|" \
-e "s|^authfile=/etc/fido2/fido2$|authfile=$authfile|" "$setup" >"$setup_copy"
# The setup must not create a caller-owned named file for pamu2fcfg. A bare
# mktemp is therefore a test failure; only the sudo stub below may invoke the
# real command, and it does so with an absolute scratch template.
cat >"$stub_bin/mktemp" <<'SH'
#!/bin/bash
printf 'mktemp' >>"$TEST_BARE_MKTEMP"
printf '\t%s' "$@" >>"$TEST_BARE_MKTEMP"
printf '\n' >>"$TEST_BARE_MKTEMP"
exit 98
SH
# Execute only the setup's expected bare-sudo protocol. The production mktemp
# template is logged exactly, but its root-created sibling is represented by a
# unique regular file inside the scratch directory. The whitelisted operations
# map every write into that directory; arbitrary direct commands are outside
# this harness.
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_TMP:-} != /* || ${TEST_AUTHDIR:-} != "$TEST_TMP/etc-fido2" || ${TEST_AUTHFILE:-} != "$TEST_AUTHDIR/fido2" || ${TEST_STAGES:-} != "$TEST_TMP/stages.log" || ${TEST_LOG:-} != "$TEST_TMP/calls.log" || ! ${TEST_FAIL_CHMOD:-} =~ ^[01]$ || ! ${TEST_FAIL_MV:-} =~ ^[01]$ ]]; then
reject "$@"
fi
printf 'sudo' >>"$TEST_LOG"
printf '\t%s' "$@" >>"$TEST_LOG"
printf '\n' >>"$TEST_LOG"
safe_stage_path() {
local candidate=$1
local prefix="$TEST_AUTHFILE.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
recorded_stage() {
local candidate=$1
safe_stage_path "$candidate" || return 1
[[ -f $candidate && ! -L $candidate ]] || return 1
/usr/bin/grep -Fxq -- "$candidate" "$TEST_STAGES"
}
case "${1:-}" in
install)
if (( $# != 9 )) || [[ $2 != "-d" || $3 != "-m" || $4 != "755" || $5 != "-o" || $6 != "root" || $7 != "-g" || $8 != "root" || $9 != "$TEST_AUTHDIR" ]]; then
reject "$@"
fi
if (( EUID == 0 )); then
exec /usr/bin/install -d -m 755 -o root -g root "$TEST_AUTHDIR"
else
exec /usr/bin/install -d -m 755 "$TEST_AUTHDIR"
fi
;;
mktemp)
if (( $# != 2 )) || [[ $2 != "$TEST_AUTHFILE.new.XXXXXX" ]]; then
reject "$@"
fi
case ${TEST_MKTEMP_MODE:-normal} in
normal)
stage=$(/usr/bin/mktemp -- "$2")
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
reject "$@"
fi
printf '%s\n' "$stage" >>"$TEST_STAGES"
printf '%s\n' "$stage"
;;
malformed)
stage="$TEST_AUTHFILE.new.A/BCDE"
/usr/bin/mkdir -- "${stage%/*}"
: >"$stage"
printf '%s\n' "$stage"
;;
nonregular)
stage="$TEST_AUTHFILE.new.BAD123"
/usr/bin/mkdir -- "$stage"
printf '%s\n' "$stage"
;;
*)
reject "$@"
;;
esac
;;
tee)
if (( $# == 2 )) && recorded_stage "$2"; then
exec /usr/bin/tee "$2"
elif (( $# == 2 )) && [[ $2 == "/etc/pam.d/polkit-1" ]]; then
/usr/bin/cat >/dev/null
else
reject "$@"
fi
;;
test)
if (( $# != 3 )) || [[ $2 != "-s" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
/usr/bin/test -s "$3"
;;
chmod)
if (( $# != 3 )) || [[ $2 != "644" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ $TEST_FAIL_CHMOD == "1" ]]; then
exit 73
fi
exec /usr/bin/chmod 644 "$3"
;;
mv)
if (( $# != 4 )) || [[ $2 != "-Tf" || $4 != "$TEST_AUTHFILE" ]] || ! recorded_stage "$3"; then
reject "$@"
fi
if [[ $TEST_FAIL_MV == "1" ]]; then
exit 74
fi
exec /usr/bin/mv -Tf -- "$3" "$TEST_AUTHFILE"
;;
rm)
if (( $# != 4 )) || [[ $2 != "-f" || $3 != "--" ]] || ! recorded_stage "$4"; then
reject "$@"
fi
exec /usr/bin/rm -f -- "$4"
;;
sed)
if (( $# != 4 )) || [[ $2 != "-i" ]]; then
reject "$@"
fi
if [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/sudo" ]]; then
exit 0
elif [[ $3 == "1i auth sufficient pam_u2f.so cue authfile=/etc/fido2/fido2" && $4 == "/etc/pam.d/polkit-1" ]]; then
exit 0
else
reject "$@"
fi
;;
echo)
if (( $# != 2 )) || [[ $2 != "FIDO2 authentication test successful" ]]; then
reject "$@"
fi
;;
*)
reject "$@"
;;
esac
SH
cat >"$stub_bin/fido2-token" <<'SH'
#!/bin/bash
echo '/dev/hidraw0: vendor=0x1050, product=0x0407 (Yubico YubiKey)'
SH
cat >"$stub_bin/omarchy-pkg-add" <<'SH'
#!/bin/bash
SH
# Record what pamu2fcfg's stdout actually targets. The fixed implementation
# gives it a pipe to privileged tee; refusing a regular-file descriptor keeps a
# regression from writing credential bytes into a caller-owned named file.
cat >"$stub_bin/pamu2fcfg" <<'SH'
#!/bin/bash
set -euo pipefail
target=$(readlink /proc/self/fd/1)
printf '%s\n' "$target" >>"$TEST_PAMU_TARGETS"
[[ $target == pipe:* ]] || exit 96
case "$TEST_PAMU_MODE" in
success)
printf '%s\n' "$TEST_CREDENTIAL"
;;
fail)
printf '%s\n' "$TEST_CREDENTIAL"
exit 23
;;
empty)
exit 0
;;
*)
exit 95
;;
esac
SH
chmod +x "$stub_bin/mktemp" "$stub_bin/sudo" "$stub_bin/fido2-token" \
"$stub_bin/omarchy-pkg-add" "$stub_bin/pamu2fcfg"
reset_run() {
: >"$calls"
: >"$stages"
: >"$pamu_targets"
: >"$bare_mktemp"
rm -rf "$authdir"
}
invoke_setup() {
local pamu_mode="${1:-success}"
local fail_chmod="${2:-0}"
local fail_mv="${3:-0}"
local mktemp_mode="${4:-normal}"
TEST_AUTHDIR="$authdir" TEST_AUTHFILE="$authfile" TEST_BARE_MKTEMP="$bare_mktemp" \
TEST_CREDENTIAL="$credential" TEST_FAIL_CHMOD="$fail_chmod" TEST_FAIL_MV="$fail_mv" \
TEST_LOG="$calls" TEST_MKTEMP_MODE="$mktemp_mode" TEST_PAMU_MODE="$pamu_mode" \
TEST_PAMU_TARGETS="$pamu_targets" TEST_STAGES="$stages" TEST_TMP="$test_tmp" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash "$setup_copy" </dev/null >/dev/null
}
run_setup() {
invoke_setup "${1:-success}" ||
fail "FIDO2 setup registers a device that answers fido2-token" "sudo calls:
$(cat "$calls")"
}
safe_fixture_stage_path() {
local candidate=$1
local prefix="$authfile.new."
local suffix
[[ $candidate == "$prefix"* ]] || return 1
suffix=${candidate#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
single_stage() {
local count
count=$(wc -l <"$stages")
(( count == 1 )) || fail "setup creates exactly one privileged stage" "got $count stages"
head -n 1 "$stages"
}
assert_pipe_target() {
local count target
count=$(wc -l <"$pamu_targets")
(( count == 1 )) || fail "setup invokes pamu2fcfg exactly once" "got $count invocations"
target=$(head -n 1 "$pamu_targets")
[[ $target == pipe:* ]] ||
fail "pamu2fcfg writes only to a pipe, never a caller-owned named file" "got: $target"
}
assert_failed_stage_cleanup() {
local stage_path
stage_path=$(single_stage)
safe_fixture_stage_path "$stage_path" ||
fail "the failed setup stage is a unique scratch sibling" "got: $stage_path"
grep -Fxq $'sudo\trm\t-f\t--\t'"$stage_path" "$calls" ||
fail "failed setup removes its exact privileged stage" "$(cat "$calls")"
[[ ! -e $stage_path && ! -L $stage_path ]] ||
fail "the failed setup stage is gone" "left behind: $stage_path"
[[ ! -e $authfile ]] || fail "failed setup never publishes a credential"
}
# Each branch below is a fixture rather than whatever the host happens to have
# at /etc/fido2, so all of them run on every machine and the staging assertions
# that follow are reached even on one that already uses FIDO2.
reset_run
mkdir -p "$authdir"
printf '%s\n' "$credential" >"$authfile"
run_setup
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing when a registration already exists"
! grep -Fq $'sudo\tmktemp\t' "$calls" ||
fail "FIDO2 setup creates no stage over an existing registration" "$(cat "$calls")"
pass "FIDO2 setup leaves an existing registration alone"
reset_run
mkdir -p "$authdir"
ln -s /dev/null "$authfile"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a symlinked authfile"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing against a symlinked authfile"
[[ -L $authfile ]] || fail "FIDO2 setup leaves the symlinked authfile in place"
pass "FIDO2 setup refuses a symlinked authfile"
reset_run
mkdir -p "$authfile"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a directory where the authfile belongs"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing against a directory authfile"
pass "FIDO2 setup refuses a non-regular authfile"
# install -d follows a symlink at the directory and applies its mode and
# ownership to whatever it points at, so the credential would be staged and
# published inside the target and that directory reopened to root:root 755.
reset_run
mkdir -p "$test_tmp/elsewhere"
chmod 700 "$test_tmp/elsewhere"
ln -s "$test_tmp/elsewhere" "$authdir"
invoke_setup >/dev/null 2>&1 &&
fail "FIDO2 setup refuses a symlinked FIDO2 directory"
[[ ! -s $stages && ! -s $pamu_targets ]] ||
fail "FIDO2 setup stages nothing through a symlinked FIDO2 directory"
! grep -Fq $'sudo\tinstall\t' "$calls" ||
fail "FIDO2 setup never runs install -d through a symlink" "$(cat "$calls")"
[[ $(stat -c %a "$test_tmp/elsewhere") == "700" ]] ||
fail "FIDO2 setup leaves the symlink target's mode alone" "got: $(stat -c %a "$test_tmp/elsewhere")"
[[ ! -e $test_tmp/elsewhere/fido2 ]] ||
fail "FIDO2 setup publishes nothing inside the symlink target"
pass "FIDO2 setup refuses a symlinked FIDO2 directory and leaves its target alone"
reset_run
run_setup
stage_path=$(single_stage)
safe_fixture_stage_path "$stage_path" ||
fail "FIDO2 setup uses a unique sibling stage" "got: $stage_path"
assert_pipe_target
[[ ! -s $bare_mktemp ]] ||
fail "FIDO2 setup never creates a caller-owned temporary file" "$(cat "$bare_mktemp")"
grep -Fxq $'sudo\tmktemp\t'"$authfile.new.XXXXXX" "$calls" ||
fail "FIDO2 setup asks root to create a unique sibling stage" "$(cat "$calls")"
grep -Fxq $'sudo\ttee\t'"$stage_path" "$calls" ||
fail "pamu2fcfg is piped into the exact privileged stage" "$(cat "$calls")"
grep -Fxq $'sudo\tchmod\t644\t'"$stage_path" "$calls" ||
fail "FIDO2 setup makes the completed authfile PAM-readable" "$(cat "$calls")"
grep -Fxq $'sudo\tmv\t-Tf\t'"$stage_path"$'\t'"$authfile" "$calls" ||
fail "FIDO2 setup atomically publishes the exact privileged stage" "$(cat "$calls")"
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "successful setup leaves its cleanup trap inert" "$(cat "$calls")"
[[ ! -e $stage_path && ! -L $stage_path ]] ||
fail "the privileged stage path is gone after publication" "left behind: $stage_path"
[[ -f $authfile && $(<"$authfile") == "$credential" ]] ||
fail "the published authfile contains the generated credential"
[[ $(stat -c %a "$authfile") == "644" ]] ||
fail "the published authfile is mode 644" "got: $(stat -c %a "$authfile")"
pass "FIDO2 setup pipes the credential into a unique root-created stage and publishes it atomically"
# A chmod failure happens after a complete credential has been written but
# before publication. It must abort the setup and leave the EXIT trap armed.
reset_run
if invoke_setup success 1 >/dev/null 2>&1; then
fail "a failed chmod propagates out of FIDO2 setup"
fi
failed_stage=$(single_stage)
assert_pipe_target
grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" ||
fail "the injected chmod failure targets the exact privileged stage" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a stage whose chmod failed is never published" "$(cat "$calls")"
assert_failed_stage_cleanup
pass "FIDO2 setup propagates chmod failure and cleans its privileged stage"
# A failed atomic rename has the same cleanup obligation. The completed stage
# must not survive beside the live authfile when publication fails.
reset_run
if invoke_setup success 0 1 >/dev/null 2>&1; then
fail "a failed mv propagates out of FIDO2 setup"
fi
failed_stage=$(single_stage)
assert_pipe_target
grep -Fxq $'sudo\tchmod\t644\t'"$failed_stage" "$calls" ||
fail "the mv-failure fixture reaches a completed mode-644 stage" "$(cat "$calls")"
grep -Fxq $'sudo\tmv\t-Tf\t'"$failed_stage"$'\t'"$authfile" "$calls" ||
fail "the injected mv failure targets the exact privileged stage" "$(cat "$calls")"
assert_failed_stage_cleanup
pass "FIDO2 setup propagates mv failure and cleans its privileged stage"
# Emit a valid credential and then fail. Without pipefail, tee's success masks
# pamu2fcfg's status and the nonempty file would be published.
reset_run
if invoke_setup fail >/dev/null 2>&1; then
fail "a failing pamu2fcfg pipeline fails setup"
fi
assert_pipe_target
assert_failed_stage_cleanup
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a failed pamu2fcfg result is never prepared for publication" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a failed pamu2fcfg result is never published" "$(cat "$calls")"
pass "FIDO2 setup propagates pamu2fcfg failure and cleans its privileged stage"
# A successful pipeline can still produce no credential. Reject that before
# chmod or rename, and clean the exact stage just as on command failure.
reset_run
if invoke_setup empty >/dev/null 2>&1; then
fail "an empty pamu2fcfg result fails setup"
fi
assert_pipe_target
assert_failed_stage_cleanup
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "an empty pamu2fcfg result is never prepared for publication" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "an empty pamu2fcfg result is never published" "$(cat "$calls")"
pass "FIDO2 setup rejects an empty credential and cleans its privileged stage"
# mktemp's output is an operand for a privileged tee, chmod, mv and rm. Take
# only the name this script asked for: a stage path outside that shape must stop
# the setup before any of them runs, exactly as the migration does.
reset_run
invoke_setup success 0 0 malformed >/dev/null 2>&1 &&
fail "a malformed mktemp result fails setup"
! grep -Fq $'sudo\ttee\t' "$calls" ||
fail "no credential is written to a malformed stage path" "$(cat "$calls")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a malformed stage path never reaches a privileged chmod" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a malformed stage path is never published" "$(cat "$calls")"
! grep -Fq $'sudo\trm\t' "$calls" ||
fail "a malformed stage path never reaches a privileged rm" "$(cat "$calls")"
[[ ! -e $authfile ]] || fail "a malformed stage publishes no authfile"
pass "FIDO2 setup rejects malformed mktemp output before any privileged write"
reset_run
invoke_setup success 0 0 nonregular >/dev/null 2>&1 &&
fail "a nonregular mktemp result fails setup"
! grep -Fq $'sudo\ttee\t' "$calls" ||
fail "no credential is written into a nonregular stage" "$(cat "$calls")"
! grep -Fq $'sudo\tchmod\t' "$calls" ||
fail "a nonregular stage never reaches a privileged chmod" "$(cat "$calls")"
! grep -Fq $'sudo\tmv\t' "$calls" ||
fail "a nonregular stage is never published" "$(cat "$calls")"
[[ ! -e $authfile ]] || fail "a nonregular stage publishes no authfile"
pass "FIDO2 setup rejects nonregular mktemp output before any privileged write"