diff --git a/bin/omarchy-remove-security-fido2 b/bin/omarchy-remove-security-fido2 index c93b490f..6df388c0 100755 --- a/bin/omarchy-remove-security-fido2 +++ b/bin/omarchy-remove-security-fido2 @@ -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..." diff --git a/bin/omarchy-setup-security-fido2 b/bin/omarchy-setup-security-fido2 index 8513968e..85fe2039 100755 --- a/bin/omarchy-setup-security-fido2 +++ b/bin/omarchy-setup-security-fido2 @@ -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" diff --git a/migrations/1787494718.sh b/migrations/1787494718.sh new file mode 100644 index 00000000..0f11ada6 --- /dev/null +++ b/migrations/1787494718.sh @@ -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 diff --git a/test/shell.d/security-fido2-migration-test.sh b/test/shell.d/security-fido2-migration-test.sh new file mode 100755 index 00000000..01e5b698 --- /dev/null +++ b/test/shell.d/security-fido2-migration-test.sh @@ -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" diff --git a/test/shell.d/security-fido2-remove-test.sh b/test/shell.d/security-fido2-remove-test.sh new file mode 100755 index 00000000..6f85090b --- /dev/null +++ b/test/shell.d/security-fido2-remove-test.sh @@ -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 +} + +# 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" diff --git a/test/shell.d/security-fido2-test.sh b/test/shell.d/security-fido2-test.sh new file mode 100755 index 00000000..f7a6a25c --- /dev/null +++ b/test/shell.d/security-fido2-test.sh @@ -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 +} + +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"