From 05fb36d3cc719fedbc534602f6a17c22eb2b928d Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Sat, 29 Aug 2026 15:05:13 +0200 Subject: [PATCH] Build Plymouth assets in a root-owned stage --- bin/omarchy-plymouth-set | 422 ++++++++++++++---------------- test/shell.d/plymouth-set-test.sh | 112 ++++---- 2 files changed, 261 insertions(+), 273 deletions(-) diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index c9c663b5..68f13ff8 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -7,19 +7,25 @@ set -euo pipefail -# Configure the Plymouth boot theme with a custom background color, text color, -# and logo. Assets are prepared without privileges, pinned to their recorded -# hashes, then published one at a time through root-owned temporary files. +# Build the authoritative theme in a root-owned directory, then publish each +# fixed destination atomically. The caller opens the selected logo before sudo, +# so the privileged process never resolves a user-controlled input path. refresh_default=false -if (( $# == 1 )) && [[ $1 == --refresh-default ]]; then +if (( $# == 1 )) && [[ $1 == "--refresh-default" ]]; then refresh_default=true elif (( $# != 3 )); then echo "Usage: omarchy-plymouth-set " >&2 exit 1 fi -if ! $refresh_default; then +logo_fd= +if $refresh_default; then + mode=refresh + bg_hex= + text_hex= +else + mode=set bg_hex="${1#\#}" text_hex="${2#\#}" logo_path="$3" @@ -39,230 +45,215 @@ if ! $refresh_default; then exit 1 fi - # omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from - # ~/.config/omarchy/themes, where an installed theme can make it a symlink to - # anything. The copies below land in world-readable /usr/share, so following - # one would republish whatever it points at. if [[ -L $logo_path ]]; then echo "Logo file is a symlink, which is not accepted: $logo_path" >&2 exit 1 fi + + # Open the logo while still unprivileged. A replacement symlink to a root-only + # file therefore fails here instead of being followed after sudo starts. + if ! exec {logo_fd}<"$logo_path"; then + echo "Unable to open logo file as the current user: $logo_path" >&2 + exit 1 + fi + if [[ ! -f /proc/$$/fd/$logo_fd ]]; then + echo "Logo input is no longer a regular file: $logo_path" >&2 + exit 1 + fi fi -theme_dir="/usr/share/plymouth/themes/omarchy" -sddm_dir="/usr/share/sddm/themes/omarchy" -max_asset_size=$((64 * 1024 * 1024)) +run_root_transaction() { + sudo /bin/bash -c ' + set -euo pipefail + PATH=/usr/bin:/bin + export PATH -plymouth_theme_assets=( - bullet.png - entry.png - lock.png - logo.png - omarchy.plymouth - omarchy.script - preview-unlock.png - progress_bar.png - progress_box.png -) -plymouth_default_assets=( - "${plymouth_theme_assets[@]}" - logos/oma.png -) -sddm_theme_assets=( - Main.qml - bullet.png - entry-failed.png - entry.png - lock-failed.png - lock.png - logo.png -) + mode=$1 + source_root=$2 + bg_hex=$3 + text_hex=$4 + max_asset_size=$5 -staging_dir=$(mktemp -d) -trap 'rm -rf -- "$staging_dir"' EXIT -chmod 0700 "$staging_dir" -plymouth_stage="$staging_dir/plymouth" -sddm_stage="$staging_dir/sddm" -mkdir -m 0700 -p "$plymouth_stage/logos" "$sddm_stage" + [[ $mode == "set" || $mode == "refresh" ]] + [[ $source_root == /* ]] + canonical_source_root=$(realpath -e -- "$source_root") + [[ $canonical_source_root == "$source_root" ]] + [[ $max_asset_size =~ ^[0-9]+$ ]] + (( max_asset_size > 0 )) -copy_regular_file() { - local source="$1" destination="$2" + if [[ $mode == "set" ]]; then + [[ $bg_hex =~ ^[0-9a-fA-F]{6}$ ]] + [[ $text_hex =~ ^[0-9a-fA-F]{6}$ ]] + fi - if [[ ! -f $source || -L $source ]]; then - echo "Refusing non-regular or symlinked asset: $source" >&2 - exit 1 - fi + theme_dir=/usr/share/plymouth/themes/omarchy + sddm_dir=/usr/share/sddm/themes/omarchy + plymouth_theme_assets=( + bullet.png + entry.png + lock.png + logo.png + omarchy.plymouth + omarchy.script + preview-unlock.png + progress_bar.png + progress_box.png + ) + plymouth_default_assets=("${plymouth_theme_assets[@]}" logos/oma.png) + sddm_theme_assets=(Main.qml bullet.png entry-failed.png entry.png lock-failed.png lock.png logo.png) - # -P ensures a source swapped to a symlink is copied as a symlink instead of - # followed; the post-copy check then rejects it. - cp -P --reflink=never -- "$source" "$destination" - if [[ ! -f $destination || -L $destination ]]; then - rm -f -- "$destination" - echo "Asset changed while it was being staged: $source" >&2 - exit 1 - fi - chmod 0600 "$destination" + validate_trusted_directory() { + local directory=$1 canonical uid directory_mode + + canonical=$(realpath -e -- "$directory") + [[ $canonical == "$directory" && -d $directory && ! -L $directory ]] + + while :; do + uid=$(stat -c %u -- "$directory") + directory_mode=$(stat -c %a -- "$directory") + (( uid == 0 )) + (( (8#$directory_mode & 0022) == 0 )) + [[ $directory == "/" ]] && break + directory=${directory%/*} + [[ -n $directory ]] || directory=/ + done + } + + validate_trusted_file() { + local source=$1 canonical uid file_mode size + + [[ -f $source && ! -L $source ]] + canonical=$(realpath -e -- "$source") + [[ $canonical == "$source" ]] + validate_trusted_directory "${source%/*}" + uid=$(stat -c %u -- "$source") + file_mode=$(stat -c %a -- "$source") + size=$(stat -c %s -- "$source") + (( uid == 0 )) + (( (8#$file_mode & 0022) == 0 )) + (( size > 0 && size <= max_asset_size )) + } + + copy_trusted_file() { + local source=$1 destination=$2 + + validate_trusted_file "$source" + cp --reflink=never -- "$source" "$destination" + chown 0:0 -- "$destination" + chmod 0600 -- "$destination" + } + + staging_dir=$(mktemp -d /tmp/omarchy-plymouth.XXXXXXXX) + temporary= + cleanup() { + [[ -z $temporary ]] || rm -f -- "$temporary" + rm -rf -- "$staging_dir" + } + trap cleanup EXIT HUP INT TERM + chown 0:0 -- "$staging_dir" + chmod 0700 -- "$staging_dir" + + plymouth_stage=$staging_dir/plymouth + sddm_stage=$staging_dir/sddm + mkdir -m 0700 -p -- "$plymouth_stage/logos" "$sddm_stage" + + if [[ $mode == "refresh" ]]; then + assets_to_stage=("${plymouth_default_assets[@]}") + else + assets_to_stage=("${plymouth_theme_assets[@]}") + fi + + for asset in "${assets_to_stage[@]}"; do + copy_trusted_file "$source_root/default/plymouth/$asset" "$plymouth_stage/$asset" + done + + if [[ $mode == "set" ]]; then + # stdin was opened by the unprivileged caller. Read no more than the + # documented limit into the root-owned stage before doing other work. + head -c "$((max_asset_size + 1))" >"$plymouth_stage/logo.png" + logo_size=$(stat -c %s -- "$plymouth_stage/logo.png") + (( logo_size > 0 && logo_size <= max_asset_size )) + chown 0:0 -- "$plymouth_stage/logo.png" + chmod 0600 -- "$plymouth_stage/logo.png" + cp --reflink=never -- "$plymouth_stage/logo.png" "$sddm_stage/logo.png" + + bg_r=$(awk -v n=$((16#${bg_hex:0:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_g=$(awk -v n=$((16#${bg_hex:2:2})) "BEGIN{printf \"%.3f\", n/255}") + bg_b=$(awk -v n=$((16#${bg_hex:4:2})) "BEGIN{printf \"%.3f\", n/255}") + + sed -i \ + -e "s/^Window.SetBackgroundTopColor.*/Window.SetBackgroundTopColor($bg_r, $bg_g, $bg_b);/" \ + -e "s/^Window.SetBackgroundBottomColor.*/Window.SetBackgroundBottomColor($bg_r, $bg_g, $bg_b);/" \ + "$plymouth_stage/omarchy.script" + + for asset in bullet.png entry.png lock.png progress_bar.png; do + magick "$plymouth_stage/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$plymouth_stage/$asset" + done + + copy_trusted_file "$source_root/default/sddm/omarchy/Main.qml" "$sddm_stage/Main.qml" + sed -i \ + -e "s/#1a1b26/#__OMARCHY_SDDM_BG__/g" \ + -e "s/#ffffff/#__OMARCHY_SDDM_TEXT__/g" \ + -e "s/#__OMARCHY_SDDM_BG__/#$bg_hex/g" \ + -e "s/#__OMARCHY_SDDM_TEXT__/#$text_hex/g" \ + "$sddm_stage/Main.qml" + + for asset in bullet.png entry.png lock.png; do + cp --reflink=never -- "$plymouth_stage/$asset" "$sddm_stage/$asset" + done + for asset in entry lock; do + magick "$plymouth_stage/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$sddm_stage/$asset-failed.png" + done + chown -R 0:0 -- "$staging_dir" + find "$staging_dir" -type f -exec chmod 0600 -- {} + + fi + + publish_asset() { + local source=$1 destination=$2 parent filename source_size copied_size + + [[ -f $source && ! -L $source ]] + (( $(stat -c %u -- "$source") == 0 )) + source_size=$(stat -c %s -- "$source") + (( source_size > 0 && source_size <= max_asset_size )) + + [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] + parent=${destination%/*} + filename=${destination##*/} + [[ -n $parent && -n $filename && $filename != "." && $filename != ".." ]] + validate_trusted_directory "$parent" + + temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") + cp --reflink=never -- "$source" "$temporary" + copied_size=$(stat -c %s -- "$temporary") + (( copied_size == source_size )) + cmp -s -- "$source" "$temporary" + chown 0:0 -- "$temporary" + chmod 0644 -- "$temporary" + sync -f -- "$temporary" + mv --no-copy -fT -- "$temporary" "$destination" + temporary= + } + + for asset in "${assets_to_stage[@]}"; do + publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" + done + + if [[ $mode == "set" ]]; then + for asset in "${sddm_theme_assets[@]}"; do + publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" + done + validate_trusted_directory "$sddm_dir" + rm -f -- "$sddm_dir/logo.svg" + fi + ' bash "$mode" "$OMARCHY_PATH" "$bg_hex" "$text_hex" "$((64 * 1024 * 1024))" } if $refresh_default; then - assets_to_stage=("${plymouth_default_assets[@]}") + run_root_transaction &2 - exit 1 - fi - - size=$(stat -c %s -- "$source") - if (( size == 0 || size > max_asset_size )); then - echo "Staged asset is empty or exceeds the ${max_asset_size}-byte limit: $source" >&2 - exit 1 - fi - hash=$(sha256sum -- "$source") - staged_sizes["$source"]=$size - staged_hashes["$source"]=${hash%% *} -} - -# Record every asset before the first sudo prompt. Root verifies both values -# after consuming stdin, so an in-place rewrite after this point fails instead -# of changing what is published. This pins the recorded result, not the trust -# of an already-user-writable OMARCHY_PATH checkout. -for asset in "${assets_to_stage[@]}"; do - record_staged_asset "$plymouth_stage/$asset" -done -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - record_staged_asset "$sddm_stage/$asset" - done -fi - -publish_asset() { - local source="$1" destination="$2" - local expected_hash="${staged_hashes[$source]}" - local expected_size="${staged_sizes[$source]}" - - # The caller's shell opens source before sudo starts. Root reads only stdin, - # verifies the recorded bytes, and never opens or chmods the final pathname. - # Its temporary file is on the destination filesystem, so mv is atomic for - # this one asset and replaces a destination symlink instead of following it. - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - expected_hash=$2 - expected_size=$3 - max_size=$4 - - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - [[ $expected_hash =~ ^[0-9a-f]{64}$ ]] - [[ $expected_size =~ ^[0-9]+$ && $max_size =~ ^[0-9]+$ ]] - (( expected_size > 0 && expected_size <= max_size )) - - parent=${destination%/*} - filename=${destination##*/} - [[ -n $parent && -n $filename && $filename != . && $filename != .. ]] - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - - temporary=$(mktemp --tmpdir="$parent" ".$filename.omarchy-new.XXXXXXXX") - cleanup() { rm -f -- "$temporary"; } - trap cleanup EXIT HUP INT TERM - - head -c "$((expected_size + 1))" >"$temporary" - actual_size=$(stat -c %s -- "$temporary") - (( actual_size == expected_size )) - actual_hash=$(sha256sum -- "$temporary") - [[ ${actual_hash%% *} == "$expected_hash" ]] - - chown 0:0 -- "$temporary" - chmod 0644 -- "$temporary" - sync -f -- "$temporary" - mv --no-copy -fT -- "$temporary" "$destination" - trap - EXIT HUP INT TERM - ' bash "$destination" "$expected_hash" "$expected_size" "$max_asset_size" <"$source" -} - -remove_legacy_asset() { - local destination="$1" - - sudo /bin/bash -c ' - set -euo pipefail - PATH=/usr/bin:/bin - export PATH - - destination=$1 - [[ $destination == /* && $destination != */ && $destination != *"/../"* ]] - parent=${destination%/*} - [[ -d $parent && ! -L $parent ]] - canonical_parent=$(realpath -e -- "$parent") - [[ $canonical_parent == "$parent" ]] - [[ $(stat -c %u -- "$parent") == 0 ]] - parent_mode=$(stat -c %a -- "$parent") - (( (8#$parent_mode & 0022) == 0 )) - rm -f -- "$destination" - ' bash "$destination" -} - -for asset in "${assets_to_stage[@]}"; do - publish_asset "$plymouth_stage/$asset" "$theme_dir/$asset" -done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -270,10 +261,3 @@ if omarchy-cmd-present limine-mkinitcpio; then else sudo mkinitcpio -P fi - -if ! $refresh_default; then - for asset in "${sddm_theme_assets[@]}"; do - publish_asset "$sddm_stage/$asset" "$sddm_dir/$asset" - done - remove_legacy_asset "$sddm_dir/logo.svg" -fi diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index 72fdca8c..b85d14f8 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -162,40 +162,20 @@ done case "$1" in /bin/bash) - [[ ${2:-} == -c && $# -ge 5 ]] || exit 90 + [[ ${2:-} == -c && $# == 9 ]] || exit 90 code=$3 shell_name=$4 - original_destination=$5 - printf 'transaction %s\n' "$original_destination" >>"$TEST_SUDO_LOG" - - if [[ ${TEST_MUTATE_DEST:-} == "$original_destination" ]]; then - expected_size=${7:-0} - case "$original_destination" in - /usr/share/plymouth/themes/omarchy/*) - relative=${original_destination#/usr/share/plymouth/themes/omarchy/} - stage_kind=plymouth - ;; - /usr/share/sddm/themes/omarchy/*) - relative=${original_destination#/usr/share/sddm/themes/omarchy/} - stage_kind=sddm - ;; - *) exit 91 ;; - esac - stage_root=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 1 -type d -print -quit) - source="$stage_root/$stage_kind/$relative" - /usr/bin/head -c "$expected_size" /dev/zero | /usr/bin/tr '\0' X >"$source" - printf '%s\n' "$source" >>"$TEST_MUTATE_LOG" - fi - - mapped_destination="$TEST_FAKE_ROOT$original_destination" - shift 5 + shift 4 + printf 'root transaction\n' >>"$TEST_SUDO_LOG" # The production helper intentionally resets PATH. For this unprivileged - # simulation only, substitute stat/chown shims so a uid-1000 test directory - # behaves like the root-owned /usr/share directory used in production. + # simulation only, substitute trusted tools and map fixed system destinations + # under the disposable fake root. code=${code/PATH=\/usr\/bin:\/bin/PATH=$TEST_ROOT_TOOLS:\/usr\/bin:\/bin} + code=${code/theme_dir=\/usr\/share\/plymouth\/themes\/omarchy/theme_dir=$TEST_FAKE_ROOT\/usr\/share\/plymouth\/themes\/omarchy} + code=${code/sddm_dir=\/usr\/share\/sddm\/themes\/omarchy/sddm_dir=$TEST_FAKE_ROOT\/usr\/share\/sddm\/themes\/omarchy} PATH="$TEST_ROOT_TOOLS:/usr/bin:/bin" \ - /bin/bash -c "$code" "$shell_name" "$mapped_destination" "$@" + /bin/bash -c "$code" "$shell_name" "$@" ;; plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) printf 'command %s\n' "$*" >>"$TEST_SUDO_LOG" @@ -211,21 +191,29 @@ SH cat >"$root_tools/stat" <<'SH' #!/bin/bash last=${!#} -if [[ ${1:-} == -c && ${2:-} == %u && $last == "$TEST_FAKE_ROOT"* ]]; then +if [[ ${1:-} == -c && ${2:-} == %u ]]; then + if [[ -n ${TEST_UNTRUSTED_SOURCE:-} && $last == "$TEST_UNTRUSTED_SOURCE"* ]]; then + printf '1000\n' + exit 0 + fi printf '0\n' exit 0 fi +if [[ ${1:-} == -c && ${2:-} == %a && $last == /tmp ]]; then + printf '755\n' + exit 0 +fi exec /usr/bin/stat "$@" SH cat >"$root_tools/chown" <<'SH' #!/bin/bash last=${!#} -[[ $last == "$TEST_FAKE_ROOT"* ]] || exit 93 +[[ $last == "$TEST_FAKE_ROOT"* || $last == /tmp/omarchy-plymouth.* ]] || exit 93 exit 0 SH -cat >"$fake_bin/magick" <<'SH' +cat >"$root_tools/magick" <<'SH' #!/bin/bash source=$1 destination=${@: -1} @@ -246,7 +234,6 @@ setup_run() { fake_root="$run_dir/root" sudo_log="$run_dir/sudo.log" leak_log="$run_dir/leaked-stage-path.log" - mutate_log="$run_dir/mutated.log" theme="$fake_root/usr/share/plymouth/themes/omarchy" sddm="$fake_root/usr/share/sddm/themes/omarchy" @@ -301,7 +288,6 @@ run_set_colors() { TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ "$@" \ /bin/bash "$ROOT/bin/omarchy-plymouth-set" "$background" "$text" "$test_tmp/logo.png" ) @@ -330,14 +316,12 @@ for requested_umask in 022 027 077; do [[ -f $destination && ! -L $destination ]] || fail "Plymouth $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "Plymouth $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "Plymouth $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "Plymouth $asset is published exactly once" done for asset in "${sddm_theme_assets[@]}"; do destination="$sddm/$asset" [[ -f $destination && ! -L $destination ]] || fail "SDDM $asset is a regular file under umask $requested_umask" [[ $(stat -c %a "$destination") == 644 ]] || fail "SDDM $asset is mode 0644 under umask $requested_umask" [[ -s $destination ]] || fail "SDDM $asset is nonempty under umask $requested_umask" - [[ $(grep -Fc "transaction /usr/share/sddm/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "SDDM $asset is published exactly once" done cmp -s "$test_tmp/logo.png" "$theme/logo.png" || fail "Plymouth receives the selected logo under umask $requested_umask" @@ -373,9 +357,9 @@ if grep -Fq '__OMARCHY_SDDM_' "$sddm/Main.qml"; then fi pass "White theme keeps a white SDDM background instead of becoming black-on-black" -# Swap the first staged source to an unreadable file after all hashes have been -# recorded but in the DEBUG hook immediately before Bash opens the redirection. -# The caller-side open must fail, so sudo never starts and nothing is published. +# Swap the selected logo to an unreadable file in the DEBUG hook immediately +# before Bash opens its descriptor. The caller-side open must fail, so sudo +# never starts and nothing is published. setup_run preopen_hook="$run_dir/preopen-hook" preopen_marker="$run_dir/preopen-marker" @@ -385,11 +369,10 @@ cat >"$preopen_hook" <<'SH' if [[ $0 == */bin/omarchy-plymouth-set ]]; then set -T trap ' - if [[ ${destination:-} == /usr/share/plymouth/themes/omarchy/bullet.png && - $BASH_COMMAND == sudo\ /bin/bash\ -c* && + if [[ $BASH_COMMAND == exec* && $BASH_COMMAND == *logo_fd* && ! -e $TEST_PREOPEN_MARKER ]]; then - mv -T -- "$source" "$source.before-preopen-swap" - ln -s -- "$TEST_SECRET" "$source" + mv -T -- "$logo_path" "$logo_path.before-preopen-swap" + ln -s -- "$TEST_SECRET" "$logo_path" printf "swapped\n" >"$TEST_PREOPEN_MARKER" fi ' DEBUG @@ -399,32 +382,55 @@ SH output=$(TEST_PREOPEN_MARKER="$preopen_marker" TEST_SECRET="$secret" BASH_ENV="$preopen_hook" run_set 077 env 2>&1) status=$? chmod 0600 "$secret" +rm -f "$test_tmp/logo.png" +mv "$test_tmp/logo.png.before-preopen-swap" "$test_tmp/logo.png" (( status != 0 )) || fail "an unreadable pre-open source swap aborts publication" [[ -s $preopen_marker ]] || fail "the pre-open source swap ran deterministically" "$output" [[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' && $(stat -c %a "$theme/bullet.png") == 600 ]] || fail "pre-open failure leaves the live destination unchanged" [[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "pre-open failure leaves destination-link victims unchanged" -if [[ -e $sudo_log ]] && grep -Fq 'transaction /usr/share/plymouth/themes/omarchy/bullet.png' "$sudo_log"; then +if [[ -e $sudo_log ]] && grep -Fq 'root transaction' "$sudo_log"; then fail "sudo started despite the caller-side open failure" fi assert_no_temporary_files "$fake_root" pass "an unreadable source swap before open fails without publication" -# Rewrite a staged file in place after Bash has opened it but before root reads -# stdin. Size is preserved, so only the recorded SHA-256 can reject this race. +# Plant both a malicious script and a root-file symlink where the old +# caller-owned stage lived. The privileged transaction must ignore that tree: +# executable/config assets come only from its root-trusted source and are built +# in its own root-owned stage. setup_run -mutate_destination='/usr/share/plymouth/themes/omarchy/omarchy.script' -output=$(run_set 022 env TEST_MUTATE_DEST="$mutate_destination" 2>&1) +attacker_stage="$stages/tmp.attacker" +mkdir -p "$attacker_stage/plymouth" +printf 'MALICIOUS BOOT SCRIPT\n' >"$attacker_stage/plymouth/omarchy.script" +ln -s "$secret" "$attacker_stage/plymouth/logo.png" + +output=$(run_set 022 env 2>&1) status=$? -(( status != 0 )) || fail "an in-place rewrite after open aborts publication" -[[ -s $mutate_log ]] || fail "the post-open in-place rewrite ran" -[[ -L $theme/omarchy.script ]] || fail "failed hash verification leaves the old destination symlink in place" -[[ $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' && $(stat -c %a "$plymouth_victim") == 600 ]] || fail "failed hash verification leaves the destination-link victim unchanged" +(( status == 0 )) || fail "a planted caller-owned stage cannot disrupt publication" "$output" +! grep -Rqs 'MALICIOUS BOOT SCRIPT' "$fake_root" || fail "caller-owned staged content reached the boot theme" +[[ -f $theme/omarchy.script && ! -L $theme/omarchy.script ]] || fail "the trusted Plymouth script replaces the planted destination symlink" +grep -Fq 'Window.SetBackgroundTopColor(0.114, 0.125, 0.129);' "$theme/omarchy.script" || fail "the installed script was derived from the trusted packaged source" +unexpected_stages=$(find "$stages" -mindepth 1 -maxdepth 1 ! -name tmp.attacker -print) +[[ -z $unexpected_stages ]] || fail "the caller created an authoritative staging directory" "$unexpected_stages" assert_no_temporary_files "$fake_root" -pass "recorded size and SHA-256 reject a same-inode rewrite after open" +pass "caller-owned content cannot enter the root-owned boot-image stage" + +# A user-owned source checkout would put the same pre-hash race on the input +# side of the root stage. Refuse it before any fixed destination is replaced. +setup_run +output=$(run_set 022 env TEST_UNTRUSTED_SOURCE="$ROOT/default/plymouth" 2>&1) +status=$? + +(( status != 0 )) || fail "a user-owned packaged source tree is rejected" +[[ $(cat "$theme/bullet.png") == 'old plymouth bullet.png' ]] || fail "an untrusted packaged source leaves the live theme unchanged" +[[ -L $theme/omarchy.script && $(cat "$plymouth_victim") == 'PLYMOUTH VICTIM' ]] || fail "an untrusted source cannot replace executable Plymouth content" +assert_no_temporary_files "$fake_root" + +pass "root rejects packaged assets that a desktop process could rewrite" # Root rejects both a symlinked parent and a group/world-writable parent before # it creates a temporary file or touches the live destination. @@ -459,7 +465,6 @@ output=$( TEST_ROOT_TOOLS="$root_tools" \ TEST_SUDO_LOG="$sudo_log" \ TEST_LEAK_LOG="$leak_log" \ - TEST_MUTATE_LOG="$mutate_log" \ /bin/bash "$ROOT/bin/omarchy-refresh-plymouth" 2>&1 ) status=$? @@ -469,7 +474,6 @@ for asset in "${plymouth_default_assets[@]}"; do destination="$theme/$asset" cmp -s "$ROOT/default/plymouth/$asset" "$destination" || fail "refresh publishes the packaged $asset bytes" [[ -f $destination && ! -L $destination && $(stat -c %a "$destination") == 644 ]] || fail "refresh publishes $asset as a regular mode-0644 file" - [[ $(grep -Fc "transaction /usr/share/plymouth/themes/omarchy/$asset" "$sudo_log") == 1 ]] || fail "refresh publishes $asset exactly once" done [[ -L $sddm/Main.qml && $(cat "$sddm_victim") == 'SDDM VICTIM' ]] || fail "Plymouth refresh leaves SDDM unchanged" ! grep -Fq 'transaction /usr/share/sddm/' "$sudo_log" || fail "Plymouth refresh does not publish SDDM assets"