* [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>
481 lines
17 KiB
Bash
Executable File
481 lines
17 KiB
Bash
Executable File
#!/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"
|