[Security] Stop the FIDO2 setup staging its authfile at a predictable /tmp path (backport of #7904)
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
This commit is contained in:
1 parent
2e989e35e5
commit
13f18b2cb7
6 files changed
+1357
-6
No files matched your search
+557
@@ -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"
|
||||
Executable
+126
@@ -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"
|
||||
Executable
+480
@@ -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"
|
||||
Reference in new issue
Block a user