diff --git a/bin/omarchy-plymouth-set b/bin/omarchy-plymouth-set index 1fd70d50..56426f1f 100755 --- a/bin/omarchy-plymouth-set +++ b/bin/omarchy-plymouth-set @@ -51,6 +51,15 @@ theme_dir="/usr/share/plymouth/themes/omarchy" staging_dir=$(mktemp -d) trap 'rm -rf "$staging_dir"' EXIT +# Publish a staged asset without ever asking a privileged process to resolve +# its user-writable source path. The shell opens source before sudo starts, so +# the inherited descriptor pins the bytes and a replacement symlink to a +# root-only file either loses the race or fails under the caller's permissions. +publish_asset() { + local source="$1" destination="$2" + sudo tee "$destination" <"$source" >/dev/null && sudo chmod 0644 "$destination" +} + find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$staging_dir/" {} + cp "$logo_path" "$staging_dir/logo.png" @@ -63,7 +72,9 @@ for asset in bullet.png entry.png lock.png progress_bar.png; do magick "$staging_dir/$asset" -channel RGB +level-colors "#$text_hex","#$text_hex" "$staging_dir/$asset" done -sudo cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/" +for asset in bullet.png entry.png lock.png logo.png omarchy.plymouth omarchy.script preview-unlock.png progress_bar.png progress_box.png; do + publish_asset "$staging_dir/$asset" "$theme_dir/$asset" || exit 1 +done sudo plymouth-set-default-theme omarchy if omarchy-cmd-present limine-mkinitcpio; then @@ -81,12 +92,12 @@ sed \ -e "s/#ffffff/#$text_hex/g" \ "$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null -sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png" +publish_asset "$staging_dir/logo.png" "$sddm_dir/logo.png" || exit 1 for asset in bullet.png entry.png lock.png; do - sudo cp "$staging_dir/$asset" "$sddm_dir/$asset" + publish_asset "$staging_dir/$asset" "$sddm_dir/$asset" || exit 1 done for asset in entry lock; do magick "$staging_dir/$asset.png" -channel RGB +level-colors "#f7768e","#f7768e" "$staging_dir/$asset-failed.png" - sudo cp "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" + publish_asset "$staging_dir/$asset-failed.png" "$sddm_dir/$asset-failed.png" || exit 1 done sudo rm -f "$sddm_dir/logo.svg" diff --git a/test/shell.d/plymouth-set-test.sh b/test/shell.d/plymouth-set-test.sh index eb8b120b..56b6447e 100755 --- a/test/shell.d/plymouth-set-test.sh +++ b/test/shell.d/plymouth-set-test.sh @@ -3,30 +3,11 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -source_dir="$test_tmp/source" -theme_dir="$test_tmp/theme" - -mkdir -m 0700 "$source_dir" -mkdir -m 0755 "$theme_dir" -touch "$source_dir/logo.png" - -cp -a --no-preserve=mode,ownership "$source_dir/." "$theme_dir/" - -[[ $(stat -c %a "$theme_dir") == "755" ]] || - fail "Plymouth asset copy preserves the theme directory permissions" - -grep -Fq \ - 'cp -a --no-preserve=mode,ownership "$staging_dir/." "$theme_dir/"' \ - "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set avoids copying staging directory ownership and mode" - -pass "Plymouth asset copy preserves the package-owned directory metadata" +trap 'chmod 0600 "$test_tmp/secret" 2>/dev/null || true; rm -rf "$test_tmp"' EXIT # omarchy-plymouth-set-by-theme hands over a theme's unlock.png from -# ~/.config/omarchy/themes, and both copies below land in world-readable -# /usr/share, so a symlink there would republish whatever it points at. +# ~/.config/omarchy/themes. Both installed copies are world-readable, so a +# symlink there must not republish whatever it points at. secret="$test_tmp/secret" printf 'not yours\n' >"$secret" ln -s "$secret" "$test_tmp/logo-link.png" @@ -34,12 +15,9 @@ ln -s "$secret" "$test_tmp/logo-link.png" output=$(OMARCHY_PATH="$ROOT" bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo-link.png" 2>&1) status=$? -(( status != 0 )) || fail "omarchy-plymouth-set refuses a symlinked logo" +((status != 0)) || fail "omarchy-plymouth-set refuses a symlinked logo" [[ $output == *"symlink"* ]] || fail "omarchy-plymouth-set says why it refused the logo" "$output" -grep -Fq 'sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"' "$ROOT/bin/omarchy-plymouth-set" || - fail "omarchy-plymouth-set copies the staged logo to SDDM rather than rereading the caller's path as root" - pass "a themed logo cannot republish a file it merely points at" # Style > Unlock picks a theme by name and hands the answer to @@ -141,3 +119,103 @@ run_unlock_action "default" [[ ! -e $set_args ]] || fail "picking default does not look up a theme named default" "$(cat "$set_args")" pass "the unlock picker still applies a theme and still resets on default" + +# Exercise the full publisher with sudo and ImageMagick shims. Immediately +# after the unprivileged shell opens each staged source, the sudo shim renames +# that source away and replaces its pathname with a symlink to a simulated +# root-only secret. Reading via the inherited stdin descriptor must still +# publish the original bytes. The shim restores the source after each read so +# every Plymouth and SDDM asset gets attacked independently. +fake_bin="$test_tmp/bin" +fake_root="$test_tmp/root" +stages="$test_tmp/stages" +attack_log="$test_tmp/attacked" +sudo_log="$test_tmp/sudo.log" +mkdir -p "$fake_bin" "$fake_root" "$stages" + +cat >"$fake_bin/sudo" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_SUDO_LOG" + +case "$1" in +tee) + destination="$2" + mapped="$TEST_FAKE_ROOT$destination" + mkdir -p "$(dirname -- "$mapped")" + + stage=$(find "$TEST_STAGES" -mindepth 1 -maxdepth 2 -type f -name omarchy.script -printf '%h\n' | head -n1) + asset=$(basename -- "$destination") + source="$stage/$asset" + pinned="$stage/.pinned-$asset" + + if [[ -n $stage && -f $source && ! -L $source ]]; then + mv -T -- "$source" "$pinned" + ln -s "$TEST_SECRET" "$source" + printf '%s\n' "$asset" >>"$TEST_ATTACK_LOG" + /usr/bin/tee "$mapped" + result=$? + rm -f -- "$source" + mv -T -- "$pinned" "$source" + exit "$result" + fi + exec /usr/bin/tee "$mapped" + ;; +chmod) + exec /usr/bin/chmod "$2" "$TEST_FAKE_ROOT$3" + ;; +rm) + destination=${@: -1} + exec /usr/bin/rm -f -- "$TEST_FAKE_ROOT$destination" + ;; +plymouth-set-default-theme | limine-mkinitcpio | mkinitcpio) + exit 0 + ;; +*) + echo "unexpected sudo command: $*" >&2 + exit 1 + ;; +esac +SH + +cat >"$fake_bin/magick" <<'SH' +#!/bin/bash +source="$1" +destination=${@: -1} +[[ $source == "$destination" ]] || /usr/bin/cp -- "$source" "$destination" +SH + +cat >"$fake_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$fake_bin"/* + +printf 'SIMULATED ROOT-ONLY SECRET\n' >"$secret" +printf 'caller-selected logo\n' >"$test_tmp/logo.png" + +output=$(PATH="$fake_bin:$ROOT/bin:$PATH" \ + TMPDIR="$stages" \ + OMARCHY_PATH="$ROOT" \ + TEST_FAKE_ROOT="$fake_root" \ + TEST_STAGES="$stages" \ + TEST_SECRET="$secret" \ + TEST_ATTACK_LOG="$attack_log" \ + TEST_SUDO_LOG="$sudo_log" \ + bash "$ROOT/bin/omarchy-plymouth-set" '#1d2021' '#ebdbb2' "$test_tmp/logo.png" 2>&1) +status=$? + +((status == 0)) || fail "Plymouth publisher succeeds while staged paths are swapped" "$output" + +expected_assets=$'bullet.png\nentry-failed.png\nentry.png\nlock-failed.png\nlock.png\nlogo.png\nomarchy.plymouth\nomarchy.script\npreview-unlock.png\nprogress_bar.png\nprogress_box.png' +actual_assets=$(sort -u "$attack_log") +[[ $actual_assets == "$expected_assets" ]] || fail "every staged asset is raced at its privileged publication" "$actual_assets" + +! grep -Rqs 'SIMULATED ROOT-ONLY SECRET' "$fake_root" || fail "a replacement symlink was published" +grep -Fq 'caller-selected logo' "$fake_root/usr/share/plymouth/themes/omarchy/logo.png" || fail "the descriptor did not preserve the selected logo bytes" +[[ $(stat -c %a "$fake_root/usr/share/plymouth/themes/omarchy") == 755 ]] || fail "fixed-file publication changed the theme directory mode" + +if grep -F "$stages/" "$sudo_log" >/dev/null; then + fail "a privileged command received a pathname inside the user-writable stage" "$(cat "$sudo_log")" +fi + +pass "privileged publication uses pinned descriptors for every staged asset"