From 44f68f4329e75fa61dfa8cc01e4737c15e48f8c2 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 14 Sep 2026 19:15:26 -0600 Subject: [PATCH 01/16] Require approval for new USB devices by default Co-authored-by: Outfoxxed --- bin/omarchy-provision-owner | 6 + bin/omarchy-remove-security-usb-authorization | 86 ++++ bin/omarchy-setup-security-usb-authorization | 112 +++++ bin/omarchy-usb-authorization-boot | 432 ++++++++++++++++++ bin/omarchy-usb-authorization-event | 40 ++ bin/omarchy-usb-authorization-restore-default | 23 + bin/omarchy-usb-authorization-review | 79 ++++ bin/omarchy-usb-authorization-watch | 23 + default/omarchy/omarchy-menu.jsonc | 4 + .../user/omarchy-usb-authorization.service | 17 + install/config/all.sh | 1 + install/config/usb-authorization.sh | 28 ++ install/helpers/usb-authorization.sh | 99 ++++ install/omarchy-base.packages | 1 + install/user/first-run/enable-user-units.sh | 3 +- manual/48-security.md | 15 +- migrations/1789433473.sh | 3 + test/shell.d/menu-test.sh | 7 + test/shell.d/usb-authorization-test.sh | 405 ++++++++++++++++ 19 files changed, 1382 insertions(+), 2 deletions(-) create mode 100755 bin/omarchy-remove-security-usb-authorization create mode 100755 bin/omarchy-setup-security-usb-authorization create mode 100755 bin/omarchy-usb-authorization-boot create mode 100755 bin/omarchy-usb-authorization-event create mode 100755 bin/omarchy-usb-authorization-restore-default create mode 100755 bin/omarchy-usb-authorization-review create mode 100755 bin/omarchy-usb-authorization-watch create mode 100644 default/systemd/user/omarchy-usb-authorization.service create mode 100644 install/config/usb-authorization.sh create mode 100644 install/helpers/usb-authorization.sh create mode 100644 migrations/1789433473.sh create mode 100755 test/shell.d/usb-authorization-test.sh diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index b5321a4d..f4bff333 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -747,6 +747,12 @@ create_user() { echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel + if omarchy-pkg-present usbguard; then + source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" + usb_authorization_add_user "$username" + systemctl try-restart usbguard.service + fi + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue diff --git a/bin/omarchy-remove-security-usb-authorization b/bin/omarchy-remove-security-usb-authorization new file mode 100755 index 00000000..ba77a0f0 --- /dev/null +++ b/bin/omarchy-remove-security-usb-authorization @@ -0,0 +1,86 @@ +#!/bin/bash + +# omarchy:summary=Stop asking before new USB devices can access the system +# omarchy:args=[--boot-only] [--yes] +# omarchy:requires-sudo=true + +set -euo pipefail + +assume_yes=0 +boot_only=0 + +while (( $# > 0 )); do + case "$1" in + --yes) assume_yes=1 ;; + --boot-only) boot_only=1 ;; + -h | --help) + echo "Usage: omarchy-remove-security-usb-authorization [--boot-only] [--yes]" + echo + echo "Disables Omarchy's USB approval prompts and restores the kernel's" + echo "normal default-allow behavior." + echo "Use --boot-only to keep USBGuard enabled after the system starts." + exit 0 + ;; + *) + echo "omarchy-remove-security-usb-authorization: unknown option '$1'. Try --help." >&2 + exit 2 + ;; + esac + shift +done + +if (( boot_only )); then + if (( ! assume_yes )); then + gum style --foreground 212 --bold "Remove USB authorization from boot" + echo + echo "USBGuard will continue blocking unknown devices after its service starts." + echo "Devices can reach kernel drivers earlier during boot." + echo + gum confirm "Disable USB authorization from kernel boot?" || exit 130 + fi + + sudo omarchy-usb-authorization-boot disable + echo + echo "Boot-time USB authorization is disabled and takes effect after reboot." + exit 0 +fi + +state_dir="$HOME/.local/state/omarchy/usb-authorization" +unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service" + +if (( ! assume_yes )); then + gum style --foreground 212 --bold "Remove USB device authorization" + echo + echo "New USB devices will be allowed to bind to kernel drivers without approval." + echo + gum confirm "Disable USB device authorization?" || exit 130 +fi + +# Rebuild a permissive boot image before stopping USBGuard. If rebuilding +# fails, the command stops here and leaves the complete policy enabled. +sudo omarchy-usb-authorization-boot disable + +systemctl --user disable --now omarchy-usb-authorization.service 2>/dev/null || true +rm -f "$unit" +systemctl --user daemon-reload + +if omarchy-pkg-present usbguard; then + sudo usbguard remove-user "$USER" 2>/dev/null || true +fi + +if omarchy-pkg-present usbguard; then + sudo systemctl disable --now usbguard.service 2>/dev/null || true + sudo omarchy-usb-authorization-restore-default +fi + +if [[ -d $state_dir/requests ]]; then + find "$state_dir/requests" -mindepth 1 -maxdepth 1 -type f -delete + rmdir "$state_dir/requests" 2>/dev/null || true +fi + +rm -f "$state_dir/package-installed-by-omarchy" \ + "$state_dir/policy-generated-by-omarchy" \ + "$state_dir/service-enabled-by-omarchy" +rmdir "$state_dir" 2>/dev/null || true + +echo "USB device authorization is disabled." diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization new file mode 100755 index 00000000..0c21c558 --- /dev/null +++ b/bin/omarchy-setup-security-usb-authorization @@ -0,0 +1,112 @@ +#!/bin/bash + +# omarchy:summary=Ask before new USB devices can access the system +# omarchy:args=[--boot] [--yes] +# omarchy:requires-sudo=true + +set -euo pipefail + +assume_yes=0 +from_boot=0 + +while (( $# > 0 )); do + case "$1" in + --yes) assume_yes=1 ;; + --boot) from_boot=1 ;; + -h | --help) + echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes]" + echo + echo "Trusts USB devices that are connected now, then blocks new devices" + echo "until you approve them from an Omarchy notification." + echo + echo "--boot also blocks USB devices from the start of kernel boot. Connected" + echo "devices are added to USBGuard's policy, but remain unavailable before" + echo "USBGuard starts, including at an encrypted-disk password prompt." + exit 0 + ;; + *) + echo "omarchy-setup-security-usb-authorization: unknown option '$1'. Try --help." >&2 + exit 2 + ;; + esac + shift +done + +state_dir="$HOME/.local/state/omarchy/usb-authorization" +unit_source="$OMARCHY_PATH/default/systemd/user/omarchy-usb-authorization.service" +unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service" +rules_file=/etc/usbguard/rules.conf +daemon_config=/etc/usbguard/usbguard-daemon.conf + +source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" + +if (( ! assume_yes )); then + if (( from_boot )); then + gum style --foreground 212 --bold "USB authorization from boot" + else + gum style --foreground 212 --bold "USB device authorization" + fi + echo + echo "Devices connected now will be trusted. Disconnect anything you do not trust." + echo "New USB devices will remain blocked until you approve them." + if (( from_boot )); then + echo + echo "Boot-time protection blocks all USB devices until USBGuard starts." + echo "A USB keyboard will not work at an encrypted-disk password prompt, even" + echo "when it is connected now. Continue only if unlock and recovery do not" + echo "depend on USB input." + fi + echo + if (( from_boot )); then + gum confirm "Enable USB authorization from kernel boot?" || exit 130 + else + gum confirm "Enable USB device authorization?" || exit 130 + fi +fi + +omarchy-pkg-add usbguard + +usb_authorization_require_secure_settings "$daemon_config" + +mkdir -p "$state_dir" "${unit%/*}" +chmod 700 "$state_dir" + +if ! usb_authorization_rules_present "$rules_file"; then + policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") + trap 'rm -f "$policy"' EXIT + chmod 600 "$policy" + usb_authorization_generate_policy "$policy" + sudo install -Dm600 -o root -g root "$policy" "$rules_file" + touch "$state_dir/policy-generated-by-omarchy" +fi + +# This user can inspect USBGuard events and approve the device named by an +# event. Policy and daemon parameters remain unavailable through the IPC ACL. +sudo usbguard add-user "$USER" \ + --devices=list,listen,modify \ + --policy=list \ + --exceptions=listen + +sudo systemctl enable usbguard.service +# add-user ACL changes are loaded only when the daemon starts, so restart an +# existing service as well as starting a fresh one. +sudo systemctl restart usbguard.service + +ln -sfn "$unit_source" "$unit" +systemctl --user daemon-reload +systemctl --user enable --now omarchy-usb-authorization.service + +if (( from_boot )); then + # Take the trust snapshot only after USBGuard is enforcing the policy. Every + # ID is resolved by the daemon, and no device-controlled descriptor text is + # passed to a privileged command. + usb_authorization_trust_present_devices + sudo omarchy-usb-authorization-boot enable +fi + +echo +echo "USB device authorization is enabled." +echo "Devices connected during setup remain trusted; new devices require approval." +if (( from_boot )); then + echo "Boot-time authorization is enabled and takes effect after reboot." +fi diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot new file mode 100755 index 00000000..e72139a3 --- /dev/null +++ b/bin/omarchy-usb-authorization-boot @@ -0,0 +1,432 @@ +#!/bin/bash + +# omarchy:summary=Manage USB authorization from the start of kernel boot +# omarchy:args= +# omarchy:requires-sudo=true +# omarchy:hidden=true + +set -euo pipefail + +drop_in=/etc/limine-entry-tool.d/usb-authorization.conf +setting='KERNEL_CMDLINE[default]+=" usbcore.authorized_default=0"' +limine_conf=/boot/limine.conf +limine_defaults=/etc/default/limine +boot_path=/boot +kernel_parameter=usbcore.authorized_default=0 +snapshot_begin='# Omarchy USB authorization begin' +snapshot_setting='SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' +snapshot_end='# Omarchy USB authorization end' + +usb_authorization_cmdline_has_parameter() { + local cmdline="$1" parameter="$2" word + + for word in $cmdline; do + [[ $word == "$parameter" ]] && return 0 + done + + return 1 +} + +usb_authorization_secure_boot_enabled() { + local value variable + + for variable in /sys/firmware/efi/efivars/SecureBoot-*; do + [[ -r $variable ]] || continue + value=$(od -An -t u1 -j 4 -N 1 "$variable") + value=${value//[[:space:]]/} + [[ $value == "1" ]] && return 0 + done + + return 1 +} + +usb_authorization_verify_boot_entries() { + local expected="$1" config="$2" esp="$3" + local machine_id="${4:-$(&2 + return 1 + fi + + paths=$(mktemp /tmp/omarchy-usb-boot-paths.XXXXXXXXXX) + section=$(mktemp /tmp/omarchy-usb-boot-cmdline.XXXXXXXXXX) + + if ! awk -v expected="$expected" -v machine_id="$machine_id" -v parameter="$kernel_parameter" ' + function contains_parameter(value, count, fields, field_index) { + count = split(value, fields, /[[:space:]]+/) + for (field_index = 1; field_index <= count; field_index++) { + if (fields[field_index] == parameter) return 1 + } + return 0 + } + + function finish_entry(found) { + if (!target_os || !is_kernel) return + entries++ + + if (has_cmdline) { + found = contains_parameter(cmdline) + if ((expected == "enabled" && !found) || (expected == "disabled" && found)) bad = 1 + } + + if (path == "") { + bad = 1 + } else { + print (has_cmdline ? "external" : "embedded") "\t" path + } + } + + /^\/[^/]/ { + finish_entry() + target_os = 0 + is_kernel = 0 + has_cmdline = 0 + cmdline = "" + path = "" + next + } + + /^[[:space:]]*comment:[[:space:]]*machine-id=/ { + target_os = index($0, "machine-id=" machine_id) > 0 + next + } + + /^[[:space:]]*\/\// { + finish_entry() + is_kernel = 0 + has_cmdline = 0 + cmdline = "" + path = "" + next + } + + /^[[:space:]]*comment:[[:space:]]*kernel-id=/ { + is_kernel = target_os + next + } + + is_kernel && /^[[:space:]]*path:[[:space:]]*/ { + path = $0 + sub(/^[[:space:]]*path:[[:space:]]*/, "", path) + next + } + + is_kernel && /^[[:space:]]*cmdline:[[:space:]]*/ { + has_cmdline = 1 + cmdline = $0 + sub(/^[[:space:]]*cmdline:[[:space:]]*/, "", cmdline) + next + } + + END { + finish_entry() + if (entries == 0) bad = 1 + exit bad + } + ' "$config" >"$paths"; then + rm -f "$paths" "$section" + echo "The generated Limine kernel entries do not all have USB authorization $expected." >&2 + return 1 + fi + + while IFS=$'\t' read -r mode path; do + if [[ $path != 'boot():/'* ]]; then + rm -f "$paths" "$section" + echo "Cannot verify the kernel image referenced by Limine: $path" >&2 + return 1 + fi + + image="$esp/${path#'boot():/'}" + image=${image%%#*} + if [[ ! -r $image ]]; then + rm -f "$paths" "$section" + echo "Cannot verify the kernel image referenced by Limine: $image" >&2 + return 1 + fi + + if [[ $path == *'#'* ]]; then + expected_hash=${path##*#} + actual_hash=$(b2sum "$image") + actual_hash=${actual_hash%% *} + if [[ $actual_hash != "$expected_hash" ]]; then + rm -f "$paths" "$section" + echo "The verification hash for $image does not match the generated Limine entry." >&2 + return 1 + fi + fi + + : >"$section" + if ! objcopy -O binary --only-section=.cmdline "$image" "$section" 2>/dev/null; then + [[ $mode == "external" ]] && continue + rm -f "$paths" "$section" + echo "Cannot inspect the embedded kernel command line in $image." >&2 + return 1 + fi + embedded_cmdline=$(tr '\0' '\n' <"$section") + + # Without Secure Boot, Limine's external command line takes precedence. + # Under Secure Boot, systemd-stub uses a nonempty embedded .cmdline. + if [[ $mode == "external" && $secure_boot == "disabled" ]]; then + continue + fi + + # A signed UKI can ignore Limine's external cmdline and use its embedded + # .cmdline instead. If one exists, both representations must agree. + if [[ -z $embedded_cmdline ]]; then + if [[ $mode == "external" || $expected == "disabled" ]]; then + continue + fi + rm -f "$paths" "$section" + echo "The generated kernel image $image has no verifiable kernel command line." >&2 + return 1 + fi + + if [[ $expected == "enabled" ]]; then + if ! usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then + rm -f "$paths" "$section" + echo "The generated kernel image $image does not enable boot-time USB authorization." >&2 + return 1 + fi + elif usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then + rm -f "$paths" "$section" + echo "The generated kernel image $image still enables boot-time USB authorization." >&2 + return 1 + fi + done <"$paths" + + rm -f "$paths" "$section" +} + +usb_authorization_rebuild_and_verify() { + local expected="$1" config="$2" esp="$3" + local machine_id="${4:-$(&2 + return 1 + fi + + temporary=$(mktemp "${config}.XXXXXXXXXX") + cp --preserve=mode,ownership "$config" "$temporary" + { + cat "$config" + printf '\n%s\n%s\n%s\n' "$snapshot_begin" "$snapshot_setting" "$snapshot_end" + } >"$temporary" + mv -f "$temporary" "$config" +} + +usb_authorization_disable_snapshot_setting() { + local config="$1" status temporary + + if usb_authorization_snapshot_block_status "$config"; then + status=0 + else + status=$? + fi + (( status == 1 )) && return 0 + if (( status != 0 )); then + echo "Refusing to modify the USB authorization block in $config." >&2 + return 1 + fi + + temporary=$(mktemp "${config}.XXXXXXXXXX") + cp --preserve=mode,ownership "$config" "$temporary" + awk -v begin="$snapshot_begin" ' + $0 == begin { skip = 2; next } + skip > 0 { skip--; next } + { print } + ' "$config" >"$temporary" + mv -f "$temporary" "$config" +} + +usb_authorization_rewrite_boot_cmdlines() { + local expected="$1" config="$2" machine_id="${3:-$("$temporary"; then + rm -f "$backup" "$temporary" + return 1 + fi + + mv -f "$temporary" "$config" + if ! limine-enroll-config; then + mv -f "$backup" "$config" + limine-enroll-config >/dev/null 2>&1 || true + echo "Restored $config because its bootloader verification data could not be updated." >&2 + return 1 + fi + + rm -f "$backup" +} + +main() { + local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary + + if (( EUID != 0 )); then + echo "Error: omarchy-usb-authorization-boot must run as root" >&2 + exit 1 + fi + + case "${1:-}" in + enable) + if usb_authorization_snapshot_block_status "$limine_defaults"; then + snapshot_status=0 + else + snapshot_status=$? + fi + if (( snapshot_status == 2 )); then + echo "Refusing to modify the USB authorization block in $limine_defaults." >&2 + exit 1 + fi + + # Quote the expected value because the Limine key contains glob brackets. + if [[ -e $drop_in ]] && [[ $(<"$drop_in") != "$setting" ]]; then + echo "Refusing to replace the existing $drop_in." >&2 + exit 1 + fi + + usb_authorization_enable_snapshot_setting "$limine_defaults" + + if [[ ! -e $drop_in ]]; then + install -d -m755 "${drop_in%/*}" + temporary=$(mktemp "${drop_in}.XXXXXXXXXX") + trap 'rm -f "$temporary"' EXIT + printf '%s\n' "$setting" >"$temporary" + chmod 644 "$temporary" + mv -f "$temporary" "$drop_in" + trap - EXIT + fi + + if ! limine-mkinitcpio || + ! usb_authorization_rewrite_boot_cmdlines enabled "$limine_conf" || + ! usb_authorization_verify_boot_entries enabled "$limine_conf" "$boot_path"; then + echo "Boot-time USB authorization remains configured in $drop_in; rerun this command after fixing the boot-image error." >&2 + exit 1 + fi + ;; + disable) + if usb_authorization_snapshot_block_status "$limine_defaults"; then + had_snapshot_setting=1 + else + snapshot_status=$? + if (( snapshot_status == 2 )); then + echo "Refusing to modify the USB authorization block in $limine_defaults." >&2 + exit 1 + fi + fi + + if [[ -e $drop_in ]]; then + if [[ $(<"$drop_in") != "$setting" ]]; then + echo "Refusing to remove the modified $drop_in." >&2 + exit 1 + fi + had_drop_in=1 + rm -f "$drop_in" + elif (( ! had_snapshot_setting )) && usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then + exit 0 + fi + + usb_authorization_disable_snapshot_setting "$limine_defaults" + if ! limine-mkinitcpio || + ! usb_authorization_rewrite_boot_cmdlines disabled "$limine_conf" || + ! usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then + if (( had_drop_in )); then + printf '%s\n' "$setting" >"$drop_in" + chmod 644 "$drop_in" + fi + if (( had_snapshot_setting )); then + usb_authorization_enable_snapshot_setting "$limine_defaults" + fi + echo "USBGuard remains required because the boot images could not be verified as default-allow." >&2 + exit 1 + fi + ;; + *) + echo "Usage: omarchy-usb-authorization-boot " >&2 + exit 2 + ;; + esac +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event new file mode 100755 index 00000000..53182715 --- /dev/null +++ b/bin/omarchy-usb-authorization-event @@ -0,0 +1,40 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Create an approval request for a blocked USB device + +set -euo pipefail + +[[ ${USBGUARD_IPC_SIGNAL:-} == "Device.PresenceChanged" ]] || exit 0 +[[ ${USBGUARD_DEVICE_EVENT:-} == "Insert" ]] || exit 0 +[[ ${USBGUARD_DEVICE_TARGET:-} == "block" ]] || exit 0 +[[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0 +[[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0 + +requests="$HOME/.local/state/omarchy/usb-authorization/requests" +mkdir -p "$requests" +chmod 700 "${requests%/*}" "$requests" + +# The token is safe to carry through the presentation launcher's command +# string. The device-controlled rule itself stays in a mode-0600 JSON file and +# is never interpolated into a shell command. +token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | sha256sum | cut -d' ' -f1)" +request="$requests/$token.json" +temporary=$(mktemp "$requests/.request.XXXXXXXXXX") +trap 'rm -f "$temporary"' EXIT + +jq -n \ + --arg id "$USBGUARD_DEVICE_ID" \ + --arg rule "$USBGUARD_DEVICE_RULE" \ + '{id: $id, rule: $rule}' >"$temporary" +chmod 600 "$temporary" +mv -f "$temporary" "$request" + +omarchy-notification-send \ + --app-name omarchy-action \ + --urgency critical \ + --glyph 󰖀 \ + "USB accessory blocked" \ + "Click to review it. Keep it blocked unless you recognize the device." \ + --exec omarchy-launch-floating-terminal-with-presentation \ + omarchy-usb-authorization-review "$token" diff --git a/bin/omarchy-usb-authorization-restore-default b/bin/omarchy-usb-authorization-restore-default new file mode 100755 index 00000000..e5844db8 --- /dev/null +++ b/bin/omarchy-usb-authorization-restore-default @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:summary=Restore the kernel's default USB authorization state +# omarchy:requires-sudo=true +# omarchy:hidden=true + +set -euo pipefail + +if (( EUID != 0 )); then + echo "Error: omarchy-usb-authorization-restore-default must run as root" >&2 + exit 1 +fi + +# USBGuard intentionally leaves root hubs in default-deny mode when it exits. +# Restore both the hub defaults and devices that are still connected. +shopt -s nullglob +for attribute in /sys/bus/usb/devices/usb*/authorized_default; do + echo 1 >"$attribute" +done +for attribute in /sys/bus/usb/devices/*/authorized; do + echo 1 >"$attribute" +done +shopt -u nullglob diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review new file mode 100755 index 00000000..aa3c16c4 --- /dev/null +++ b/bin/omarchy-usb-authorization-review @@ -0,0 +1,79 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Review a blocked USB device authorization request + +set -euo pipefail + +token="${1:-}" +if [[ ! $token =~ ^request-[0-9a-f]{64}$ ]]; then + echo "This USB authorization request is invalid or expired." >&2 + exit 1 +fi + +request="$HOME/.local/state/omarchy/usb-authorization/requests/$token.json" +if [[ ! -f $request || -L $request ]]; then + echo "This USB authorization request is no longer available." >&2 + exit 1 +fi + +id=$(jq -er '.id | select(test("^[0-9]+$"))' "$request") +expected_rule=$(jq -er '.rule | select(type == "string" and length > 0)' "$request") +read_blocked_rule() { + local line + + while IFS= read -r line; do + if [[ $line == "$id: "* ]]; then + printf '%s\n' "${line#"$id: "}" + return 0 + fi + done < <(usbguard list-devices --blocked) + + return 1 +} + +current_rule=$(read_blocked_rule || true) + +if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 +fi + +gum style --foreground 212 --bold "Blocked USB accessory" +echo +echo "The following details come from the device and can be forged:" +printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' +echo + +choice=$(gum choose \ + "Allow once" \ + "Always allow this device" \ + "Keep blocked") || exit 130 + +if [[ $choice != "Keep blocked" ]]; then + current_rule=$(read_blocked_rule || true) + if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 + fi +fi + +case "$choice" in +"Allow once") + # Target the complete identity rule instead of USBGuard's numeric ID. IDs can + # be reassigned when the daemon restarts while the review dialog is open. + usbguard allow-device "$expected_rule" + echo "USB accessory allowed until it is disconnected." + ;; +"Always allow this device") + usbguard allow-device --permanent "$expected_rule" + echo "USB accessory added to the trusted policy." + ;; +"Keep blocked") + echo "USB accessory remains blocked." + ;; +esac + +rm -f "$request" diff --git a/bin/omarchy-usb-authorization-watch b/bin/omarchy-usb-authorization-watch new file mode 100755 index 00000000..8b22fdf4 --- /dev/null +++ b/bin/omarchy-usb-authorization-watch @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Watch USBGuard for devices awaiting user approval + +set -euo pipefail + +event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event" + +# A device inserted while the graphical session was stopped is already safely +# blocked by USBGuard. Surface it now so the user can approve it after login. +while IFS= read -r line; do + if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then + USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ + "$event_handler" + fi +done < <(usbguard list-devices --blocked) + +exec usbguard watch --wait --exec "$event_handler" diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 7583d8d8..c0dd2eda 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -181,6 +181,8 @@ "setup.config": {"icon":"","label":"Config"}, "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, + "setup.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"}, + "setup.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, @@ -298,6 +300,8 @@ "remove.security": {"icon":"","label":"Security","title":"Remove"}, "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, + "remove.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"}, + "remove.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, diff --git a/default/systemd/user/omarchy-usb-authorization.service b/default/systemd/user/omarchy-usb-authorization.service new file mode 100644 index 00000000..bfc27a61 --- /dev/null +++ b/default/systemd/user/omarchy-usb-authorization.service @@ -0,0 +1,17 @@ +[Unit] +Description=Ask before allowing new USB devices +After=graphical-session.target dbus.socket +Requires=dbus.socket +PartOf=graphical-session.target +ConditionEnvironment=OMARCHY_PATH +ConditionEnvironment=WAYLAND_DISPLAY + +[Service] +Type=simple +ExecCondition=/usr/bin/systemctl is-active --quiet usbguard.service +ExecStart=/usr/bin/bash -lc 'exec "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch"' +Restart=always +RestartSec=2 + +[Install] +WantedBy=graphical-session.target diff --git a/install/config/all.sh b/install/config/all.sh index a221a398..3ff8d66a 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,5 +7,6 @@ run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh" run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" +run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/usb-authorization.sh b/install/config/usb-authorization.sh new file mode 100644 index 00000000..958b82ae --- /dev/null +++ b/install/config/usb-authorization.sh @@ -0,0 +1,28 @@ +echo "Configure USB device authorization" + +rules_file="${OMARCHY_USB_AUTHORIZATION_RULES_FILE:-/etc/usbguard/rules.conf}" +daemon_config="${OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG:-/etc/usbguard/usbguard-daemon.conf}" + +source "$OMARCHY_INSTALL/helpers/usb-authorization.sh" +usb_authorization_require_secure_settings "$daemon_config" + +if [[ ! -s $rules_file ]]; then + policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") + chmod 600 "$policy" + + if usb_authorization_generate_policy "$policy"; then + install -Dm600 -o root -g root "$policy" "$rules_file" + rm -f "$policy" + else + rm -f "$policy" + return 1 + fi +fi + +if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then + usb_authorization_add_user "$OMARCHY_INSTALL_USER" +fi + +# Installs are followed by a reboot, so enable the daemon without starting it +# in the live ISO's chroot. +systemctl enable usbguard.service diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh new file mode 100644 index 00000000..219a8e5b --- /dev/null +++ b/install/helpers/usb-authorization.sh @@ -0,0 +1,99 @@ +usb_authorization_config_value() { + local config="$1" key="$2" + local awk_command=(awk) + + if [[ ! -r $config ]]; then + if [[ $config == "/etc/usbguard/usbguard-daemon.conf" ]]; then + awk_command=(sudo awk) + else + echo "Cannot read USBGuard configuration: $config" >&2 + return 1 + fi + fi + + "${awk_command[@]}" -F= -v key="$key" ' + /^[[:space:]]*#/ { next } + $1 ~ "^[[:space:]]*" key "[[:space:]]*$" { + value=$0 + sub(/^[^=]*=[[:space:]]*/, "", value) + sub(/[[:space:]]*$/, "", value) + print value + exit + } + ' "$config" +} + +usb_authorization_rules_present() { + local rules="$1" + + if [[ -r $rules ]]; then + [[ -s $rules ]] + elif [[ $rules == "/etc/usbguard/rules.conf" ]]; then + sudo test -s "$rules" + else + return 1 + fi +} + +usb_authorization_require_secure_setting() { + local config="$1" key="$2" expected="$3" actual + + actual=$(usb_authorization_config_value "$config" "$key") + if [[ $actual != "$expected" ]]; then + echo "USBGuard's $key must be '$expected' before Omarchy enables it." >&2 + echo "Current value: ${actual:-} in $config" >&2 + return 1 + fi +} + +usb_authorization_require_secure_settings() { + local config="$1" + + usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block + usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy + usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy + usb_authorization_require_secure_setting "$config" AuthorizedDefault none + usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false +} + +usb_authorization_generate_policy() { + local policy="$1" + + usbguard generate-policy >"$policy" + if [[ ! -s $policy ]] || ! grep -q '^allow ' "$policy"; then + echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 + return 1 + fi +} + +usb_authorization_add_user() { + local user="$1" + + # This user can inspect USBGuard events and approve the device named by an + # event. Policy and daemon parameters remain unavailable through the IPC ACL. + usbguard add-user "$user" \ + --devices=list,listen,modify \ + --policy=list \ + --exceptions=listen +} + +usb_authorization_trust_present_devices() { + local line id + local count=0 + local -a devices + + mapfile -t devices < <(usbguard list-devices) + + for line in "${devices[@]}"; do + if [[ $line =~ ^([0-9]+):[[:space:]] ]]; then + id="${BASH_REMATCH[1]}" + usbguard allow-device --permanent "$id" + (( ++count )) + fi + done + + if (( count == 0 )); then + echo "USBGuard did not report any connected USB devices; boot authorization was not enabled." >&2 + return 1 + fi +} diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 76e74110..3579c27c 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -133,6 +133,7 @@ udiskie ufw ufw-docker unzip +usbguard usage uwsm vi diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 9865fc20..67addca5 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -18,4 +18,5 @@ systemctl --user enable --now \ omarchy-sleep-lock.service \ omarchy-migrate-notify.service \ omarchy-fcitx5.service \ - omarchy-crash-watch.service + omarchy-crash-watch.service \ + omarchy-usb-authorization.service diff --git a/manual/48-security.md b/manual/48-security.md index 45750398..76f1db24 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -6,7 +6,20 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys 2. *Firewall is enabled by default*: All incoming traffic is blocked by default except for port 53317 for [LocalSend](https://localsend.org/). Even ssh is off until you turn it on via _Setup > Security > SSHD_, which opens port 22 (rate limited against brute force) as part of the setup. We even lock down Docker access using the [ufw-docker](https://github.com/chaifeng/ufw-docker) setup to prevent that your containers are accidentally exposed to the world. 3. *Arch always have the latest updates*: Arch, the underlying distro that Omarchy is built on, is a rolling distribution. This means that any security vulnerability that's discovered and patched in any package is quickly available for install using `omarchy-update`. You're always running the latest, most secure versions of everything that way. 4. *Omarchy maintains its own packages and mirror*: Omarchy only relies on packages from Arch's own core/extra/multilib repositories and its own Omarchy Package Repository by default. You can install software directly from AUR, but the base install doesn't — only a few optional installs, like the third-party browsers, pull from the AUR. -5. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability. +5. *New USB devices require approval*: Omarchy trusts the USB devices present during installation, then blocks newly connected accessories until you approve them. This keeps an unknown accessory from reaching a matching kernel driver merely because it claims a supported device identity. +6. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability. + +## USB Device Authorization + +USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked. + +You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy. + +Only approve hardware you recognize. A malicious USB device can forge the manufacturer, model, serial number, and device class shown during review. Permanent approval is a convenience for accessories you control, not proof of the device's identity. + +The policy protects devices connected after USBGuard starts during boot. It does not replace operating-system and driver security updates, and USB devices needed before the service starts, such as a keyboard used to unlock an encrypted disk, remain governed by the kernel's boot-time policy. + +For machines whose disk unlock and recovery never depend on USB input, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. ## Changing your passwords diff --git a/migrations/1789433473.sh b/migrations/1789433473.sh new file mode 100644 index 00000000..47a59c55 --- /dev/null +++ b/migrations/1789433473.sh @@ -0,0 +1,3 @@ +echo "Enable approval for newly connected USB devices" + +omarchy-setup-security-usb-authorization --yes diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index a424379b..cf8b7a35 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -324,6 +324,13 @@ assert( defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'), 'menu places Passwordless Sudo under Setup > Security' ) +assert( + defaultById['setup.security.usb-authorization'].action.includes('omarchy-setup-security-usb-authorization') + && defaultById['remove.security.usb-authorization'].action.includes('omarchy-remove-security-usb-authorization') + && defaultById['setup.security.usb-authorization-boot'].action.includes('omarchy-setup-security-usb-authorization --boot') + && defaultById['remove.security.usb-authorization-boot'].action.includes('omarchy-remove-security-usb-authorization --boot-only'), + 'menu can enable and remove USB device authorization under Security' +) assert( !defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'], 'menu removes the relocated toggles from Trigger > Toggle' diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh new file mode 100755 index 00000000..68c8ed56 --- /dev/null +++ b/test/shell.d/usb-authorization-test.sh @@ -0,0 +1,405 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT + +stub_bin="$scratch/bin" +home="$scratch/home" +rules="$scratch/etc/usbguard/rules.conf" +daemon_config="$scratch/etc/usbguard/usbguard-daemon.conf" +calls="$scratch/calls" + +mkdir -p "$stub_bin" "$home" "${rules%/*}" +: >"$rules" +: >"$calls" + +cat >"$daemon_config" <<'CONF' +ImplicitPolicyTarget=block +PresentDevicePolicy=apply-policy +InsertedDevicePolicy=apply-policy +AuthorizedDefault=none +RestoreControllerDeviceState=false +CONF + +cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-pkg-present" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-pkg-add" <<'STUB' +#!/bin/bash +printf 'pkg-add <%s>\n' "$*" >>"$CALLS" +STUB +cat >"$stub_bin/omarchy-pkg-drop" <<'STUB' +#!/bin/bash +printf 'pkg-drop <%s>\n' "$*" >>"$CALLS" +STUB +cat >"$stub_bin/install" <<'STUB' +#!/bin/bash +if [[ $1 == -Dm600 && $2 == -o && $3 == root && $4 == -g && $5 == root ]]; then + exec /usr/bin/install -Dm600 "$6" "$7" +fi +exec /usr/bin/install "$@" +STUB +cat >"$stub_bin/systemctl" <<'STUB' +#!/bin/bash +printf 'systemctl <%s>\n' "$*" >>"$CALLS" +if [[ $* == "is-enabled --quiet usbguard.service" ]]; then + exit 1 +fi +STUB +cat >"$stub_bin/usbguard" <<'STUB' +#!/bin/bash +case "$1" in +generate-policy) + echo 'usbguard ' >>"$CALLS" + echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + ;; +list-devices) + if [[ ${2:-} == "--blocked" ]]; then + if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then + rule="${BLOCKED_DEVICE_RULE:?}" + if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then + rule=$(<"$BLOCKED_DEVICE_RULE_FILE") + fi + printf '17: %s\n' "$rule" + fi + else + echo '4: allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' + fi + ;; +*) + printf 'usbguard' >>"$CALLS" + printf ' <%s>' "$@" >>"$CALLS" + printf '\n' >>"$CALLS" + ;; +esac +STUB +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +printf 'sudo' >>"$CALLS" +printf ' <%s>' "$@" >>"$CALLS" +printf '\n' >>"$CALLS" +if [[ $1 == awk && ${!#} == /etc/usbguard/usbguard-daemon.conf ]]; then + args=("$@") + args[-1]="$TEST_DAEMON_CONFIG" + exec "${args[@]}" +elif [[ $1 == test && $2 == -s && $3 == /etc/usbguard/rules.conf ]]; then + exec /usr/bin/test -s "$TEST_RULES" +elif [[ $1 == install && $2 == -Dm600 && $3 == -o && $4 == root && $5 == -g && $6 == root && $8 == /etc/usbguard/rules.conf ]]; then + exec /usr/bin/install -Dm600 "$7" "$TEST_RULES" +elif [[ $1 == omarchy-usb-authorization-restore-default ]]; then + echo 1 >"$TEST_SYSFS/usb1/authorized_default" + echo 1 >"$TEST_SYSFS/1-2/authorized" + exit 0 +elif [[ $1 == omarchy-usb-authorization-boot ]]; then + exit 0 +fi +exec "$@" +STUB +cat >"$stub_bin/gum" <<'STUB' +#!/bin/bash +case "$1" in +style) exit 0 ;; +choose) + if [[ -n ${GUM_REASSIGN_RULE:-} ]]; then + printf '%s\n' "$GUM_REASSIGN_RULE" >"$BLOCKED_DEVICE_RULE_FILE" + fi + printf '%s\n' "${GUM_CHOICE:-Keep blocked}" + ;; +confirm) exit 0 ;; +esac +STUB +cat >"$stub_bin/omarchy-notification-send" <<'STUB' +#!/bin/bash +printf 'notification' >>"$CALLS" +printf ' <%s>' "$@" >>"$CALLS" +printf '\n' >>"$CALLS" +STUB +chmod +x "$stub_bin"/* + +export HOME="$home" +export USER=tester +export OMARCHY_PATH="$ROOT" +export CALLS="$calls" +export PATH="$stub_bin:$ROOT/bin:$PATH" +export TEST_DAEMON_CONFIG="$daemon_config" +export TEST_RULES="$rules" + +if "$ROOT/bin/omarchy-usb-authorization-restore-default" >/dev/null 2>&1; then + fail "the fixed-path USB authorization restore helper requires root" +fi +if "$ROOT/bin/omarchy-usb-authorization-boot" enable >/dev/null 2>&1; then + fail "the fixed-path boot authorization helper requires root" +fi +! grep -q 'OMARCHY_USB_AUTHORIZATION_.*ROOT\|OMARCHY_USB_AUTHORIZATION_.*FILE\|OMARCHY_USB_AUTHORIZATION_.*CONFIG' \ + "$ROOT/bin/omarchy-setup-security-usb-authorization" \ + "$ROOT/bin/omarchy-remove-security-usb-authorization" \ + "$ROOT/bin/omarchy-usb-authorization-boot" || + fail "user-facing USB authorization commands do not elevate caller-selected paths" +pass "USB authorization keeps privileged paths fixed" + +"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-output" + +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || + fail "USB authorization setup trusts devices present during enrollment" +[[ $(stat -c %a "$rules") == 600 ]] || fail "USB authorization policy is root-private" +grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || + fail "USB authorization grants only the IPC access needed by the approval flow" +grep -Fqx 'systemctl ' "$calls" || + fail "USB authorization reloads its user ACL before the watcher starts" +[[ -L $home/.config/systemd/user/omarchy-usb-authorization.service ]] || + fail "USB authorization installs the graphical-session watcher" +grep -Fqx 'pkg-add ' "$calls" || fail "USB authorization ensures USBGuard is installed" +pass "USB authorization enrolls present devices before enabling default-deny" + +generate_count=$(grep -c '^usbguard $' "$calls") +"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-existing-output" +generate_count_after=$(grep -c '^usbguard $' "$calls") +(( generate_count_after == generate_count )) || fail "re-enabling preserves the existing trusted-device policy" +pass "USB authorization setup preserves an existing policy" + +: >"$calls" +"$ROOT/bin/omarchy-setup-security-usb-authorization" --boot --yes >"$scratch/setup-boot-output" +grep -Fqx 'usbguard <--permanent> <4>' "$calls" || + fail "boot authorization trusts the connected root controller" +grep -Fqx 'usbguard <--permanent> <5>' "$calls" || + fail "boot authorization trusts the connected USB devices" +grep -Fqx 'sudo ' "$calls" || + fail "boot authorization uses the fixed-path boot-image helper" +grep -Fq 'takes effect after reboot' "$scratch/setup-boot-output" || + fail "boot authorization explains when the change becomes active" +pass "USB authorization can snapshot connected devices for early-boot denial" + +boot_conf="$scratch/limine.conf" +machine_id=$("$scratch/embedded-cmdline" +historical_uki="$scratch/embedded.efi_sha256_fixture" +objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki" +objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki" +embedded_hash=$(b2sum "$historical_uki") +embedded_hash=${embedded_hash%% *} +cat >"$boot_conf" </dev/null; then + fail "boot authorization rejects Limine's false-success status when entries were not rebuilt" +fi +usb_authorization_rewrite_boot_cmdlines enabled "$boot_conf" "$machine_id" +usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" disabled || + fail "a non-Secure-Boot snapshot uses its updated external command line" +if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then + fail "an external allow parameter cannot hide a stale embedded UKI command line" +fi +printf 'quiet splash usbcore.authorized_default=0\0' >"$scratch/embedded-cmdline" +objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki" +objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki" +updated_embedded_hash=$(b2sum "$historical_uki") +updated_embedded_hash=${updated_embedded_hash%% *} +sed -i "s/embedded.efi_sha256_fixture#$embedded_hash/embedded.efi_sha256_fixture#$updated_embedded_hash/" "$boot_conf" +usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled || + fail "boot authorization updates current and snapshot entries for this machine" +[[ $(grep -c 'usbcore.authorized_default=0' "$boot_conf") == 3 ]] || + fail "boot authorization leaves another operating system's kernel entries unchanged" +printf 'stale image' >"$scratch/vmlinuz-linux" +if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then + fail "boot authorization rejects a stale Limine verification hash" +fi +: >"$scratch/vmlinuz-linux" +if usb_authorization_rebuild_and_verify disabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then + fail "boot authorization detects a stale deny-by-default boot entry during removal" +fi +usb_authorization_rewrite_boot_cmdlines disabled "$boot_conf" "$machine_id" +printf 'quiet splash\0' >"$scratch/embedded-cmdline" +objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki" +objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki" +disabled_embedded_hash=$(b2sum "$historical_uki") +disabled_embedded_hash=${disabled_embedded_hash%% *} +sed -i "s/embedded.efi_sha256_fixture#$updated_embedded_hash/embedded.efi_sha256_fixture#$disabled_embedded_hash/" "$boot_conf" +usb_authorization_rebuild_and_verify disabled "$boot_conf" "$scratch" "$machine_id" enabled || + fail "boot authorization removes the parameter from current and snapshot entries" + +snapshot_config="$scratch/limine-defaults" +printf 'TARGET_OS_NAME="Omarchy"\n' >"$snapshot_config" +usb_authorization_enable_snapshot_setting "$snapshot_config" +grep -Fqx 'SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' "$snapshot_config" || + fail "future Limine snapshots inherit boot-time USB authorization" +usb_authorization_enable_snapshot_setting "$snapshot_config" +[[ $(grep -Fxc '# Omarchy USB authorization begin' "$snapshot_config") == 1 ]] || + fail "the snapshot setting remains idempotent" +usb_authorization_disable_snapshot_setting "$snapshot_config" +! grep -Fq 'usbcore.authorized_default=0' "$snapshot_config" || + fail "removing boot authorization restores future snapshot defaults" +pass "boot authorization verifies generated entries despite Limine false-success exits" + +: >"$calls" +"$ROOT/bin/omarchy-remove-security-usb-authorization" --boot-only --yes >"$scratch/remove-boot-output" +grep -Fqx 'sudo ' "$calls" || + fail "boot-only removal rebuilds the permissive boot policy" +! grep -Fq 'systemctl <--user disable --now omarchy-usb-authorization.service>' "$calls" || + fail "boot-only removal leaves the userspace approval watcher enabled" +grep -Fq 'takes effect after reboot' "$scratch/remove-boot-output" || + fail "boot-only removal explains when the change becomes active" +pass "early-boot denial can be removed without disabling USBGuard" + +grep -qx 'usbguard' "$ROOT/install/omarchy-base.packages" || fail "USBGuard is installed by default" +grep -Fq 'config/usb-authorization.sh' "$ROOT/install/config/all.sh" || + fail "fresh installs configure the USB authorization policy" +grep -Fq 'omarchy-usb-authorization.service' "$ROOT/install/user/first-run/enable-user-units.sh" || + fail "fresh installs enable the USB approval watcher" +grep -Fq 'usb_authorization_add_user "$username"' "$ROOT/bin/omarchy-provision-owner" || + fail "deferred provisioning grants the new owner approval access" +grep -Fq 'systemctl try-restart usbguard.service' "$ROOT/bin/omarchy-provision-owner" || + fail "deferred provisioning reloads the new owner ACL" +grep -Fqx 'omarchy-setup-security-usb-authorization --yes' "$ROOT/migrations/1789433473.sh" || + fail "existing installs enable USB authorization during migration" + +: >"$rules" +: >"$calls" +export OMARCHY_INSTALL="$ROOT/install" +export OMARCHY_INSTALL_USER=tester +export OMARCHY_USB_AUTHORIZATION_RULES_FILE="$rules" +export OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG="$daemon_config" +bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-output" +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || + fail "fresh installation enrolls devices present during installation" +grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || + fail "fresh installation grants the owner narrowly scoped approval access" +grep -Fqx 'systemctl ' "$calls" || + fail "fresh installation enables USBGuard for the first boot" +pass "USB authorization is the default for fresh and existing installations" + +malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' +export BLOCKED_DEVICE_RULE="$malicious_rule" +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" + +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +[[ -n $request ]] || fail "a blocked USB device creates a review request" +[[ $(stat -c %a "$request") == 600 ]] || fail "device-controlled request data stays private" +[[ $(jq -r .id "$request") == 17 ]] || fail "the request records the USBGuard device id" +[[ $(jq -r .rule "$request") == "$malicious_rule" ]] || fail "the request preserves the identity snapshot" +! grep -Fq "$malicious_rule" "$calls" || fail "device text never enters the notification's launch command" +[[ ! -e $scratch/injected ]] || fail "device text cannot execute during notification" +token=$(basename "${request%.json}") +grep -Eq "notification .* <$token>$" "$calls" || + fail "the notification action carries only an opaque request token" +pass "blocked device metadata remains data across the desktop notification boundary" + +GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-once-output" +grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review can allow the exact device once" +[[ ! -e $request ]] || fail "a completed review consumes its request" +pass "USB review allows a still-matching device once" + +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +token=$(basename "${request%.json}") +GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-always-output" +grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" +pass "USB review supports explicit persistent trust" + +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +token=$(basename "${request%.json}") +device_rule_file="$scratch/blocked-device-rule" +printf '%s\n' "$malicious_rule" >"$device_rule_file" +replacement_rule='block id 1d50:60c7 name "Replacement gadget" hash "replacement"' +allow_count=$(grep -c '^usbguard ' "$calls") +if BLOCKED_DEVICE_RULE_FILE="$device_rule_file" \ + GUM_REASSIGN_RULE="$replacement_rule" \ + GUM_CHOICE='Always allow this device' \ + "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-reassigned-output" 2>&1; then + fail "review rejects a USBGuard ID reassigned while the approval dialog is open" +fi +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || + fail "an ID reassigned during review cannot authorize a different device" +[[ ! -e $request ]] || fail "a reassigned-ID review request is discarded" +pass "USB review revalidates identity after the approval dialog" + +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +token=$(basename "${request%.json}") +allow_count=$(grep -c '^usbguard ' "$calls") +if BLOCKED_DEVICE_PRESENT=0 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-stale-output" 2>&1; then + fail "review rejects a request after its device disappears" +fi +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || + fail "a reused USBGuard id cannot authorize a different device" +[[ ! -e $request ]] || fail "a stale review request is discarded" +pass "USB review binds approval to the device snapshot" + +sysfs="$scratch/sys/bus/usb/devices" +mkdir -p "$sysfs/usb1" "$sysfs/1-2" +echo 0 >"$sysfs/usb1/authorized_default" +echo 0 >"$sysfs/1-2/authorized" +export TEST_SYSFS="$sysfs" + +"$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/remove-output" + +[[ $(<"$sysfs/usb1/authorized_default") == 1 ]] || fail "removal restores root-hub default authorization" +[[ $(<"$sysfs/1-2/authorized") == 1 ]] || fail "removal reauthorizes devices left connected" +grep -Fqx 'systemctl ' "$calls" || fail "removal stops a service Omarchy enabled" +grep -Fqx 'usbguard ' "$calls" || fail "removal drops the user's USBGuard IPC access" +grep -Fqx 'sudo ' "$calls" || + fail "removal restores USB authorization only through the fixed-path root helper" +grep -Fqx 'sudo ' "$calls" || + fail "removing USB authorization also removes early-boot denial" +! grep -Fqx 'pkg-drop ' "$calls" || fail "disabling keeps the default USBGuard package installed" +[[ ! -e $home/.config/systemd/user/omarchy-usb-authorization.service ]] || + fail "removal deletes the graphical-session watcher" +pass "USB authorization removal restores the original default-allow behavior" From 7309746ac842a30774be8da850ce358306bc76df Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 13:05:25 -0600 Subject: [PATCH 02/16] Fix USB approval alerts after policy changes and reconnects --- bin/omarchy-usb-authorization-event | 44 ++++++++++++++-- bin/omarchy-usb-authorization-watch | 15 +----- test/shell.d/usb-authorization-test.sh | 72 +++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index 53182715..31031127 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -5,9 +5,30 @@ set -euo pipefail -[[ ${USBGUARD_IPC_SIGNAL:-} == "Device.PresenceChanged" ]] || exit 0 -[[ ${USBGUARD_DEVICE_EVENT:-} == "Insert" ]] || exit 0 -[[ ${USBGUARD_DEVICE_TARGET:-} == "block" ]] || exit 0 +case "${USBGUARD_IPC_SIGNAL:-}" in +IPC.Connected) + # USBGuard scans present devices before accepting IPC connections. Repeat + # this on every reconnect: temporary approvals disappear on daemon restart. + blocked=$(usbguard list-devices --blocked) || exit 1 + while IFS= read -r line; do + if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then + USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ + "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" + fi + done <<<"$blocked" + exit 0 + ;; +Device.PolicyApplied) + # PresenceChanged precedes policy evaluation, even for trusted devices. + # PolicyApplied also covers block-to-block and allow-to-block transitions. + [[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0 + ;; +*) exit 0 ;; +esac + [[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0 [[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0 @@ -20,6 +41,23 @@ chmod 700 "${requests%/*}" "$requests" # is never interpolated into a shell command. token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | sha256sum | cut -d' ' -f1)" request="$requests/$token.json" +# watch executes handlers concurrently. A reconnect scan and a policy event +# can refer to the same device; publish only one pending request. +exec 9>"$requests/.lock" +flock 9 +[[ ! -e $request ]] || exit 0 + +# Discard events overtaken by another policy decision or device replacement. +blocked=$(usbguard list-devices --blocked) || exit 1 +current=0 +while IFS= read -r line; do + if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then + current=1 + break + fi +done <<<"$blocked" +(( current )) || exit 0 + temporary=$(mktemp "$requests/.request.XXXXXXXXXX") trap 'rm -f "$temporary"' EXIT diff --git a/bin/omarchy-usb-authorization-watch b/bin/omarchy-usb-authorization-watch index 8b22fdf4..302c7968 100755 --- a/bin/omarchy-usb-authorization-watch +++ b/bin/omarchy-usb-authorization-watch @@ -7,17 +7,6 @@ set -euo pipefail event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event" -# A device inserted while the graphical session was stopped is already safely -# blocked by USBGuard. Surface it now so the user can approve it after login. -while IFS= read -r line; do - if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then - USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ - USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ - USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ - USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ - "$event_handler" - fi -done < <(usbguard list-devices --blocked) - +# The handler scans blocked devices on IPC.Connected, including reconnects +# after a daemon restart, once the event subscription is established. exec usbguard watch --wait --exec "$event_handler" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 68c8ed56..69d5687c 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -307,10 +307,35 @@ pass "USB authorization is the default for fresh and existing installations" malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' export BLOCKED_DEVICE_RULE="$malicious_rule" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +notification_count=$(grep -c '^notification' "$calls" || true) +for presence in Insert Present; do + USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_EVENT="$presence" \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +done +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_TARGET_NEW=allow \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_RULE="${malicious_rule/#block/allow}" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls" || true) == "$notification_count" ]] || + fail "a trusted device's transient blocked presence must not prompt" +BLOCKED_DEVICE_PRESENT=0 \ + USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls" || true) == "$notification_count" ]] || + fail "a stale blocked event must not prompt after authorization or removal" +pass "USB alerts use the final policy and discard stale events" +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" @@ -326,15 +351,46 @@ grep -Eq "notification .* <$token>$" "$calls" fail "the notification action carries only an opaque request token" pass "blocked device metadata remains data across the desktop notification boundary" +notification_count=$(grep -c '^notification' "$calls") +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" & +scan_pid=$! +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +wait "$scan_pid" +[[ $(grep -c '^notification' "$calls") == "$notification_count" ]] || + fail "overlapping scan and policy events must not duplicate pending prompts" +pass "USB reconnect scans and policy events deduplicate pending requests" + GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-once-output" grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review can allow the exact device once" [[ ! -e $request ]] || fail "a completed review consumes its request" pass "USB review allows a still-matching device once" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +# A daemon restart discards the temporary approval. The IPC reconnect must +# recover the blocked device even when its startup policy events were missed. +notification_count=$(grep -c '^notification' "$calls") +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "reconnection recreates a consumed Allow once request" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "reconnection prompts again for a temporary approval lost on restart" +GUM_CHOICE='Keep blocked' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >/dev/null +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_TARGET_OLD=allow \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "a later allow-to-block policy transition creates a request" +GUM_CHOICE='Keep blocked' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >/dev/null +pass "USB approval recovers after IPC reconnects and policy transitions" + +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) @@ -343,10 +399,10 @@ GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-revie grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" pass "USB review supports explicit persistent trust" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) @@ -366,10 +422,10 @@ fi [[ ! -e $request ]] || fail "a reassigned-ID review request is discarded" pass "USB review revalidates identity after the approval dialog" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) From 4ea410d6a1afe9b4d8a964be23d2fd9f578065ec Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 13:35:57 -0600 Subject: [PATCH 03/16] Retry USB approval notifications after delivery failure --- bin/omarchy-usb-authorization-event | 9 +++++++-- test/shell.d/usb-authorization-test.sh | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index 31031127..a26fd6c6 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -68,11 +68,16 @@ jq -n \ chmod 600 "$temporary" mv -f "$temporary" "$request" -omarchy-notification-send \ +if ! omarchy-notification-send \ --app-name omarchy-action \ --urgency critical \ --glyph 󰖀 \ "USB accessory blocked" \ "Click to review it. Keep it blocked unless you recognize the device." \ --exec omarchy-launch-floating-terminal-with-presentation \ - omarchy-usb-authorization-review "$token" + omarchy-usb-authorization-review "$token"; then + # Only a delivered notification may suppress subsequent attempts. Keep the + # lock until failure cleanup so another event cannot skip an unsent request. + rm -f "$request" + exit 1 +fi diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 69d5687c..1cebe809 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -123,6 +123,7 @@ cat >"$stub_bin/omarchy-notification-send" <<'STUB' printf 'notification' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" +exit "${NOTIFICATION_FAIL:-0}" STUB chmod +x "$stub_bin"/* @@ -439,6 +440,18 @@ fi [[ ! -e $request ]] || fail "a stale review request is discarded" pass "USB review binds approval to the device snapshot" +notification_count=$(grep -c '^notification' "$calls") +if NOTIFICATION_FAIL=1 USBGUARD_IPC_SIGNAL=IPC.Connected \ + "$ROOT/bin/omarchy-usb-authorization-event"; then + fail "a failed notification must report failure" +fi +[[ ! -e $request ]] || fail "a failed notification must not suppress retries" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "reconnection retries a failed notification" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 2)) ]] || + fail "reconnection must attempt delivery after an earlier send failure" +pass "failed USB notifications can be retried on reconnection" + sysfs="$scratch/sys/bus/usb/devices" mkdir -p "$sysfs/usb1" "$sysfs/1-2" echo 0 >"$sysfs/usb1/authorized_default" From 666800eb0c06e11de5bf7bd04282e4b112eef5de Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 13:35:57 -0600 Subject: [PATCH 04/16] Persist USB boot policy in Limine snapshot manifests --- bin/omarchy-usb-authorization-boot | 77 ++++++++++++++++++++++++-- test/shell.d/usb-authorization-test.sh | 44 +++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index e72139a3..6ffb010c 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -338,6 +338,75 @@ usb_authorization_rewrite_boot_cmdlines() { rm -f "$backup" } +usb_authorization_rewrite_snapshot_manifest() { + local expected="$1" manifest="$2" temporary + + [[ -e $manifest ]] || return 0 + temporary=$(mktemp "${manifest}.omarchy-usb.XXXXXXXXXX") + if ! cp --preserve=mode,ownership "$manifest" "$temporary"; then + rm -f "$temporary" + return 1 + fi + + # Limine uses the cached lines when present, and structured command lines + # otherwise. Update both, including nested kernels and restore entries. + if ! jq --arg expected "$expected" --arg parameter "$kernel_parameter" ' + def cmdline: + if type != "string" then error("Invalid snapshot command line") else + [splits("[[:space:]]+") | select(length > 0 and . != $parameter)] + | if $expected == "enabled" then . + [$parameter] else . end + | join(" ") + end; + def lines: + map(if test("^\\s*(cmdline|kernel_cmdline):"; "i") then + capture("^(?\\s*(?:cmdline|kernel_cmdline):\\s*)(?.*)$"; "i") + | .prefix + (.value | cmdline) + else . end); + def kernel: + .cmdlineDetails |= map(.cmdline |= cmdline | .snapshotCmdline |= cmdline) + | .allInConfig |= lines + | .allInSnapshotConfig |= lines + | .subKernels |= map(kernel); + if .jsonFormatVersion != "1.3.0" or (.snapshotEntries | type) != "array" then + error("Unsupported Limine snapshot manifest format") + else + .snapshotEntries |= map(.kernelEntries |= map(kernel)) + end + ' "$manifest" >"$temporary"; then + rm -f "$temporary" + echo "Cannot update stored USB boot policy in $manifest." >&2 + return 1 + fi + + mv -f "$temporary" "$manifest" +} + +usb_authorization_sync_and_verify() ( + local expected="$1" config="$2" esp="$3" + local machine_id="${4:-$(/run/lock/boot-partition.lock + flock --timeout 30 9 || return 1 + + # Recover/upgrade the manifest first, including backups if necessary. + limine-snapper-sync --no-mutex --no-hooks || return 1 + for manifest in \ + "$esp/$machine_id/limine_history/snapshots.json" \ + "$esp/$machine_id/limine_history/snapshots.json.old" \ + "/var/cache/boot/$machine_id/lss/snapshots.json"; do + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" || return 1 + done + + usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine_id" || return 1 + # Verify the regenerated menu, so synchronization cannot mask stale stored + # command lines with a one-time edit to limine.conf. + limine-snapper-sync --no-mutex --no-hooks || return 1 + limine-enroll-config || return 1 + usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" +) + main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary @@ -377,8 +446,7 @@ main() { fi if ! limine-mkinitcpio || - ! usb_authorization_rewrite_boot_cmdlines enabled "$limine_conf" || - ! usb_authorization_verify_boot_entries enabled "$limine_conf" "$boot_path"; then + ! usb_authorization_sync_and_verify enabled "$limine_conf" "$boot_path"; then echo "Boot-time USB authorization remains configured in $drop_in; rerun this command after fixing the boot-image error." >&2 exit 1 fi @@ -401,14 +469,11 @@ main() { fi had_drop_in=1 rm -f "$drop_in" - elif (( ! had_snapshot_setting )) && usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then - exit 0 fi usb_authorization_disable_snapshot_setting "$limine_defaults" if ! limine-mkinitcpio || - ! usb_authorization_rewrite_boot_cmdlines disabled "$limine_conf" || - ! usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then + ! usb_authorization_sync_and_verify disabled "$limine_conf" "$boot_path"; then if (( had_drop_in )); then printf '%s\n' "$setting" >"$drop_in" chmod 644 "$drop_in" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 1cebe809..784906de 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -269,6 +269,50 @@ usb_authorization_disable_snapshot_setting "$snapshot_config" fail "removing boot authorization restores future snapshot defaults" pass "boot authorization verifies generated entries despite Limine false-success exits" +manifest="$scratch/snapshots.json" +jq -n --arg hash "$boot_hash" ' + def kernel: + {cmdlineDetails: [{limineKey: "CMDLINE", cmdline: "quiet rootflags=subvol=@", snapshotCmdline: "quiet rootflags=subvol=/snapshot"}], + allInConfig: [" cmdline: quiet rootflags=subvol=@"], + allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/vmlinuz-linux#" + $hash, "cmdline: quiet rootflags=subvol=/snapshot"], + subKernels: [], properties: {untouched: "usbcore.authorized_default=0"}}; + {jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel | .allInSnapshotConfig = []]]}], uuid: "preserve-me"} +' >"$manifest" +cp "$manifest" "$scratch/manifest-original.json" +for expected in enabled disabled; do + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" + cp "$manifest" "$scratch/manifest-once.json" + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" + cmp -s "$manifest" "$scratch/manifest-once.json" || fail "manifest updates are idempotent" + # Model both Limine render paths: cached lines and structured details. + for representation in cached structured; do + { + printf '/Omarchy\ncomment: machine-id=%s\n //linux\n' "$machine_id" + if [[ $representation == "cached" ]]; then + jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$manifest" + else + printf 'comment: kernel-id=linux\npath: boot():/vmlinuz-linux#%s\n' "$boot_hash" + jq -r '"cmdline: " + .snapshotEntries[0].kernelEntries[0].subKernels[0].cmdlineDetails[0].snapshotCmdline' "$manifest" + fi + } >"$scratch/regenerated.conf" + usb_authorization_verify_boot_entries "$expected" "$scratch/regenerated.conf" "$scratch" "$machine_id" disabled || + fail "snapshot synchronization preserves $expected policy from $representation entries" + done + jq -e '.snapshotEntries[0].kernelEntries[0].properties.untouched == "usbcore.authorized_default=0"' "$manifest" >/dev/null || + fail "manifest changes preserve unrelated metadata" +done +jq -S . "$manifest" >"$scratch/manifest-after.json" +jq -S . "$scratch/manifest-original.json" >"$scratch/manifest-before.json" +cmp -s "$scratch/manifest-before.json" "$scratch/manifest-after.json" || + fail "disable restores all stored command lines without changing other snapshot data" +printf '{bad json' >"$scratch/bad-manifest.json" +if usb_authorization_rewrite_snapshot_manifest enabled "$scratch/bad-manifest.json" 2>/dev/null; then + fail "invalid manifests must fail closed" +fi +[[ $(<"$scratch/bad-manifest.json") == '{bad json' ]] || fail "failed manifest updates preserve the original" +pass "USB boot policy survives snapshot regeneration from cached and structured manifests" + + : >"$calls" "$ROOT/bin/omarchy-remove-security-usb-authorization" --boot-only --yes >"$scratch/remove-boot-output" grep -Fqx 'sudo ' "$calls" || From 79db1a182696ad696af8f09ee9b8b9efa36beab5 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 14:06:32 -0600 Subject: [PATCH 05/16] Allow USB authorization setup with an empty device inventory --- install/helpers/usb-authorization.sh | 14 ++++++++--- test/shell.d/usb-authorization-test.sh | 32 +++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh index 219a8e5b..a99fbfb9 100644 --- a/install/helpers/usb-authorization.sh +++ b/install/helpers/usb-authorization.sh @@ -59,11 +59,19 @@ usb_authorization_require_secure_settings() { usb_authorization_generate_policy() { local policy="$1" - usbguard generate-policy >"$policy" - if [[ ! -s $policy ]] || ! grep -q '^allow ' "$policy"; then - echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 + if ! usbguard generate-policy >"$policy"; then + echo "USBGuard could not enumerate devices; nothing was enabled." >&2 return 1 fi + if ! grep -q '^allow ' "$policy"; then + if grep -q '^[[:space:]]*[^#[:space:]]' "$policy"; then + echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 + return 1 + fi + # A successful empty inventory is valid on machines without USB controllers. + # Keep the file nonempty so re-enabling preserves this default-deny policy. + echo '# No USB devices were present during enrollment.' >>"$policy" + fi } usb_authorization_add_user() { diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 784906de..fd05258d 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -60,7 +60,10 @@ cat >"$stub_bin/usbguard" <<'STUB' case "$1" in generate-policy) echo 'usbguard ' >>"$CALLS" - echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + [[ ${GENERATE_FAIL:-0} == 0 ]] || exit 1 + if [[ ${GENERATE_EMPTY:-0} == 0 ]]; then + echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + fi ;; list-devices) if [[ ${2:-} == "--blocked" ]]; then @@ -168,6 +171,21 @@ generate_count_after=$(grep -c '^usbguard $' "$calls") (( generate_count_after == generate_count )) || fail "re-enabling preserves the existing trusted-device policy" pass "USB authorization setup preserves an existing policy" +: >"$rules" +GENERATE_EMPTY=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-output" +[[ -s $rules ]] || fail "empty USB inventory must persist an initialized policy" +! grep -q '^allow ' "$rules" || fail "empty inventory must not add permissive rules" +generate_count=$(grep -c '^usbguard $' "$calls") +"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-again-output" +[[ $(grep -c '^usbguard $' "$calls") == "$generate_count" ]] || + fail "re-enabling an empty policy must not enroll newly attached devices" +: >"$rules" +if GENERATE_FAIL=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-failed-output" 2>&1; then + fail "enumeration failure must not be accepted as an empty USB inventory" +fi +[[ ! -s $rules ]] || fail "failed enumeration must not install a policy" +pass "USB setup accepts an empty inventory while rejecting enumeration failure" + : >"$calls" "$ROOT/bin/omarchy-setup-security-usb-authorization" --boot --yes >"$scratch/setup-boot-output" grep -Fqx 'usbguard <--permanent> <4>' "$calls" || @@ -350,6 +368,18 @@ grep -Fqx 'systemctl ' "$calls" || fail "fresh installation enables USBGuard for the first boot" pass "USB authorization is the default for fresh and existing installations" +: >"$rules" +GENERATE_EMPTY=1 bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-empty-output" +[[ -s $rules ]] || fail "fresh install accepts a successful empty USB inventory" +! grep -q '^allow ' "$rules" || fail "empty fresh-install inventory stays default-deny" +: >"$rules" +: >"$calls" +if GENERATE_FAIL=1 bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-enumeration-failed" 2>&1; then + fail "fresh install must reject a generator failure" +fi +! grep -Fq 'systemctl ' "$calls" || fail "failed enumeration must not enable USBGuard" +pass "fresh installation distinguishes no USB hardware from enumeration failure" + malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' export BLOCKED_DEVICE_RULE="$malicious_rule" notification_count=$(grep -c '^notification' "$calls" || true) From 9e20c2a185fdb2e5abb88e507ce443314e2041c8 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 14:06:32 -0600 Subject: [PATCH 06/16] Retry USB approval delivery when notifications become available --- bin/omarchy-usb-authorization-event | 76 +++++++++++++++----------- test/shell.d/usb-authorization-test.sh | 43 +++++++++++---- 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index a26fd6c6..8a93a1c0 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -16,7 +16,7 @@ IPC.Connected) USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ - "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" + "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" || true fi done <<<"$blocked" exit 0 @@ -44,40 +44,52 @@ request="$requests/$token.json" # watch executes handlers concurrently. A reconnect scan and a policy event # can refer to the same device; publish only one pending request. exec 9>"$requests/.lock" -flock 9 -[[ ! -e $request ]] || exit 0 +# Keep retrying without needing another USB event. Handlers belong to the +# watcher service and are stopped with the graphical session. +while true; do + flock 9 + [[ ! -e $request ]] || exit 0 -# Discard events overtaken by another policy decision or device replacement. -blocked=$(usbguard list-devices --blocked) || exit 1 -current=0 -while IFS= read -r line; do - if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then - current=1 - break + # Discard events overtaken by another policy decision or device replacement. + blocked=$(usbguard list-devices --blocked) || exit 1 + current=0 + while IFS= read -r line; do + if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then + current=1 + break + fi + done <<<"$blocked" + (( current )) || exit 0 + + # Login and shell restarts can leave D-Bus alive before notifications are ready. + # Bound each wait so disappeared devices are rechecked between attempts. + if ! omarchy-notification-wait 1; then + flock -u 9 + sleep 1 + continue fi -done <<<"$blocked" -(( current )) || exit 0 -temporary=$(mktemp "$requests/.request.XXXXXXXXXX") -trap 'rm -f "$temporary"' EXIT + temporary=$(mktemp "$requests/.request.XXXXXXXXXX") + trap 'rm -f "$temporary"' EXIT -jq -n \ - --arg id "$USBGUARD_DEVICE_ID" \ - --arg rule "$USBGUARD_DEVICE_RULE" \ - '{id: $id, rule: $rule}' >"$temporary" -chmod 600 "$temporary" -mv -f "$temporary" "$request" + jq -n \ + --arg id "$USBGUARD_DEVICE_ID" \ + --arg rule "$USBGUARD_DEVICE_RULE" \ + '{id: $id, rule: $rule}' >"$temporary" + chmod 600 "$temporary" + mv -f "$temporary" "$request" -if ! omarchy-notification-send \ - --app-name omarchy-action \ - --urgency critical \ - --glyph 󰖀 \ - "USB accessory blocked" \ - "Click to review it. Keep it blocked unless you recognize the device." \ - --exec omarchy-launch-floating-terminal-with-presentation \ - omarchy-usb-authorization-review "$token"; then - # Only a delivered notification may suppress subsequent attempts. Keep the - # lock until failure cleanup so another event cannot skip an unsent request. + if omarchy-notification-send \ + --app-name omarchy-action \ + --urgency critical \ + --glyph 󰖀 \ + "USB accessory blocked" \ + "Click to review it. Keep it blocked unless you recognize the device." \ + --exec omarchy-launch-floating-terminal-with-presentation \ + omarchy-usb-authorization-review "$token"; then + exit 0 + fi rm -f "$request" - exit 1 -fi + flock -u 9 + sleep 1 +done diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index fd05258d..6017e18d 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -67,6 +67,7 @@ generate-policy) ;; list-devices) if [[ ${2:-} == "--blocked" ]]; then + if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then rule="${BLOCKED_DEVICE_RULE:?}" if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then @@ -126,7 +127,17 @@ cat >"$stub_bin/omarchy-notification-send" <<'STUB' printf 'notification' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" -exit "${NOTIFICATION_FAIL:-0}" +if [[ -n ${NOTIFICATION_FAILURE_MARKER:-} && ! -e $NOTIFICATION_FAILURE_MARKER ]]; then + touch "$NOTIFICATION_FAILURE_MARKER" + exit 1 +fi +STUB +cat >"$stub_bin/omarchy-notification-wait" <<'STUB' +#!/bin/bash +if [[ -n ${NOTIFICATION_READY_FILE:-} ]]; then + touch "$NOTIFICATION_READY_FILE.waited" + [[ -e $NOTIFICATION_READY_FILE ]] +fi STUB chmod +x "$stub_bin"/* @@ -515,16 +526,26 @@ fi pass "USB review binds approval to the device snapshot" notification_count=$(grep -c '^notification' "$calls") -if NOTIFICATION_FAIL=1 USBGUARD_IPC_SIGNAL=IPC.Connected \ - "$ROOT/bin/omarchy-usb-authorization-event"; then - fail "a failed notification must report failure" -fi -[[ ! -e $request ]] || fail "a failed notification must not suppress retries" -USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" -[[ -f $request ]] || fail "reconnection retries a failed notification" -[[ $(grep -c '^notification' "$calls") == $((notification_count + 2)) ]] || - fail "reconnection must attempt delivery after an earlier send failure" -pass "failed USB notifications can be retried on reconnection" +printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked" +NOTIFICATION_READY_FILE="$scratch/notification-ready" \ + NOTIFICATION_FAILURE_MARKER="$scratch/first-send-failed" \ + BLOCKED_DEVICES_FILE="$scratch/two-blocked" \ + USBGUARD_IPC_SIGNAL=IPC.Connected \ + timeout 10 "$ROOT/bin/omarchy-usb-authorization-event" & +scan_pid=$! +for ((attempt=0; attempt<50; attempt++)); do + [[ -e $scratch/notification-ready.waited ]] && break + sleep 0.02 +done +[[ -e $scratch/notification-ready.waited ]] || fail "startup waits for notification readiness" +[[ $(grep -c '^notification' "$calls") == "$notification_count" ]] || + fail "startup does not send before the notification server is ready" +touch "$scratch/notification-ready" +wait "$scan_pid" || fail "startup scan must retry and finish without another USB event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 3)) ]] || + fail "two blocked devices must be notified despite a failed first send" +[[ -f $request ]] || fail "successful retry leaves a reviewable request" +pass "USB startup waits for notifications and automatically retries delivery for all devices" sysfs="$scratch/sys/bus/usb/devices" mkdir -p "$sysfs/usb1" "$sysfs/1-2" From 54bacefbf4be83b16f1d42b29b89386991dc6aeb Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 14:11:46 -0600 Subject: [PATCH 07/16] Explain USB availability in older recovery snapshots --- bin/omarchy-setup-security-usb-authorization | 10 ++++++++-- manual/48-security.md | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization index 0c21c558..c059b0fb 100755 --- a/bin/omarchy-setup-security-usb-authorization +++ b/bin/omarchy-setup-security-usb-authorization @@ -22,6 +22,9 @@ while (( $# > 0 )); do echo "--boot also blocks USB devices from the start of kernel boot. Connected" echo "devices are added to USBGuard's policy, but remain unavailable before" echo "USBGuard starts, including at an encrypted-disk password prompt." + echo "Older snapshots without USBGuard keep all USB disabled after startup," + echo "including keyboards, network adapters, and storage. Use --boot only if" + echo "disk unlock and recovery work without any USB devices." exit 0 ;; *) @@ -53,8 +56,11 @@ if (( ! assume_yes )); then echo echo "Boot-time protection blocks all USB devices until USBGuard starts." echo "A USB keyboard will not work at an encrypted-disk password prompt, even" - echo "when it is connected now. Continue only if unlock and recovery do not" - echo "depend on USB input." + echo "when it is connected now." + echo + echo "Older snapshots without USBGuard keep ALL USB disabled after startup," + echo "including keyboards, network adapters, and storage." + echo "Continue only if disk unlock and recovery work without any USB devices." fi echo if (( from_boot )); then diff --git a/manual/48-security.md b/manual/48-security.md index 76f1db24..ecce35a2 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -19,7 +19,9 @@ Only approve hardware you recognize. A malicious USB device can forge the manufa The policy protects devices connected after USBGuard starts during boot. It does not replace operating-system and driver security updates, and USB devices needed before the service starts, such as a keyboard used to unlock an encrypted disk, remain governed by the kernel's boot-time policy. -For machines whose disk unlock and recovery never depend on USB input, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. +For machines whose disk unlock and recovery never depend on any USB devices, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt. + +Older snapshots created before USBGuard was installed and enabled keep all USB disabled even after startup, including keyboards, network adapters, and storage. Trusting the devices connected now does not make them available in those snapshots. Enable boot-time protection only if you can unlock and recover the machine without any USB devices. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. ## Changing your passwords From 67d173c9f942649f3d030b9b0f7505d959327c50 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 16 Sep 2026 16:05:59 -0600 Subject: [PATCH 08/16] Defer USB enrollment until owner provisioning completes --- bin/omarchy-provision-owner | 12 ++++---- install/config/usb-authorization.sh | 22 ++++++-------- install/helpers/usb-authorization.sh | 37 +++++++++++++++++++---- manual/48-security.md | 2 ++ test/shell.d/usb-authorization-test.sh | 41 ++++++++++++++++++++++---- 5 files changed, 85 insertions(+), 29 deletions(-) diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index f4bff333..a4f2d996 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -747,12 +747,6 @@ create_user() { echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel - if omarchy-pkg-present usbguard; then - source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" - usb_authorization_add_user "$username" - systemctl try-restart usbguard.service - fi - source "$OMARCHY_PATH/install/helpers/browser-policy.sh" for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue @@ -1040,6 +1034,12 @@ run_provisioning() { limine-update fi + if omarchy-pkg-present usbguard; then + log_step "enrolling the owner's USB devices" + source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" + usb_authorization_provision_owner "$username" + fi + log_step "cleaning up provisioning state" cleanup_oem_state log_step "first-boot setup complete" diff --git a/install/config/usb-authorization.sh b/install/config/usb-authorization.sh index 958b82ae..6b0747e8 100644 --- a/install/config/usb-authorization.sh +++ b/install/config/usb-authorization.sh @@ -1,5 +1,12 @@ echo "Configure USB device authorization" +# A deferred install has only the builder's peripherals attached. The owner +# needs working input before there is an account or a session for approvals. +if [[ -z ${OMARCHY_INSTALL_USER:-} ]]; then + systemctl disable usbguard.service + return 0 +fi + rules_file="${OMARCHY_USB_AUTHORIZATION_RULES_FILE:-/etc/usbguard/rules.conf}" daemon_config="${OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG:-/etc/usbguard/usbguard-daemon.conf}" @@ -7,21 +14,10 @@ source "$OMARCHY_INSTALL/helpers/usb-authorization.sh" usb_authorization_require_secure_settings "$daemon_config" if [[ ! -s $rules_file ]]; then - policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") - chmod 600 "$policy" - - if usb_authorization_generate_policy "$policy"; then - install -Dm600 -o root -g root "$policy" "$rules_file" - rm -f "$policy" - else - rm -f "$policy" - return 1 - fi + usb_authorization_install_policy "$rules_file" fi -if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then - usb_authorization_add_user "$OMARCHY_INSTALL_USER" -fi +usb_authorization_add_user "$OMARCHY_INSTALL_USER" # Installs are followed by a reboot, so enable the daemon without starting it # in the live ISO's chroot. diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh index a99fbfb9..70e36628 100644 --- a/install/helpers/usb-authorization.sh +++ b/install/helpers/usb-authorization.sh @@ -49,11 +49,11 @@ usb_authorization_require_secure_setting() { usb_authorization_require_secure_settings() { local config="$1" - usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block - usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy - usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy - usb_authorization_require_secure_setting "$config" AuthorizedDefault none - usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false + usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block || return 1 + usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy || return 1 + usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy || return 1 + usb_authorization_require_secure_setting "$config" AuthorizedDefault none || return 1 + usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false || return 1 } usb_authorization_generate_policy() { @@ -85,6 +85,33 @@ usb_authorization_add_user() { --exceptions=listen } +usb_authorization_install_policy() { + local rules="$1" policy status=0 + + policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") || return 1 + if usb_authorization_generate_policy "$policy"; then + install -Dm600 -o root -g root "$policy" "$rules" || status=$? + else + status=1 + fi + rm -f "$policy" + return "$status" +} + +usb_authorization_provision_owner() { + local user="$1" + local rules="${2:-/etc/usbguard/rules.conf}" + local config="${3:-/etc/usbguard/usbguard-daemon.conf}" + + usb_authorization_require_secure_settings "$config" || return 1 + # Enroll the owner's hardware, replacing any policy inherited from the + # builder. Do this only after the interactive provisioning steps finish. + usb_authorization_install_policy "$rules" || return 1 + usb_authorization_add_user "$user" || return 1 + systemctl enable usbguard.service || return 1 + systemctl restart usbguard.service +} + usb_authorization_trust_present_devices() { local line id local count=0 diff --git a/manual/48-security.md b/manual/48-security.md index ecce35a2..01e805b6 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -13,6 +13,8 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked. +On a preinstalled machine that asks you to create your account at first boot, USB protection starts at the end of that setup. It trusts the devices connected then, so your keyboard works throughout account setup even if it differs from the installer's keyboard. + You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy. Only approve hardware you recognize. A malicious USB device can forge the manufacturer, model, serial number, and device class shown during review. Permanent approval is a convenience for accessories you control, not proof of the device's identity. diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 6017e18d..c04d3acc 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -62,7 +62,7 @@ generate-policy) echo 'usbguard ' >>"$CALLS" [[ ${GENERATE_FAIL:-0} == 0 ]] || exit 1 if [[ ${GENERATE_EMPTY:-0} == 0 ]]; then - echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + printf '%s\n' "${GENERATED_RULE:-allow id 1d6b:0002 name \"Linux Foundation root hub\" hash \"root\"}" fi ;; list-devices) @@ -357,10 +357,8 @@ grep -Fq 'config/usb-authorization.sh' "$ROOT/install/config/all.sh" || fail "fresh installs configure the USB authorization policy" grep -Fq 'omarchy-usb-authorization.service' "$ROOT/install/user/first-run/enable-user-units.sh" || fail "fresh installs enable the USB approval watcher" -grep -Fq 'usb_authorization_add_user "$username"' "$ROOT/bin/omarchy-provision-owner" || - fail "deferred provisioning grants the new owner approval access" -grep -Fq 'systemctl try-restart usbguard.service' "$ROOT/bin/omarchy-provision-owner" || - fail "deferred provisioning reloads the new owner ACL" +grep -Fq 'usb_authorization_provision_owner "$username"' "$ROOT/bin/omarchy-provision-owner" || + fail "deferred provisioning enrolls the owner before finishing" grep -Fqx 'omarchy-setup-security-usb-authorization --yes' "$ROOT/migrations/1789433473.sh" || fail "existing installs enable USB authorization during migration" @@ -391,6 +389,39 @@ fi ! grep -Fq 'systemctl ' "$calls" || fail "failed enumeration must not enable USBGuard" pass "fresh installation distinguishes no USB hardware from enumeration failure" +: >"$rules" +: >"$calls" +OMARCHY_INSTALL_USER="" bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-deferred-output" +[[ ! -s $rules ]] || fail "deferred installation must not enroll the builder's hardware" +! grep -q '^usbguard' "$calls" || fail "deferred installation must not enumerate or grant owner ACLs" +grep -Fqx 'systemctl ' "$calls" || fail "deferred installation leaves enforcement disabled" +! grep -Fq 'systemctl ' "$calls" || fail "deferred installation must not enable enforcement" + +source "$ROOT/install/helpers/usb-authorization.sh" +echo 'allow id 1234:0001 name "Builder keyboard" hash "builder"' >"$rules" +owner_rule='allow id 1234:0002 name "Owner keyboard" hash "owner"' +: >"$calls" +GENERATED_RULE="$owner_rule" usb_authorization_provision_owner owner "$rules" "$daemon_config" +[[ $(<"$rules") == "$owner_rule" ]] || fail "owner enrollment must replace inherited builder trust" +grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || + fail "owner provisioning grants approval access" +[[ $(tail -2 "$calls") == $'systemctl \nsystemctl ' ]] || + fail "enforcement starts only after owner policy and ACL setup" + +: >"$calls" +if GENERATE_FAIL=1 usb_authorization_provision_owner owner "$rules" "$daemon_config" >"$scratch/owner-failed" 2>&1; then + fail "owner enrollment must reject enumeration failure" +fi +[[ $(<"$rules") == "$owner_rule" ]] || fail "failed enrollment preserves the old policy" +! grep -q '^systemctl' "$calls" || fail "failed owner enrollment must not enable enforcement" +: >"$calls" +sed 's/ImplicitPolicyTarget=block/ImplicitPolicyTarget=allow/' "$daemon_config" >"$scratch/insecure-daemon.conf" +if usb_authorization_provision_owner owner "$rules" "$scratch/insecure-daemon.conf" >"$scratch/owner-insecure" 2>&1; then + fail "owner enrollment rejects insecure settings even when called in a conditional" +fi +[[ ! -s $calls ]] || fail "invalid settings must prevent all enrollment changes" +pass "deferred installs enroll owner hardware before enabling enforcement" + malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' export BLOCKED_DEVICE_RULE="$malicious_rule" notification_count=$(grep -c '^notification' "$calls" || true) From 58a9c95bf6f3f0eab49b2f75ce2a39c9f867cf74 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 16 Sep 2026 16:06:13 -0600 Subject: [PATCH 09/16] Verify USB approval before reporting success --- bin/omarchy-usb-authorization-review | 47 +++++++++++++++++---- test/shell.d/usb-authorization-test.sh | 56 +++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review index aa3c16c4..432a3199 100755 --- a/bin/omarchy-usb-authorization-review +++ b/bin/omarchy-usb-authorization-review @@ -60,16 +60,49 @@ if [[ $choice != "Keep blocked" ]]; then fi fi +approval_failed() { + echo "Could not confirm USB approval. The request has been kept; try again." >&2 + exit 1 +} + case "$choice" in -"Allow once") +"Allow once" | "Always allow this device") # Target the complete identity rule instead of USBGuard's numeric ID. IDs can # be reassigned when the daemon restarts while the review dialog is open. - usbguard allow-device "$expected_rule" - echo "USB accessory allowed until it is disconnected." - ;; -"Always allow this device") - usbguard allow-device --permanent "$expected_rule" - echo "USB accessory added to the trusted policy." + options=() + if [[ $choice == "Always allow this device" ]]; then + options+=(--permanent) + fi + usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed + + # Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can + # return success without authorizing anything. Verify the exact device. + allowed_rule="allow ${expected_rule#block }" + allowed=$(usbguard list-devices --allowed) || approval_failed + grep -Fqx -- "$id: $allowed_rule" <<<"$allowed" || approval_failed + + if [[ $choice == "Always allow this device" ]]; then + # Let USBGuard match policy rules: saved rules can omit the device's port. + # --show-devices indents the devices matching each policy rule. + policy=$(usbguard list-rules --show-devices) || approval_failed + policy_allows=0 + matched=0 + while IFS= read -r line; do + if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then + policy_allows=0 + if [[ $line =~ ^[0-9]+:[[:space:]]allow[[:space:]] ]]; then + policy_allows=1 + fi + elif (( policy_allows )) && [[ $line == $'\t'"$id: $allowed_rule" ]]; then + matched=1 + break + fi + done <<<"$policy" + (( matched )) || approval_failed + echo "USB accessory added to the trusted policy." + else + echo "USB accessory allowed until it is disconnected." + fi ;; "Keep blocked") echo "USB accessory remains blocked." diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index c04d3acc..584f2f77 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -66,7 +66,13 @@ generate-policy) fi ;; list-devices) - if [[ ${2:-} == "--blocked" ]]; then + if [[ ${2:-} == "--allowed" ]]; then + [[ ${APPROVAL_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_ALLOWED_RULE ]]; then + printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + fi + elif [[ ${2:-} == "--blocked" ]]; then + [[ ! -f $TEST_ALLOWED_RULE ]] || exit 0 if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then rule="${BLOCKED_DEVICE_RULE:?}" @@ -80,10 +86,27 @@ list-devices) echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' fi ;; +list-rules) + [[ ${APPROVAL_POLICY_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_SAVED_RULE ]]; then + printf '40: %s\n' "$(<"$TEST_SAVED_RULE")" + printf '\t17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + fi + ;; *) printf 'usbguard' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" + if [[ $1 == "allow-device" && ${!#} == block* ]]; then + [[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1 + if [[ ${APPROVAL_SILENT_FAIL:-0} == 0 ]]; then + rule="${!#}" + printf '%s\n' "${APPROVAL_REPLACEMENT_RULE:-allow ${rule#block }}" >"$TEST_ALLOWED_RULE" + if [[ $2 == "--permanent" && ${APPROVAL_NO_POLICY:-0} == 0 ]]; then + cp "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + fi + fi + fi ;; esac STUB @@ -148,6 +171,8 @@ export CALLS="$calls" export PATH="$stub_bin:$ROOT/bin:$PATH" export TEST_DAEMON_CONFIG="$daemon_config" export TEST_RULES="$rules" +export TEST_ALLOWED_RULE="$scratch/allowed-rule" +export TEST_SAVED_RULE="$scratch/saved-rule" if "$ROOT/bin/omarchy-usb-authorization-restore-default" >/dev/null 2>&1; then fail "the fixed-path USB authorization restore helper requires root" @@ -486,6 +511,8 @@ grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review c [[ ! -e $request ]] || fail "a completed review consumes its request" pass "USB review allows a still-matching device once" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + # A daemon restart discards the temporary approval. The IPC reconnect must # recover the blocked device even when its startup policy events were missed. notification_count=$(grep -c '^notification' "$calls") @@ -515,6 +542,7 @@ token=$(basename "${request%.json}") GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-always-output" grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" pass "USB review supports explicit persistent trust" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ @@ -556,6 +584,32 @@ fi [[ ! -e $request ]] || fail "a stale review request is discarded" pass "USB review binds approval to the device snapshot" +for choice in 'Allow once' 'Always allow this device'; do + for failure in APPROVAL_SILENT_FAIL APPROVAL_EXIT_FAIL APPROVAL_QUERY_FAIL APPROVAL_REPLACEMENT_RULE; do + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" + value=1 + [[ $failure != "APPROVAL_REPLACEMENT_RULE" ]] || value='allow id 9999:9999 hash "replacement"' + if env "$failure=$value" GUM_CHOICE="$choice" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-failed" 2>&1; then + fail "$choice must reject $failure" + fi + [[ -f $request ]] || fail "unverified approval must retain the request" + ! grep -Eq 'USB accessory (allowed until|added to)' "$scratch/review-failed" || fail "failed approval must not report success" + done +done +for failure in APPROVAL_NO_POLICY APPROVAL_POLICY_QUERY_FAIL; do + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + if env "$failure=1" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-no-policy" 2>&1; then + fail "permanent approval must reject $failure" + fi + [[ -f $request ]] || fail "unverified permanent approval must retain the request" +done +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-retry" +[[ ! -e $request ]] || fail "a verified retry consumes the retained request" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +pass "USB approvals verify device and permanent policy before consuming requests" + notification_count=$(grep -c '^notification' "$calls") printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked" NOTIFICATION_READY_FILE="$scratch/notification-ready" \ From afad8530622c871b64f7b775a96cbe50e26c1218 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Thu, 17 Sep 2026 11:02:10 -0600 Subject: [PATCH 10/16] Disable restored USB policy until factory owner enrollment --- bin/omarchy-system-factory-reset | 8 ++ test/shell.d/usb-factory-reset-test.sh | 104 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100755 test/shell.d/usb-factory-reset-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca401..217ad00a 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -330,6 +330,14 @@ stage_full_reset() { [[ -f $unit_src/omarchy-provision-owner.service && -x $next/usr/bin/omarchy-provision-owner ]] || fail "the factory snapshot predates provisioning support; cannot reset from it" + # A normal install's factory snapshot includes the original owner's USB + # policy and enabled daemon. Keep input usable during the new owner's setup; + # provisioning enrolls their devices before enabling USBGuard again. + if [[ -f $next/usr/lib/systemd/system/usbguard.service ]]; then + systemctl --root="$next" disable usbguard.service >>"$LOG_FILE" 2>&1 || + fail "could not disable USBGuard in the factory system (see $LOG_FILE)" + fi + log "Scrubbing machine identity from the factory system" systemd-id128 new >"$next/etc/machine-id" rm -f "$next"/etc/ssh/ssh_host_* diff --git a/test/shell.d/usb-factory-reset-test.sh b/test/shell.d/usb-factory-reset-test.sh new file mode 100755 index 00000000..e3d71404 --- /dev/null +++ b/test/shell.d/usb-factory-reset-test.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +# Exercise reset staging in a temporary root with the real offline systemctl. +# Never invoke the reset command's main function or any actual disk operations. +python - "$ROOT" <<'PY' +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile + +repo = Path(sys.argv[1]) +source = (repo / 'bin/omarchy-system-factory-reset').read_text() +functions = [] +for name in ['install_provisioning_units', 'sanitize_factory_baseline', 'stage_full_reset']: + match = re.search(r'^' + name + r'\(\) \{\n.*?^\}', source, re.M | re.S) + assert match, name + functions.append(match.group()) + +with tempfile.TemporaryDirectory(prefix='omarchy-usb-reset-') as tmp: + scratch = Path(tmp) + for scenario in ['enabled', 'disabled', 'absent', 'disable-failure']: + root = scratch / scenario + top = root / 'top' + factory = top / '@factory' + for rel in ['etc/systemd/system/basic.target.wants', 'usr/lib/systemd/system', + 'etc/usbguard', 'usr/bin', 'usr/share/omarchy/install/provisioning']: + (factory / rel).mkdir(parents=True, exist_ok=True) + (top / '@').mkdir() + (top / '@' / 'original-root').touch() + (factory / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/bash\n') + (factory / 'etc/machine-id').write_text('old-machine-id\n') + policy = 'allow id 1234:0001 name "Original owner keyboard" hash "old-owner"\n' + (factory / 'etc/usbguard/rules.conf').write_text(policy) + unit = factory / 'usr/lib/systemd/system/usbguard.service' + enabled_path = Path('etc/systemd/system/basic.target.wants/usbguard.service') + if scenario != 'absent': + unit.write_text('[Unit]\nDescription=Fixture USBGuard\n[Service]\nExecStart=/bin/true\n[Install]\nWantedBy=basic.target\n') + if scenario in ['enabled', 'disable-failure']: + (factory / enabled_path).symlink_to('/usr/lib/systemd/system/usbguard.service') + owner = factory / 'usr/bin/omarchy-provision-owner' + owner.touch() + owner.chmod(0o755) + for name in ['omarchy-provision-owner.service', 'omarchy-system-factory-reset-finish.service']: + shutil.copyfile(repo / 'install/provisioning' / name, + factory / 'usr/share/omarchy/install/provisioning' / name) + harness = '''#!/bin/bash +set -euo pipefail +TOP_MNT="$1/top" +NEXT_NAME=@next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE="$1/log" +log() { :; } +fail() { echo "$*" >&2; exit 1; } +btrfs() { + if [[ $1 == subvolume && $2 == snapshot ]]; then + cp -a "$3" "$4" + elif [[ $1 == property ]]; then + : + else + return 90 + fi +} +passwd() { :; } +systemd-id128() { echo new-machine-id; } +encrypted_install() { return 1; } +rebuild_next_boot() { :; } +sync() { :; } +systemctl() { + # All service changes must address the staged root, never the live machine. + [[ $1 == "--root=$TOP_MNT/$NEXT_NAME" && $2 == disable && $3 == usbguard.service ]] || return 91 + [[ $2 != "${FAIL_OPERATION:-}" ]] || return 92 + command systemctl "$@" +} +''' + '\n\n'.join(functions) + '\nstage_full_reset\n' + runner = root / 'run' + runner.write_text(harness) + env = os.environ.copy() + env['FAIL_OPERATION'] = 'disable' if scenario == 'disable-failure' else '' + result = subprocess.run(['bash', str(runner), str(root)], env=env, capture_output=True, text=True) + if scenario == 'disable-failure': + assert result.returncode != 0, 'failed disable must abort reset staging' + assert (top / '@' / 'original-root').exists(), 'failure must not activate the reset root' + assert 'could not disable USBGuard' in result.stderr + else: + assert result.returncode == 0, result.stderr + staged = top / '@' + assert not (staged / enabled_path).is_symlink(), 'reset must not start USBGuard before enrollment' + assert (staged / 'var/lib/omarchy/provisioning/pending').exists() + assert (staged / 'etc/usbguard/rules.conf').read_text() == policy + if scenario == 'enabled': + assert (factory / enabled_path).is_symlink(), 'only the staged clone is disabled' + # The owner-enrollment helper enables the daemon after policy and + # ACL setup. Confirm an ordinary enable can reverse this disable. + subprocess.run(['systemctl', '--root=' + str(staged), 'enable', 'usbguard.service'], + check=True, capture_output=True, text=True) + assert (staged / enabled_path).is_symlink() + print('ok - factory USB enrollment staging: ' + scenario) +PY From 90d604f41d6ae52c6e53ca26ab3de311c7580420 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Thu, 17 Sep 2026 12:04:02 -0600 Subject: [PATCH 11/16] Expire USB approval deduplication across daemon and watcher restarts --- bin/omarchy-usb-authorization-event | 72 ++++++++++++++++++++------ bin/omarchy-usb-authorization-watch | 5 ++ test/shell.d/usb-authorization-test.sh | 64 ++++++++++++++++++++++- 3 files changed, 125 insertions(+), 16 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index 8a93a1c0..0d05001f 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -6,7 +6,43 @@ set -euo pipefail case "${USBGUARD_IPC_SIGNAL:-}" in -IPC.Connected) +IPC.Connected) ;; +Device.PolicyApplied) + # PresenceChanged precedes policy evaluation, even for trusted devices. + [[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0 + ;; +Device.PresenceChanged) + [[ ${USBGUARD_DEVICE_EVENT:-} == "Remove" ]] || exit 0 + ;; +*) exit 0 ;; +esac + +requests="$HOME/.local/state/omarchy/usb-authorization/requests" +mkdir -p "$requests" +chmod 700 "${requests%/*}" "$requests" +exec 9>"$requests/.lock" + +request_generation() { + local daemon_id + + # USBGuard reuses device IDs after restarting. Include both the daemon + # invocation and watcher lifetime so old prompts cannot suppress new ones. + daemon_id=$(systemctl show usbguard.service --property=InvocationID --value) || return 1 + [[ $daemon_id =~ ^[0-9a-f]{32}$ ]] || return 1 + printf '%s:%s' "${OMARCHY_USB_AUTHORIZATION_WATCH_ID:-standalone}" "$daemon_id" +} + +if [[ $USBGUARD_IPC_SIGNAL == "IPC.Connected" ]]; then + flock 9 + generation=$(request_generation) || exit 1 + for pending in "$requests"/request-*.json; do + [[ -f $pending ]] || continue + if ! jq -e --arg generation "$generation" '.generation == $generation' "$pending" >/dev/null 2>&1; then + rm -f "$pending" + fi + done + flock -u 9 + # USBGuard scans present devices before accepting IPC connections. Repeat # this on every reconnect: temporary approvals disappear on daemon restart. blocked=$(usbguard list-devices --blocked) || exit 1 @@ -20,21 +56,14 @@ IPC.Connected) fi done <<<"$blocked" exit 0 - ;; -Device.PolicyApplied) - # PresenceChanged precedes policy evaluation, even for trusted devices. - # PolicyApplied also covers block-to-block and allow-to-block transitions. - [[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0 - ;; -*) exit 0 ;; -esac +fi [[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0 [[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0 -requests="$HOME/.local/state/omarchy/usb-authorization/requests" -mkdir -p "$requests" -chmod 700 "${requests%/*}" "$requests" +# A removed device's final target may be allow even though a blocked request +# remains. Its identity attributes are unchanged; normalize the target. +USBGUARD_DEVICE_RULE="block ${USBGUARD_DEVICE_RULE#* }" # The token is safe to carry through the presentation launcher's command # string. The device-controlled rule itself stays in a mode-0600 JSON file and @@ -43,12 +72,24 @@ token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | request="$requests/$token.json" # watch executes handlers concurrently. A reconnect scan and a policy event # can refer to the same device; publish only one pending request. -exec 9>"$requests/.lock" +if [[ $USBGUARD_IPC_SIGNAL == "Device.PresenceChanged" ]]; then + flock 9 + blocked=$(usbguard list-devices --blocked) || exit 1 + # A delayed removal event must not erase a request for a reappeared device. + if ! grep -Fqx -- "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" <<<"$blocked"; then + rm -f "$request" + fi + exit 0 +fi + # Keep retrying without needing another USB event. Handlers belong to the # watcher service and are stopped with the graphical session. while true; do flock 9 - [[ ! -e $request ]] || exit 0 + generation=$(request_generation) || exit 1 + if [[ -f $request ]] && jq -e --arg generation "$generation" '.generation == $generation' "$request" >/dev/null 2>&1; then + exit 0 + fi # Discard events overtaken by another policy decision or device replacement. blocked=$(usbguard list-devices --blocked) || exit 1 @@ -75,7 +116,8 @@ while true; do jq -n \ --arg id "$USBGUARD_DEVICE_ID" \ --arg rule "$USBGUARD_DEVICE_RULE" \ - '{id: $id, rule: $rule}' >"$temporary" + --arg generation "$generation" \ + '{id: $id, rule: $rule, generation: $generation}' >"$temporary" chmod 600 "$temporary" mv -f "$temporary" "$request" diff --git a/bin/omarchy-usb-authorization-watch b/bin/omarchy-usb-authorization-watch index 302c7968..46ce10d3 100755 --- a/bin/omarchy-usb-authorization-watch +++ b/bin/omarchy-usb-authorization-watch @@ -5,6 +5,11 @@ set -euo pipefail +# Starting a new graphical-session watcher must offer fresh prompts even if +# their old notification actions were dismissed and aged out of history. +OMARCHY_USB_AUTHORIZATION_WATCH_ID=$(cat /proc/sys/kernel/random/uuid) +export OMARCHY_USB_AUTHORIZATION_WATCH_ID + event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event" # The handler scans blocked devices on IPC.Connected, including reconnects diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 584f2f77..c9a5a825 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -51,7 +51,9 @@ STUB cat >"$stub_bin/systemctl" <<'STUB' #!/bin/bash printf 'systemctl <%s>\n' "$*" >>"$CALLS" -if [[ $* == "is-enabled --quiet usbguard.service" ]]; then +if [[ $* == "show usbguard.service --property=InvocationID --value" ]]; then + printf '%s\n' "${TEST_DAEMON_INVOCATION:-11111111111111111111111111111111}" +elif [[ $* == "is-enabled --quiet usbguard.service" ]]; then exit 1 fi STUB @@ -97,6 +99,9 @@ list-rules) printf 'usbguard' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" + if [[ $1 == "watch" && -n ${TEST_WATCH_ID_FILE:-} ]]; then + printf '%s\n' "$OMARCHY_USB_AUTHORIZATION_WATCH_ID" >"$TEST_WATCH_ID_FILE" + fi if [[ $1 == "allow-device" && ${!#} == block* ]]; then [[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1 if [[ ${APPROVAL_SILENT_FAIL:-0} == 0 ]]; then @@ -506,6 +511,63 @@ wait "$scan_pid" fail "overlapping scan and policy events must not duplicate pending prompts" pass "USB reconnect scans and policy events deduplicate pending requests" +# Dismissing/canceling a notification leaves its request, even after the shell +# discards the archived action. Reusing the ID after daemon restart must prompt. +notification_count=$(grep -c '^notification' "$calls") +export TEST_DAEMON_INVOCATION=22222222222222222222222222222222 +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" & +scan_pid=$! +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +wait "$scan_pid" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "reused IDs must get exactly one new notification after daemon restart" +[[ -f $request ]] || fail "the recovered notification must have a reviewable request" + +# Starting another watcher/session also expires old notification deduplication. +notification_count=$(grep -c '^notification' "$calls") +export OMARCHY_USB_AUTHORIZATION_WATCH_ID=new-session +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "a new watcher must recover an orphaned approval action" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "same-generation scans must still deduplicate" + +# A removal that arrives late must not erase a new connection's request. +USBGUARD_IPC_SIGNAL=Device.PresenceChanged USBGUARD_DEVICE_EVENT=Remove \ + USBGUARD_DEVICE_ID=17 USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "delayed removal must preserve a currently blocked device's request" +BLOCKED_DEVICE_PRESENT=0 \ + USBGUARD_IPC_SIGNAL=Device.PresenceChanged USBGUARD_DEVICE_EVENT=Remove \ + USBGUARD_DEVICE_ID=17 USBGUARD_DEVICE_RULE="${malicious_rule/#block/allow}" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ ! -e $request ]] || fail "device removal must clean up its pending request" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 2)) ]] || + fail "a reappeared device must receive a fresh notification" + +# Upgrade from requests written before generations existed, and remove stale +# requests for devices no longer in the inventory. +jq 'del(.generation)' "$request" >"$scratch/legacy-request" +mv "$scratch/legacy-request" "$request" +legacy_orphan="${request%/*}/request-$(printf '%064d' 0).json" +printf '{"id":"99","rule":"block id 0000:0000"}\n' >"$legacy_orphan" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 3)) ]] || + fail "legacy request markers must not suppress notification recovery" +[[ ! -e $legacy_orphan ]] || fail "reconnect must clean up old-generation orphan requests" + +TEST_WATCH_ID_FILE="$scratch/watch-one" "$ROOT/bin/omarchy-usb-authorization-watch" +TEST_WATCH_ID_FILE="$scratch/watch-two" "$ROOT/bin/omarchy-usb-authorization-watch" +[[ -s $scratch/watch-one && $(<"$scratch/watch-one") != "$(<"$scratch/watch-two")" ]] || + fail "each watcher must export a fresh lifetime identifier" +pass "USB prompts recover across daemon and session generations and device removal" + GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-once-output" grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review can allow the exact device once" [[ ! -e $request ]] || fail "a completed review consumes its request" From db6ab5a8bf928f78bcabe04a19030d7df579159e Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Thu, 17 Sep 2026 15:55:32 -0600 Subject: [PATCH 12/16] Resume USB approval verification after transient failures --- bin/omarchy-usb-authorization-review | 130 +++++++++++++++---------- test/shell.d/usb-authorization-test.sh | 105 +++++++++++++++++++- 2 files changed, 184 insertions(+), 51 deletions(-) diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review index 432a3199..630e7612 100755 --- a/bin/omarchy-usb-authorization-review +++ b/bin/omarchy-usb-authorization-review @@ -12,72 +12,103 @@ if [[ ! $token =~ ^request-[0-9a-f]{64}$ ]]; then fi request="$HOME/.local/state/omarchy/usb-authorization/requests/$token.json" +# Serialize request updates with watcher events, but release the lock while the +# user chooses. A changed request must never inherit an old dialog's approval. +if [[ ! -d ${request%/*} ]]; then + echo "This USB authorization request is no longer available." >&2 + exit 1 +fi +exec 9>"${request%/*}/.lock" +flock 9 if [[ ! -f $request || -L $request ]]; then echo "This USB authorization request is no longer available." >&2 exit 1 fi -id=$(jq -er '.id | select(test("^[0-9]+$"))' "$request") -expected_rule=$(jq -er '.rule | select(type == "string" and length > 0)' "$request") -read_blocked_rule() { - local line - - while IFS= read -r line; do - if [[ $line == "$id: "* ]]; then - printf '%s\n' "${line#"$id: "}" - return 0 - fi - done < <(usbguard list-devices --blocked) - - return 1 -} - -current_rule=$(read_blocked_rule || true) - -if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then - rm -f "$request" - echo "That USB device was removed or changed. Connect it again to review it." >&2 - exit 1 -fi - -gum style --foreground 212 --bold "Blocked USB accessory" -echo -echo "The following details come from the device and can be forged:" -printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' -echo - -choice=$(gum choose \ - "Allow once" \ - "Always allow this device" \ - "Keep blocked") || exit 130 - -if [[ $choice != "Keep blocked" ]]; then - current_rule=$(read_blocked_rule || true) - if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then - rm -f "$request" - echo "That USB device was removed or changed. Connect it again to review it." >&2 - exit 1 - fi -fi +snapshot=$(cat "$request") +id=$(jq -er '.id | select(test("^[0-9]+$"))' <<<"$snapshot") +expected_rule=$(jq -er '.rule | select(type == "string" and startswith("block "))' <<<"$snapshot") +choice=$(jq -er '.approval // "" | select(. == "" or . == "Allow once" or . == "Always allow this device")' <<<"$snapshot") +allowed_rule="allow ${expected_rule#block }" approval_failed() { echo "Could not confirm USB approval. The request has been kept; try again." >&2 exit 1 } +read_current_rule() { + local devices line + + current_rule="" + devices=$(usbguard list-devices) || approval_failed + while IFS= read -r line; do + if [[ $line == "$id: "* ]]; then + current_rule="${line#"$id: "}" + return + fi + done <<<"$devices" +} + +validate_device() { + read_current_rule + if [[ $current_rule == "$expected_rule" ]] || + [[ -n $choice && $current_rule == "$allowed_rule" ]]; then + return + fi + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 +} + +validate_device + +if [[ -z $choice ]]; then + flock -u 9 + gum style --foreground 212 --bold "Blocked USB accessory" + echo + echo "The following details come from the device and can be forged:" + printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' + echo + + choice=$(gum choose \ + "Allow once" \ + "Always allow this device" \ + "Keep blocked") || exit 130 + + flock 9 + if [[ ! -f $request || -L $request || $(cat "$request") != "$snapshot" ]]; then + echo "This USB authorization request changed. Open the latest notification to review it." >&2 + exit 1 + fi + # A fresh choice can authorize only the blocked identity shown in the dialog. + read_current_rule + if [[ $current_rule != "$expected_rule" ]]; then + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 + fi +fi + case "$choice" in "Allow once" | "Always allow this device") - # Target the complete identity rule instead of USBGuard's numeric ID. IDs can - # be reassigned when the daemon restarts while the review dialog is open. - options=() - if [[ $choice == "Always allow this device" ]]; then - options+=(--permanent) + # Save intent before applying policy: a later query can fail after the device + # was authorized. Retry must verify that approval, including persistent trust. + temporary=$(mktemp "${request%/*}/.request.XXXXXXXXXX") + trap 'rm -f "$temporary"' EXIT + jq --arg approval "$choice" '.approval = $approval' "$request" >"$temporary" + mv -f "$temporary" "$request" + + if [[ $current_rule == "$expected_rule" ]]; then + # Match the complete identity, since numeric IDs can be reassigned. + options=() + if [[ $choice == "Always allow this device" ]]; then + options+=(--permanent) + fi + usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed fi - usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed # Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can # return success without authorizing anything. Verify the exact device. - allowed_rule="allow ${expected_rule#block }" allowed=$(usbguard list-devices --allowed) || approval_failed grep -Fqx -- "$id: $allowed_rule" <<<"$allowed" || approval_failed @@ -107,6 +138,7 @@ case "$choice" in "Keep blocked") echo "USB accessory remains blocked." ;; +*) exit 1 ;; esac rm -f "$request" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index c9a5a825..d6eba4c9 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -74,6 +74,7 @@ list-devices) printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")" fi elif [[ ${2:-} == "--blocked" ]]; then + [[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1 [[ ! -f $TEST_ALLOWED_RULE ]] || exit 0 if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then @@ -84,6 +85,17 @@ list-devices) printf '17: %s\n' "$rule" fi else + [[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_ALLOWED_RULE ]]; then + [[ ${APPROVAL_QUERY_FAIL:-0} == 0 ]] || exit 1 + printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + elif [[ -n ${BLOCKED_DEVICE_RULE:-} && ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then + rule="$BLOCKED_DEVICE_RULE" + if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then + rule=$(<"$BLOCKED_DEVICE_RULE_FILE") + fi + printf '17: %s\n' "$rule" + fi echo '4: allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' fi @@ -142,6 +154,12 @@ cat >"$stub_bin/gum" <<'STUB' case "$1" in style) exit 0 ;; choose) + echo "gum choose" >>"$CALLS" + [[ ${GUM_CANCEL:-0} == 0 ]] || exit 130 + if [[ -n ${GUM_REPLACE_REQUEST:-} ]]; then + jq '.generation = "replacement-generation"' "$GUM_REPLACE_REQUEST" >"$GUM_REPLACE_REQUEST.new" + mv "$GUM_REPLACE_REQUEST.new" "$GUM_REPLACE_REQUEST" + fi if [[ -n ${GUM_REASSIGN_RULE:-} ]]; then printf '%s\n' "$GUM_REASSIGN_RULE" >"$BLOCKED_DEVICE_RULE_FILE" fi @@ -648,7 +666,7 @@ pass "USB review binds approval to the device snapshot" for choice in 'Allow once' 'Always allow this device'; do for failure in APPROVAL_SILENT_FAIL APPROVAL_EXIT_FAIL APPROVAL_QUERY_FAIL APPROVAL_REPLACEMENT_RULE; do - rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request" USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" value=1 [[ $failure != "APPROVAL_REPLACEMENT_RULE" ]] || value='allow id 9999:9999 hash "replacement"' @@ -660,7 +678,8 @@ for choice in 'Allow once' 'Always allow this device'; do done done for failure in APPROVAL_NO_POLICY APPROVAL_POLICY_QUERY_FAIL; do - rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request" + USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" if env "$failure=1" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-no-policy" 2>&1; then fail "permanent approval must reject $failure" fi @@ -672,6 +691,88 @@ GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-revie rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" pass "USB approvals verify device and permanent policy before consuming requests" +# Exercise retries without resetting the device state left by the first attempt. +for choice in 'Allow once' 'Always allow this device'; do + USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" + failure=APPROVAL_QUERY_FAIL + [[ $choice != 'Always allow this device' ]] || failure=APPROVAL_POLICY_QUERY_FAIL + if env "$failure=1" GUM_CHOICE="$choice" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-first" 2>&1; then + fail "transient verification failure must retain the approval" + fi + [[ -s $TEST_ALLOWED_RULE && -f $request ]] || fail "fixture must retain an already-allowed device" + [[ $(jq -r .approval "$request") == "$choice" ]] || fail "retry must remember the selected approval" + [[ $(stat -c %a "$request") == 600 ]] || fail "saved approval intent stays private" + allow_count=$(grep -c '^usbguard ' "$calls") + prompt_count=$(grep -c '^gum choose' "$calls") + GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-second" + [[ ! -e $request ]] || fail "verified approval retry consumes the request" + [[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "already-allowed retry must not reapply authorization" + [[ $(grep -c '^gum choose' "$calls") == "$prompt_count" ]] || fail "retry must resume the original approval without another choice" + if [[ $choice == 'Always allow this device' ]]; then + grep -Fq 'added to the trusted policy' "$scratch/retry-second" || fail "permanent retry must finish permanent verification" + else + grep -Fq 'allowed until it is disconnected' "$scratch/retry-second" || fail "once retry must finish temporary verification" + fi + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +done +pass "USB approval retries resume successful temporary and permanent authorization" + +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +if APPROVAL_NO_POLICY=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy" 2>&1; then + fail "permanent approval requires saved policy" +fi +allow_count=$(grep -c '^usbguard ' "$calls") +if GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then + fail "retry cannot downgrade permanent approval when policy is missing" +fi +[[ -f $request && $(jq -r .approval "$request") == 'Always allow this device' ]] || fail "missing policy retains original permanent intent" +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "retry must not repeat authorization on an allowed device" +for failure in BLOCKED_QUERY_FAIL APPROVAL_QUERY_FAIL; do + if env "$failure=1" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/query-retry" 2>&1; then + fail "retry must fail when inventory cannot be read" + fi + [[ -f $request ]] || fail "inventory query failures must preserve pending approvals" +done +printf '%s\n' "allow ${replacement_rule#block }" >"$TEST_ALLOWED_RULE" +if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-retry" 2>&1; then + fail "retry must reject a replacement allowed device" +fi +[[ ! -e $request ]] || fail "a proven replacement consumes the stale request" +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "replacement retry must not authorize anything" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +pass "USB approval retries preserve permanent intent and validate allowed identity" + +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +if BLOCKED_QUERY_FAIL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/fresh-query-failure" 2>&1; then + fail "fresh review must fail on inventory query failure" +fi +[[ -f $request ]] || fail "fresh inventory failure must not delete the request" +jq '.approval = "invalid"' "$request" >"$scratch/invalid-approval" +mv "$scratch/invalid-approval" "$request" +if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/invalid-intent" 2>&1; then + fail "invalid saved intent must not authorize" +fi +jq 'del(.approval)' "$request" >"$scratch/fresh-request" +mv "$scratch/fresh-request" "$request" +if GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/cancel" 2>&1; then + fail "canceled review must not authorize" +fi +jq -e '.approval == null' "$request" >/dev/null || fail "canceling must not store an approval" +if GUM_REPLACE_REQUEST="$request" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-request" 2>&1; then + fail "open dialog must not approve a replaced request" +fi +[[ $(jq -r .generation "$request") == 'replacement-generation' ]] || fail "stale dialog must leave replacement request intact" +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "canceled or superseded requests cannot authorize" +rm -f "$request" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +if APPROVAL_EXIT_FAIL=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/blocked-retry-first" 2>&1; then + fail "failed apply must retain a blocked request" +fi +GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/blocked-retry-second" +[[ -s $TEST_SAVED_RULE && ! -e $request ]] || fail "blocked retry must apply its saved permanent choice" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +pass "USB review preserves transient failures and rejects canceled or replaced dialogs" + notification_count=$(grep -c '^notification' "$calls") printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked" NOTIFICATION_READY_FILE="$scratch/notification-ready" \ From de72b64872cec8987a1c5db27e9727f5dae80d50 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Fri, 18 Sep 2026 12:43:29 -0600 Subject: [PATCH 13/16] Re-sign historical UKIs before changing USB boot policy --- bin/omarchy-usb-authorization-boot | 188 ++++++++++++++++++ manual/48-security.md | 2 + test/shell.d/usb-boot-archives-test.sh | 255 +++++++++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100755 test/shell.d/usb-boot-archives-test.sh diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index 6ffb010c..1db602e1 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -381,6 +381,188 @@ usb_authorization_rewrite_snapshot_manifest() { mv -f "$temporary" "$manifest" } +usb_authorization_verify_signature() { + # sbctl verify can return zero for unsigned or unreadable files. Check its + # structured result, including the exact path, instead of just the exit code. + if ! sbctl verify --json "$1" | jq -e --arg file "$1" \ + 'length == 1 and .[0].file_name == $file and .[0].is_signed == 1' >/dev/null; then + echo "Cannot verify the Secure Boot signature of $1 using this machine's signing keys." >&2 + return 1 + fi +} + +# Called with Limine's boot-partition lock held. Prepare signed copies of +# conflicting historical UKIs before changing the selected boot policy. +usb_authorization_reconcile_archived_images() ( + local expected="$1" config="$2" esp="$3" machine_id="$4" + local cache="${5:-/var/cache/boot/$machine_id/lss/snapshots.json}" + local history="$esp/$machine_id/limine_history" staging manifest names name image + local cmdline hash_command old_digest new_digest old_hash new_hash replacement index=0 + local published=0 committed=0 section layout image_base image_size alignment address + local -a manifests=() originals=() + + usb_authorization_secure_boot_enabled || return 0 + [[ -d $history ]] || return 0 + staging=$(mktemp -d "$esp/.omarchy-usb-uki.XXXXXXXXXX") || return 1 + trap ' + if (( published && ! committed )); then + for ((index=0; index<${#originals[@]}; index++)); do + cp --preserve=mode,ownership "$staging/original.$index" "${originals[index]}" || true + done + limine-enroll-config >/dev/null 2>&1 || true + fi + rm -rf "$staging" + ' EXIT + + for manifest in "$history/snapshots.json" "$history/snapshots.json.old" "$cache"; do + [[ -f $manifest ]] || continue + jq -e '.jsonFormatVersion == "1.3.0" and (.snapshotEntries | type == "array")' "$manifest" >/dev/null || return 1 + manifests+=("$manifest") + done + (( ${#manifests[@]} )) || return 0 + names=$(jq -sr '[.[] | .snapshotEntries[] | .. | objects | .imageDetails? // empty | .[] | .fileHashName] | unique[]' "${manifests[@]}") || return 1 + printf '[]\n' >"$staging/replacements.json" + + while IFS= read -r name; do + [[ -n $name ]] || continue + # Only image names, never arbitrary paths supplied by a manifest. + if [[ $name == */* || $name == "." || $name == ".." ]]; then + echo "Invalid archived kernel image name: $name" >&2 + return 1 + fi + image="$history/$name" + [[ -f $image && ! -L $image ]] || continue + : >"$staging/cmdline" + objcopy -O binary --only-section=.cmdline "$image" "$staging/cmdline" 2>/dev/null || continue + cmdline=$(tr '\0' '\n' <"$staging/cmdline") + [[ -n $cmdline ]] || continue + if usb_authorization_cmdline_has_parameter "$cmdline" "$kernel_parameter"; then + [[ $expected == "disabled" ]] || continue + else + [[ $expected == "enabled" ]] || continue + fi + + # Multi-profile images and signed PCR policies need their original builder. + # Do not silently invalidate their additional boot/unlock guarantees. + for section in .profile .pcrsig; do + objcopy -O binary --only-section="$section" "$image" "$staging/section" || return 1 + if [[ -s $staging/section ]]; then + echo "Rebuild archived image $image with its original signing policy before changing USB boot protection ($section)." >&2 + return 1 + fi + done + if [[ ! $name =~ ^(.+)_(sha1|sha256|b3|xxh)_([0-9a-f]+)$ ]]; then + echo "Cannot verify the content hash of archived image $image." >&2 + return 1 + fi + case "${BASH_REMATCH[2]}" in + sha1) hash_command=sha1sum ;; + sha256) hash_command=sha256sum ;; + b3) hash_command=b3sum ;; + xxh) hash_command=xxhsum ;; + esac + old_digest=${BASH_REMATCH[3]} + new_digest=$("$hash_command" "$image") || return 1 + if [[ ${new_digest%% *} != "$old_digest" ]]; then + echo "Archived image hash mismatch: $image" >&2 + return 1 + fi + # Verify the old signature before signing a modified copy with this machine's key. + usb_authorization_verify_signature "$image" || return 1 + cp "$image" "$staging/unsigned.efi" || return 1 + sbattach --remove "$staging/unsigned.efi" || return 1 + # Preserve the embedded boot arguments and change only our exact USB + # parameter. Do not turn a signed command line into an external override. + printf '%s' "$cmdline" | jq -Rrs --arg parameter "$kernel_parameter" --arg expected "$expected" ' + [scan("[^[:space:]]+|[[:space:]]+") | select(. != $parameter)] + | join("") | sub("[[:space:]]+$"; "") + | if $expected == "enabled" then . + " " + $parameter else . end + ' >"$staging/new-cmdline" || return 1 + printf '\0' >>"$staging/new-cmdline" + # objcopy --update-section retains PE VirtualSize and can truncate a longer + # command line. Add a fresh section after the existing virtual image instead. + layout=$(objdump -p "$staging/unsigned.efi" | awk ' + $1 == "ImageBase" { base = $2 } + $1 == "SizeOfImage" { size = $2 } + $1 == "SectionAlignment" { alignment = $2 } + END { + if (base !~ /^[0-9a-fA-F]+$/ || size !~ /^[0-9a-fA-F]+$/ || alignment !~ /^[0-9a-fA-F]+$/) exit 1 + print "0x" base, "0x" size, "0x" alignment + } + ') || return 1 + read -r image_base image_size alignment <<<"$layout" + (( alignment > 0 )) || return 1 + address=$((image_base + (image_size + alignment - 1) / alignment * alignment)) + objcopy --remove-section=.cmdline "$staging/unsigned.efi" "$staging/without-cmdline.efi" || return 1 + objcopy --add-section .cmdline="$staging/new-cmdline" \ + --change-section-vma .cmdline="$address" \ + --set-section-flags .cmdline=alloc,load,readonly,data,contents \ + "$staging/without-cmdline.efi" "$staging/image.efi" || return 1 + sbctl sign "$staging/image.efi" || return 1 + usb_authorization_verify_signature "$staging/image.efi" || return 1 + objcopy -O binary --only-section=.cmdline "$staging/image.efi" "$staging/cmdline" || return 1 + cmp -s "$staging/cmdline" "$staging/new-cmdline" || return 1 + new_digest=$("$hash_command" "$staging/image.efi") || return 1 + replacement="${name%"$old_digest"}${new_digest%% *}" + old_hash=$(b2sum "$image") || return 1 + new_hash=$(b2sum "$staging/image.efi") || return 1 + # Publish copies first: every old reference remains usable until its manifest changes. + if [[ -e $history/$replacement || -L $history/$replacement ]]; then + [[ ! -L $history/$replacement ]] && cmp -s "$staging/image.efi" "$history/$replacement" || return 1 + else + mv "$staging/image.efi" "$history/$replacement" || return 1 + fi + jq --arg old "$name" --arg new "$replacement" \ + --arg old_hash "${old_hash%% *}" --arg new_hash "${new_hash%% *}" \ + '. + [{old: $old, new: $new, old_hash: $old_hash, new_hash: $new_hash}]' \ + "$staging/replacements.json" >"$staging/next.json" || return 1 + mv "$staging/next.json" "$staging/replacements.json" || return 1 + done <<<"$names" + jq -e 'length > 0' "$staging/replacements.json" >/dev/null || return 0 + + originals=("$config" "${manifests[@]}") + for ((index=0; index<${#originals[@]}; index++)); do + cp --preserve=mode,ownership "${originals[index]}" "$staging/original.$index" || return 1 + cp --preserve=mode,ownership "${originals[index]}" "$staging/updated.$index" || return 1 + # Update cached menu lines, structured image metadata, restore paths and + # both hashes. The new file name remains a valid Limine content address. + if (( index == 0 )); then + jq -Rrs --slurpfile replacements "$staging/replacements.json" ' + split("\n") | map(reduce $replacements[0][] as $r (. ; + if contains($r.old) then split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash) + else . end)) | join("\n") + ' "$staging/original.$index" >"$staging/updated.$index" || return 1 + else + jq --slurpfile replacements "$staging/replacements.json" ' + walk(if type == "string" then + reduce $replacements[0][] as $r (. ; split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash)) + else . end) + ' "$staging/original.$index" >"$staging/updated.$index" || return 1 + fi + done + published=1 + for ((index=0; index<${#originals[@]}; index++)); do + mv -f "$staging/updated.$index" "${originals[index]}" || return 1 + done + limine-enroll-config || return 1 + committed=1 +) + +usb_authorization_prepare_archived_images() ( + local expected="$1" config="$2" esp="$3" machine_id="${4:-$(/run/lock/boot-partition.lock + flock --timeout 30 9 || return 1 + limine-snapper-sync --no-mutex --no-hooks || return 1 + usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine_id" +) + usb_authorization_sync_and_verify() ( local expected="$1" config="$2" esp="$3" local machine_id="${4:-$( Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. +With Secure Boot enabled, changing USB boot protection may require access to your signing keys to update older snapshot boot images. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying. + ## Changing your passwords You have two passwords on an encrypted install: the one that unlocks the drive at boot, and the one you log in and `sudo` with. Both can be changed under _Update > Password_ in the Omarchy menu — _Drive Encryption_ for the first, _User_ for the second. Changing the drive password asks for the current one first, so have it handy. diff --git a/test/shell.d/usb-boot-archives-test.sh b/test/shell.d/usb-boot-archives-test.sh new file mode 100755 index 00000000..f222f5ad --- /dev/null +++ b/test/shell.d/usb-boot-archives-test.sh @@ -0,0 +1,255 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/bin/omarchy-usb-authorization-boot" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +machine=12345678901234567890123456789012 +export calls="$scratch/calls" +: >"$calls" + +# Optional integration run uses real sbctl keys and sbattach in isolated paths. +sbctl() { + printf '%s\n' "$1" >>"$calls" + [[ ${SIGN_FAIL:-0} != 1 || $1 != sign ]] || return 1 + [[ ${VERIFY_FAIL:-0} != 1 || $1 != verify ]] || return 1 + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + /usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@" + elif [[ $1 == verify ]]; then + jq -n --arg file "${!#}" --argjson signed "${TEST_SIGNED:-1}" '[{file_name: $file, is_signed: $signed}]' + fi +} +sbattach() { + if [[ -n ${USB_TEST_SBATTACH:-} ]]; then + "$USB_TEST_SBATTACH" "$@" + fi +} +omarchy-cmd-missing() { return 1; } +limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; } +usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; } + +fixture() { + local embedded="$1" hash digest image_base + esp="$scratch/case-$RANDOM" + history="$esp/$machine/limine_history" + manifest="$history/snapshots.json" + cache="$esp/cache.json" + config="$esp/limine.conf" + mkdir -p "$history" + printf '%s\0' "$embedded" >"$esp/cmdline" + cp /usr/lib/systemd/boot/efi/linuxx64.efi.stub "$esp/image.efi" + image_base=$(objdump -p "$esp/image.efi" | awk '$1 == "ImageBase" {print "0x" $2}') + objcopy --add-section .cmdline="$esp/cmdline" --change-section-vma .cmdline="$((image_base + 0x100000))" "$esp/image.efi" + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + sbctl sign "$esp/image.efi" >/dev/null + fi + digest=$(sha256sum "$esp/image.efi"); digest=${digest%% *} + old_name="test.efi_sha256_$digest" + cp "$esp/image.efi" "$history/$old_name" + hash=$(b2sum "$esp/image.efi"); hash=${hash%% *} + printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n comment: kernel-id=linux\n path: boot():/%s/limine_history/%s#%s\n cmdline: %s rootflags=subvol=/snapshot\n' \ + "$machine" "$machine" "$old_name" "$hash" "$embedded" >"$config" + jq -n --arg name "$old_name" --arg hash "$hash" --arg machine "$machine" --arg cmdline "$embedded" ' + def kernel: + {imageDetails: [{fileHashName: $name, fileName: "test.efi", + snapshotFilePathLine: "/" + $machine + "/limine_history/" + $name + "#" + $hash, + filePathLine: "/EFI/Linux/test.efi#" + $hash, properties: {HASH: "#" + $hash}}], + cmdlineDetails: [{cmdline: $cmdline, snapshotCmdline: $cmdline + " rootflags=subvol=/snapshot"}], + allInConfig: ["path: boot():/EFI/Linux/test.efi#" + $hash, "cmdline: " + $cmdline], + allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/" + $machine + "/limine_history/" + $name + "#" + $hash, "cmdline: " + $cmdline + " rootflags=subvol=/snapshot"], subKernels: []}; + {jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel]]}]} + ' >"$manifest" + cp "$manifest" "$manifest.old" + cp "$manifest" "$cache" + cp "$manifest" "$esp/before.json" + cp "$config" "$esp/before.conf" + : >"$calls" +} + +for expected in enabled disabled; do + old='quiet' + [[ $expected != disabled ]] || old+=' usbcore.authorized_default=0' + fixture "$old" + usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache" + new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest") + [[ $new_name != "$old_name" && -f $history/$new_name ]] || fail "conflicting archives get new content-addressed copies" + cmp "$history/$old_name" "$esp/image.efi" || fail "original archived image remains unchanged" + digest=$(sha256sum "$history/$new_name") + [[ $new_name == *"${digest%% *}" ]] || fail "replacement name matches its content hash" + objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section" + embedded=$(tr '\0' '\n' <"$esp/section") + wanted=quiet + [[ $expected != enabled ]] || wanted+=' usbcore.authorized_default=0' + [[ $embedded == "$wanted" ]] || fail "replacement must preserve embedded boot arguments and change only USB policy" + objcopy -O binary --only-section=.text "$history/$old_name" "$esp/text.before" + objcopy -O binary --only-section=.text "$history/$new_name" "$esp/text.after" + cmp "$esp/text.before" "$esp/text.after" || fail "archive conversion must not alter executable code" + [[ $(grep -c '^sign$' "$calls") == 1 && $(grep -c '^verify$' "$calls") == 2 ]] || fail "deduplicated image must be verified before and after signing" + for file in "$manifest" "$manifest.old" "$cache"; do + ! grep -Fq "$old_name" "$file" || fail "no old image references may survive in manifests" + usb_authorization_rewrite_snapshot_manifest "$expected" "$file" + # Both cached and structured restore representations must regenerate valid entries. + for format in cached structured; do + { + printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n' "$machine" + if [[ $format == cached ]]; then + jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$file" + else + printf 'comment: kernel-id=linux\n' + jq -r '.snapshotEntries[0].kernelEntries[0].subKernels[0] | "path: boot():" + .imageDetails[0].snapshotFilePathLine, "cmdline: " + .cmdlineDetails[0].snapshotCmdline' "$file" + fi + } >"$esp/regenerated.conf" + usb_authorization_verify_boot_entries "$expected" "$esp/regenerated.conf" "$esp" "$machine" enabled || fail "regenerated $format $expected snapshot must verify" + done + done + usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine" + usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine" enabled || fail "$expected archive transition must verify under Secure Boot" + usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache" + [[ $(grep -c '^sign$' "$calls") == 1 ]] || fail "retry must not resign an already-compatible archive" + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + usb_authorization_verify_signature "$history/$new_name" + fi + pass "USB $expected transition updates signed history and all cached/structured references" +done + +fixture quiet +old_hash=$(b2sum "$esp/image.efi"); old_hash=${old_hash%% *} +printf ' //current\n comment: kernel-id=current\n path: boot():/image.efi#%s\n cmdline: quiet\n' "$old_hash" >>"$config" +usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" +grep -Fq "path: boot():/image.efi#$old_hash" "$config" || fail "preflight must not rewrite hashes of unchanged current images sharing archive content" +pass "USB archive preflight preserves unchanged current-image hashes" + +fixture 'quiet rd.test="two spaces"' +usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" +new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest") +objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section" +[[ $(tr '\0' '\n' <"$esp/section") == 'quiet rd.test="two spaces" usbcore.authorized_default=0' ]] || fail "unrelated quoted arguments must retain their whitespace" +pass "USB archive policy changes preserve unrelated embedded arguments and executable code" + +for failure in SIGN_FAIL VERIFY_FAIL ENROLL_FAIL; do + fixture quiet + if (export "$failure=1"; usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache") >"$esp/failure.log" 2>&1; then + fail "$failure must prevent publication" + fi + cmp "$config" "$esp/before.conf" || fail "$failure must preserve the original boot menu" + for file in "$manifest" "$manifest.old" "$cache"; do + cmp "$file" "$esp/before.json" || fail "$failure must preserve every manifest" + done + cmp "$history/$old_name" "$esp/image.efi" || fail "$failure must preserve the archived image" +done +pass "USB archive signing, verification and enrollment failures preserve original references" + +fixture quiet +printf corrupt >>"$history/$old_name" +if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/hash-failure" 2>&1; then + fail "corrupt original archive must never be signed" +fi +[[ ! -s $calls ]] || fail "hash failure must occur before signing" +cmp "$config" "$esp/before.conf" +SECURE_BOOT=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" +[[ ! -s $calls ]] || fail "non-Secure-Boot transitions must not rewrite archive images" +pass "USB archive conversion rejects corruption and skips non-Secure-Boot systems" + +if [[ -z ${USB_TEST_SBCTL_CONFIG:-} ]]; then + fixture quiet + if TEST_SIGNED=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/unsigned" 2>&1; then + fail "a false-success sbctl result cannot authorize signing" + fi + ! grep -qx sign "$calls" || fail "an unsigned original must not be resigned" +fi +for missing in menu manifest; do + fixture quiet + if [[ $missing == menu ]]; then + sed -i '/cmdline:/d' "$config" + else + jq '.snapshotEntries[0].kernelEntries[0].cmdlineDetails = []' "$manifest" >"$esp/modified" + mv "$esp/modified" "$manifest" + fi + usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" || fail "embedded-only $missing must support policy changes" + usb_authorization_rewrite_boot_cmdlines enabled "$config" "$machine" + usb_authorization_verify_boot_entries enabled "$config" "$esp" "$machine" enabled || fail "embedded-only $missing must retain a valid command line" +done +for section in .profile .pcrsig; do + fixture quiet + printf protected >"$esp/protected" + objcopy --add-section "$section=$esp/protected" "$history/$old_name" 2>/dev/null + if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/protected-failure" 2>&1; then + fail "a $section image must require its original builder" + fi + grep -Fq "($section)" "$esp/protected-failure" || fail "unsupported signing policies need an actionable error" + [[ ! -s $calls ]] || fail "unsupported signing policies must not be resigned" +done +pass "USB archive conversion requires authentic signatures and preserves boot/unlock requirements" + +# Package hooks use this same lock. Installing the optional signature tool +# must finish before preparation takes the lock, while sync must run under it. +fixture quiet +prepare=$(declare -f usb_authorization_prepare_archived_images) +prepare=${prepare//\/run\/lock\/boot-partition.lock/$scratch\/preflight.lock} +eval "$prepare" +omarchy-cmd-missing() { return 0; } +omarchy-pkg-add() { + flock --nonblock "$scratch/preflight.lock" true || fail "package installation must precede the boot lock" + printf 'package\n' >>"$calls" +} +limine-snapper-sync() { + [[ $* == '--no-mutex --no-hooks' ]] || fail "preparation must use the already-held lock" + if flock --nonblock "$scratch/preflight.lock" true; then + fail "snapshot synchronization must hold the boot lock" + fi +} +usb_authorization_prepare_archived_images enabled "$config" "$esp" "$machine" +grep -qx package "$calls" || fail "missing signature tooling must be installed before preparing archives" +pass "USB archive preparation installs optional tools before taking Limine's lock" + +# The disposable Lab runs the actual root-only main function with only boot paths +# and external build/sync commands redirected into this fixture. +if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then + (( EUID == 0 )) || fail "main-path fixtures require the disposable guest's root" + usb_authorization_prepare_archived_images() { + usb_authorization_reconcile_archived_images "$1" "$2" "$3" "$machine" "$cache" + } + limine-mkinitcpio() { return 0; } + usb_authorization_sync_and_verify() { + local file + for file in "$manifest" "$manifest.old" "$cache"; do + usb_authorization_rewrite_snapshot_manifest "$1" "$file" || return 1 + done + usb_authorization_rewrite_boot_cmdlines "$1" "$2" "$machine" || return 1 + usb_authorization_verify_boot_entries "$1" "$2" "$3" "$machine" enabled + } + for action in enable disable; do + old=quiet + [[ $action != disable ]] || old+=' usbcore.authorized_default=0' + fixture "$old" + drop_in="$esp/entry-tool/usb-authorization.conf" + limine_defaults="$esp/defaults" + limine_conf="$config" + boot_path="$esp" + printf '# fixture defaults\n' >"$limine_defaults" + if [[ $action == disable ]]; then + mkdir -p "${drop_in%/*}" + printf '%s\n' "$setting" >"$drop_in" + usb_authorization_enable_snapshot_setting "$limine_defaults" + fi + cp "$limine_defaults" "$esp/defaults.before" + if (export SIGN_FAIL=1; main "$action") >"$esp/main-failed" 2>&1; then + fail "$action must stop if archive preparation fails" + fi + cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes" + if [[ $action == enable ]]; then + [[ ! -e $drop_in ]] || fail "enable preflight failure must not create the boot setting" + else + [[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting" + fi + (main "$action") + if [[ $action == enable ]]; then + [[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy" + else + [[ ! -e $drop_in ]] || fail "successful disable clears the boot policy" + fi + pass "USB $action main path prepares archives before changing boot policy" + done +fi From e12c8ed4521403a691fb6992e1952f679ff977d7 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Fri, 18 Sep 2026 13:30:03 -0600 Subject: [PATCH 14/16] Restore signed boot files when USB boot policy changes fail --- bin/omarchy-usb-authorization-boot | 136 ++++++++++++++++- manual/48-security.md | 2 +- test/shell.d/usb-authorization-test.sh | 2 + test/shell.d/usb-boot-archives-test.sh | 6 +- test/shell.d/usb-boot-enrollment-test.sh | 183 +++++++++++++++++++++++ 5 files changed, 320 insertions(+), 9 deletions(-) create mode 100755 test/shell.d/usb-boot-enrollment-test.sh diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index 1db602e1..2fdd4cbe 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -328,9 +328,11 @@ usb_authorization_rewrite_boot_cmdlines() { fi mv -f "$temporary" "$config" - if ! limine-enroll-config; then + if ! usb_authorization_enroll_config "$config" "${config%/*}"; then mv -f "$backup" "$config" - limine-enroll-config >/dev/null 2>&1 || true + if ! usb_authorization_secure_boot_enabled; then + limine-enroll-config >/dev/null 2>&1 || true + fi echo "Restored $config because its bootloader verification data could not be updated." >&2 return 1 fi @@ -391,6 +393,109 @@ usb_authorization_verify_signature() { fi } +# Read the same architecture and enrollment setting as the installed Limine tool. +usb_authorization_limine_settings() ( + source /usr/lib/limine/limine-common-functions + load_config || return 1 + printf '%s %s\n' "$(limine_efi_arch | tr '[:upper:]' '[:lower:]')" "${ENABLE_ENROLL_LIMINE_CONFIG:-no}" +) + +usb_authorization_verify_bootloader() ( + local config="$1" esp="$2" architecture enrollment image check digest + read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1 + [[ -n $architecture ]] || return 1 + image="$esp/EFI/limine/limine_$architecture.efi" + usb_authorization_verify_signature "$image" || return 1 + check=$(mktemp) || return 1 + trap 'rm -f "$check"' EXIT + cp "$image" "$check" || return 1 + if [[ $enrollment == "yes" ]]; then + digest=$(b2sum "$config") || return 1 + limine enroll-config "$check" "${digest%% *}" --quiet || return 1 + else + limine enroll-config "$check" --reset --quiet || return 1 + fi + # Enrollment edits fixed bytes in the executable. Reapplying the expected + # value must be a no-op, including when enrollment is explicitly disabled. + if ! cmp -s "$image" "$check"; then + echo "Limine's signed executable does not match the configured menu enrollment." >&2 + return 1 + fi +) + +usb_authorization_enroll_config() { + limine-enroll-config || return 1 + if usb_authorization_secure_boot_enabled; then + usb_authorization_verify_bootloader "$1" "$2" || return 1 + fi +} + +# Kernel rebuild hooks also reset and re-sign Limine. Checkpoint the complete +# boot files, not just the menu: rebuilding can overwrite images it references. +# The outer process holds the real boot lock; the private mount namespace gives +# nested Limine tools their own lock inode so they cannot deadlock against it. +usb_authorization_boot_transaction() ( + local action="$1" state architecture enrollment index manifest changed=0 committed=0 restored=1 + local machine_id="$(/run/lock/boot-partition.lock + flock --timeout 30 8 || return 1 + read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1 + [[ -n $architecture ]] || return 1 + usb_authorization_verify_signature "$boot_path/EFI/limine/limine_$architecture.efi" || return 1 + state=$(mktemp -d /var/tmp/omarchy-usb-boot.XXXXXXXXXX) || return 1 + trap ' + if (( changed && ! committed )); then + echo "USB boot policy failed; restoring the previous boot files and settings." >&2 + for ((index=0; index<${#paths[@]}; index++)); do + if [[ -d $state/$index && ! -L $state/$index ]]; then + mkdir -p "${paths[index]}" && cp -a "$state/$index/." "${paths[index]}/" || restored=0 + elif [[ -e $state/$index || -L $state/$index ]]; then + cp -a --remove-destination "$state/$index" "${paths[index]}" || restored=0 + else + rm -rf -- "${paths[index]}" || restored=0 + fi + done + # New unreferenced images are harmless, but a newly created cached backup + # manifest must not reintroduce the failed policy on a later recovery. + for manifest in snapshots.json snapshots.json.old; do + [[ -e $state/0/$machine_id/limine_history/$manifest ]] || + rm -f "$boot_path/$machine_id/limine_history/$manifest" || restored=0 + [[ -e $state/1/lss/$manifest ]] || rm -f "$cache/lss/$manifest" || restored=0 + done + sync -f "$boot_path" || restored=0 + fi + if (( restored )); then + rm -rf "$state" + else + echo "Boot recovery was incomplete. Preserve and restore the checkpoint at $state before rebooting." >&2 + fi + ' EXIT + trap 'exit 1' HUP INT TERM + for ((index=0; index<${#paths[@]}; index++)); do + if [[ -d ${paths[index]} ]]; then + cp -a "${paths[index]}/." "$state/$index" || return 1 + elif [[ -e ${paths[index]} || -L ${paths[index]} ]]; then + cp -a "${paths[index]}" "$state/$index" || return 1 + fi + done + : >"$state/lock" + changed=1 + usb_authorization_run_transaction "$state" "$action" || return 1 + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" || return 1 + sync -f "$boot_path" || return 1 + committed=1 +) + +usb_authorization_run_transaction() { + unshare --mount --propagation private /bin/bash -c ' + mount --bind "$1/lock" /run/lock/boot-partition.lock || exit 1 + source "$2" + usb_authorization_main "$3" + ' bash "$1" "${BASH_SOURCE[0]}" "$2" +} + # Called with Limine's boot-partition lock held. Prepare signed copies of # conflicting historical UKIs before changing the selected boot policy. usb_authorization_reconcile_archived_images() ( @@ -409,7 +514,6 @@ usb_authorization_reconcile_archived_images() ( for ((index=0; index<${#originals[@]}; index++)); do cp --preserve=mode,ownership "$staging/original.$index" "${originals[index]}" || true done - limine-enroll-config >/dev/null 2>&1 || true fi rm -rf "$staging" ' EXIT @@ -544,7 +648,7 @@ usb_authorization_reconcile_archived_images() ( for ((index=0; index<${#originals[@]}; index++)); do mv -f "$staging/updated.$index" "${originals[index]}" || return 1 done - limine-enroll-config || return 1 + usb_authorization_enroll_config "$config" "$esp" || return 1 committed=1 ) @@ -586,11 +690,11 @@ usb_authorization_sync_and_verify() ( # Verify the regenerated menu, so synchronization cannot mask stale stored # command lines with a one-time edit to limine.conf. limine-snapper-sync --no-mutex --no-hooks || return 1 - limine-enroll-config || return 1 + usb_authorization_enroll_config "$config" "$esp" || return 1 usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" ) -main() { +usb_authorization_main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary if (( EUID != 0 )); then @@ -680,6 +784,26 @@ main() { esac } +main() { + if (( EUID != 0 )); then + echo "Error: omarchy-usb-authorization-boot must run as root" >&2 + return 1 + fi + case "${1:-}" in + enable|disable) + if usb_authorization_secure_boot_enabled; then + usb_authorization_boot_transaction "$1" + else + usb_authorization_main "$1" + fi + ;; + *) + echo "Usage: omarchy-usb-authorization-boot " >&2 + return 2 + ;; + esac +} + if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@" fi diff --git a/manual/48-security.md b/manual/48-security.md index c1b37d15..6525317c 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -25,7 +25,7 @@ For machines whose disk unlock and recovery never depend on any USB devices, _Se Older snapshots created before USBGuard was installed and enabled keep all USB disabled even after startup, including keyboards, network adapters, and storage. Trusting the devices connected now does not make them available in those snapshots. Enable boot-time protection only if you can unlock and recover the machine without any USB devices. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. -With Secure Boot enabled, changing USB boot protection may require access to your signing keys to update older snapshot boot images. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying. +With Secure Boot enabled, changing USB boot protection requires access to your signing keys and enough free space on the system drive for a temporary copy of the boot files. If signing fails, Omarchy restores the previous boot files and settings. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying. ## Changing your passwords diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index d6eba4c9..c3d64ba3 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -295,6 +295,8 @@ CONF source "$ROOT/bin/omarchy-usb-authorization-boot" limine-mkinitcpio() { return 0; } limine-enroll-config() { return 0; } +# Signed bootloader enrollment and rollback have a dedicated real-PE suite. +usb_authorization_verify_bootloader() { return 0; } if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then fail "boot authorization rejects Limine's false-success status when entries were not rebuilt" diff --git a/test/shell.d/usb-boot-archives-test.sh b/test/shell.d/usb-boot-archives-test.sh index f222f5ad..1da27dea 100755 --- a/test/shell.d/usb-boot-archives-test.sh +++ b/test/shell.d/usb-boot-archives-test.sh @@ -29,6 +29,8 @@ sbattach() { omarchy-cmd-missing() { return 1; } limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; } usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; } +# Bootloader enrollment/rollback is exercised with real EFI binaries in its own suite. +usb_authorization_verify_bootloader() { return 0; } fixture() { local embedded="$1" hash digest image_base @@ -235,7 +237,7 @@ if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then usb_authorization_enable_snapshot_setting "$limine_defaults" fi cp "$limine_defaults" "$esp/defaults.before" - if (export SIGN_FAIL=1; main "$action") >"$esp/main-failed" 2>&1; then + if (export SIGN_FAIL=1; usb_authorization_main "$action") >"$esp/main-failed" 2>&1; then fail "$action must stop if archive preparation fails" fi cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes" @@ -244,7 +246,7 @@ if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then else [[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting" fi - (main "$action") + (usb_authorization_main "$action") if [[ $action == enable ]]; then [[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy" else diff --git a/test/shell.d/usb-boot-enrollment-test.sh b/test/shell.d/usb-boot-enrollment-test.sh new file mode 100755 index 00000000..a1d98d57 --- /dev/null +++ b/test/shell.d/usb-boot-enrollment-test.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/bin/omarchy-usb-authorization-boot" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +usb_authorization_secure_boot_enabled() { return 0; } +usb_authorization_limine_settings() { printf 'x64 %s\n' "${ENROLLMENT:-yes}"; } + +# Real PE enrollment always runs. An optional isolated sbctl configuration also +# exercises real signatures; default runs track signed content without host keys. +sbctl() { + if [[ $1 == sign && ${SIGN_FAIL:-0} == 1 ]]; then + return 1 + fi + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + /usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@" + elif [[ $1 == sign ]]; then + sha256sum "${!#}" | cut -d ' ' -f1 >>"$scratch/signed" + elif [[ $1 == verify ]]; then + local digest signed=0 + digest=$(sha256sum "${!#}") + if grep -qx "${digest%% *}" "$scratch/signed"; then signed=1; fi + jq -n --arg file "${!#}" --argjson signed "$signed" '[{file_name: $file, is_signed: $signed}]' + fi +} + +fixture() { + case_dir=$(mktemp -d "$scratch/case.XXXXXXXX") + boot_path="$case_dir/boot" + cache="$case_dir/cache" + limine_conf="$boot_path/limine.conf" + limine_defaults="$case_dir/defaults" + drop_in="$case_dir/dropins/usb-authorization.conf" + image="$boot_path/EFI/limine/limine_x64.efi" + mkdir -p "${image%/*}" "$cache/lss" "${drop_in%/*}" + printf 'initial menu\n' >"$limine_conf" + printf 'initial kernel\n' >"$boot_path/kernel.efi" + printf 'initial snapshot\n' >"$cache/lss/snapshots.json" + printf 'initial defaults\n' >"$limine_defaults" + : >"$scratch/signed" + cp /usr/share/limine/BOOTX64.EFI "$image" + if [[ ${ENROLLMENT:-yes} == yes ]]; then + digest=$(b2sum "$limine_conf") + limine enroll-config "$image" "${digest%% *}" --quiet + fi + sbctl sign "$image" >/dev/null + cp "$image" "$case_dir/original.efi" + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" +} + +# Matches the installed wrapper: restore unsigned binary, enroll, attempt sign, +# then mask failure with a successful final sync. No real ESP is ever touched. +limine-enroll-config() { + cp /usr/share/limine/BOOTX64.EFI "$image" + if [[ ${ENROLLMENT:-yes} == yes && ${SKIP_ENROLLMENT:-0} != 1 ]]; then + local digest + digest=$(b2sum "$limine_conf") + limine enroll-config "$image" "${digest%% *}" --quiet || return 1 + fi + sbctl sign "$image" || true + sync -f "$image" +} + +# Relocate the outer real flock into scratch. The runner stands in for the +# namespace child and deliberately mutates the same files as rebuild hooks. +transaction=$(declare -f usb_authorization_boot_transaction) +transaction=${transaction//\/run\/lock\/boot-partition.lock/$scratch\/boot.lock} +eval "$transaction" +usb_authorization_run_transaction() { + if flock --nonblock "$scratch/boot.lock" true; then + fail "the real boot lock must cover the entire transaction" + fi + printf 'changed menu\n' >"$limine_conf" + printf 'changed kernel\n' >"$boot_path/kernel.efi" + printf 'changed metadata\n' >"$cache/lss/snapshots.json" + printf 'new cached backup\n' >"$cache/lss/snapshots.json.old" + mkdir -p "$boot_path/$("$boot_path/$("$limine_defaults" + printf '%s\n' "$setting" >"$drop_in" + if [[ ${EARLY_FAILURE:-0} == 1 ]]; then + cp /usr/share/limine/BOOTX64.EFI "$image" + return 1 + fi + if [[ ${UNCHECKED_HOOK:-0} == 1 ]]; then + limine-enroll-config + else + usb_authorization_enroll_config "$limine_conf" "$boot_path" + fi +} + +for failure in SIGN_FAIL SKIP_ENROLLMENT EARLY_FAILURE; do + fixture + if (export "$failure=1"; usb_authorization_boot_transaction enable "$cache") >"$case_dir/failure.log" 2>&1; then + fail "$failure must reject the boot transition" + fi + cmp "$image" "$case_dir/original.efi" || fail "$failure must restore the signed bootloader byte-for-byte without signing" + [[ $(<"$limine_conf") == 'initial menu' ]] || fail "$failure must restore matching menu" + [[ $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "$failure must restore images referenced by that menu" + [[ $(<"$cache/lss/snapshots.json") == 'initial snapshot' ]] || fail "$failure must restore cached snapshot metadata" + [[ ! -e $cache/lss/snapshots.json.old && ! -e $boot_path/$("$drop_in" +if (SIGN_FAIL=1 UNCHECKED_HOOK=1 usb_authorization_boot_transaction disable "$cache") >"$case_dir/failure.log" 2>&1; then + fail "final verification must reject false-success rebuild hooks" +fi +cmp "$image" "$case_dir/original.efi" || fail "false-success hooks must restore original signed Limine" +[[ $(<"$drop_in") == 'original opt-in' ]] || fail "failed disable must preserve an existing drop-in" +pass "USB boot transitions independently verify enrollment after external hooks" + +for enrollment in yes no; do + export ENROLLMENT="$enrollment" + fixture + usb_authorization_boot_transaction enable "$cache" + [[ $(<"$limine_conf") == 'changed menu' && -e $drop_in ]] || fail "successful transition must commit" + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" +done +pass "USB boot transitions commit verified signing with enrollment enabled or disabled" + +# Disposable Lab only: exercise the actual namespace runner and both lock +# inodes, with the installed enrollment/restore functions and real signatures. +if [[ ${USB_TEST_NAMESPACE:-0} == 1 ]]; then + (( EUID == 0 )) || fail "namespace tests require root in the disposable guest" + [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]] || fail "namespace tests require isolated signing keys" + export ENROLLMENT=yes + for outcome in failure success; do + fixture + cp /usr/share/limine/BOOTX64.EFI "$case_dir/limine_x64.efi" + tar -cf "$case_dir/limine.bak" -C "$case_dir" limine_x64.efi + lock_inode=$(stat -Lc '%d:%i' /run/lock/boot-partition.lock) + # Source the shipped functions in both the parent and namespace child. + sed '/^if \[\[ ${BASH_SOURCE\[0\]} == "\$0" \]\]; then/,$d' "$ROOT/bin/omarchy-usb-authorization-boot" >"$case_dir/helper" + for variable in scratch case_dir boot_path cache limine_conf limine_defaults drop_in image lock_inode; do + printf '%s=%q\n' "$variable" "${!variable}" >>"$case_dir/helper" + done + declare -f sbctl usb_authorization_limine_settings usb_authorization_secure_boot_enabled >>"$case_dir/helper" + cat >>"$case_dir/helper" <<'SCRIPT' +usb_authorization_main() { + [[ $(stat -Lc '%d:%i' /run/lock/boot-partition.lock) != "$lock_inode" ]] || return 1 + exec 9>/run/lock/boot-partition.lock + flock --nonblock 9 || return 1 + printf 'changed menu\n' >"$limine_conf" + printf 'changed kernel\n' >"$boot_path/kernel.efi" + source /usr/lib/limine/limine-common-functions + LIMINE_DIR_PATH="${image%/*}" + LIMINE_EFI_FILE=limine_x64.efi + BINARY_TARGET_PATH="$image" + BINARY_BACKUP_PATH="$case_dir/limine.bak" + LIMINE_CONFIG_PATH="$limine_conf" + ENABLE_ENROLL_LIMINE_CONFIG=yes + is_sb_installed() { return 0; } + is_uefi() { return 0; } + limine-enroll-config() { enroll_config; } + usb_authorization_enroll_config "$limine_conf" "$boot_path" +} +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + usb_authorization_boot_transaction "$1" "$cache" +fi +SCRIPT + if [[ $outcome == failure ]]; then + if SIGN_FAIL=1 bash "$case_dir/helper" enable >"$case_dir/namespace.log" 2>&1; then + fail "actual namespace must reject installed wrapper's false success" + fi + grep -Fq 'Failed to sign' "$case_dir/namespace.log" || fail "failure must reach the installed enrollment function" + cmp "$image" "$case_dir/original.efi" || fail "namespace rollback restores original signed bootloader" + [[ $(<"$limine_conf") == 'initial menu' && $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "namespace rollback restores matching boot files" + else + bash "$case_dir/helper" disable >"$case_dir/namespace.log" 2>&1 + [[ $(<"$limine_conf") == 'changed menu' ]] || fail "namespace success commits" + fi + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" + flock --nonblock /run/lock/boot-partition.lock true || fail "outer lock must be released" + done + pass "USB real namespace isolates nested locks and recovers installed Limine signing failure" +fi From 60d6b255da422592c63234903fc908104247c2e6 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Fri, 25 Sep 2026 14:13:48 -0600 Subject: [PATCH 15/16] Require approval for new Thunderbolt accessories --- AGENTS.md | 2 +- bin/omarchy-provision-owner | 3 + ...-remove-security-thunderbolt-authorization | 10 + bin/omarchy-remove-security-thunderbolt-boot | 9 + bin/omarchy-security-functions | 137 ++++ ...y-setup-security-thunderbolt-authorization | 42 ++ bin/omarchy-setup-security-thunderbolt-boot | 23 + bin/omarchy-system-factory-reset | 13 + bin/omarchy-thunderbolt-authorization-admin | 17 + bin/omarchy-thunderbolt-authorization-approve | 25 + ...chy-thunderbolt-authorization-boot-enabled | 8 + bin/omarchy-thunderbolt-authorization-daemon | 16 + bin/omarchy-thunderbolt-authorization-review | 10 + bin/omarchy-thunderbolt-authorization-watch | 9 + default/omarchy/omarchy-menu.jsonc | 4 + default/polkit/org.omarchy.thunderbolt.policy | 14 + .../omarchy-thunderbolt-authorization.service | 16 + docs/thunderbolt-authorization.md | 43 ++ .../rules.d/40-omarchy-thunderbolt.rules | 17 + .../bolt.service.d/omarchy-authorization.conf | 3 + .../omarchy-thunderbolt-authorization.service | 26 + install/config/all.sh | 1 + install/config/thunderbolt-authorization.sh | 10 + install/helpers/thunderbolt-policy.sh | 319 +++++++++ install/helpers/thunderbolt-review.sh | 254 +++++++ install/helpers/thunderbolt-setup.sh | 398 +++++++++++ install/user/first-run/enable-user-units.sh | 3 +- manual/48-security.md | 20 + migrations/1790344599.sh | 2 + .../fixtures/thunderbolt/bolt-exercise.sh | 87 +++ .../fixtures/thunderbolt/bolt-integration.py | 185 +++++ .../fixtures/thunderbolt/bolt-removal.sh | 95 +++ .../fixtures/thunderbolt/policy-test.sh | 640 ++++++++++++++++++ .../fixtures/thunderbolt/startup-test.sh | 31 + .../shell.d/thunderbolt-authorization-test.sh | 13 + 35 files changed, 2503 insertions(+), 2 deletions(-) create mode 100755 bin/omarchy-remove-security-thunderbolt-authorization create mode 100755 bin/omarchy-remove-security-thunderbolt-boot create mode 100755 bin/omarchy-security-functions create mode 100755 bin/omarchy-setup-security-thunderbolt-authorization create mode 100755 bin/omarchy-setup-security-thunderbolt-boot create mode 100755 bin/omarchy-thunderbolt-authorization-admin create mode 100755 bin/omarchy-thunderbolt-authorization-approve create mode 100755 bin/omarchy-thunderbolt-authorization-boot-enabled create mode 100755 bin/omarchy-thunderbolt-authorization-daemon create mode 100755 bin/omarchy-thunderbolt-authorization-review create mode 100755 bin/omarchy-thunderbolt-authorization-watch create mode 100644 default/polkit/org.omarchy.thunderbolt.policy create mode 100644 default/systemd/user/omarchy-thunderbolt-authorization.service create mode 100644 docs/thunderbolt-authorization.md create mode 100644 etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules create mode 100644 etc/systemd/system/bolt.service.d/omarchy-authorization.conf create mode 100644 etc/systemd/system/omarchy-thunderbolt-authorization.service create mode 100644 install/config/thunderbolt-authorization.sh create mode 100644 install/helpers/thunderbolt-policy.sh create mode 100644 install/helpers/thunderbolt-review.sh create mode 100644 install/helpers/thunderbolt-setup.sh create mode 100644 migrations/1790344599.sh create mode 100644 test/shell.d/fixtures/thunderbolt/bolt-exercise.sh create mode 100644 test/shell.d/fixtures/thunderbolt/bolt-integration.py create mode 100644 test/shell.d/fixtures/thunderbolt/bolt-removal.sh create mode 100644 test/shell.d/fixtures/thunderbolt/policy-test.sh create mode 100644 test/shell.d/fixtures/thunderbolt/startup-test.sh create mode 100755 test/shell.d/thunderbolt-authorization-test.sh diff --git a/AGENTS.md b/AGENTS.md index e4a0084e..6de19cb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience: - Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`) - Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable - For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`) -- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`) +- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index a4f2d996..f28136ee 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -1040,6 +1040,9 @@ run_provisioning() { usb_authorization_provision_owner "$username" fi + log_step "enrolling the owner's Thunderbolt accessories" + /usr/bin/omarchy-thunderbolt-authorization-admin owner + log_step "cleaning up provisioning state" cleanup_oem_state log_step "first-boot setup complete" diff --git a/bin/omarchy-remove-security-thunderbolt-authorization b/bin/omarchy-remove-security-thunderbolt-authorization new file mode 100755 index 00000000..0910041d --- /dev/null +++ b/bin/omarchy-remove-security-thunderbolt-authorization @@ -0,0 +1,10 @@ +#!/bin/bash + +# omarchy:summary=Restore Bolt's default authorization behavior + +set -euo pipefail + +gum confirm "Restore Bolt's previous policy? New accessories may be authorized automatically when IOMMU protection is active." || exit 0 +sudo /usr/bin/omarchy-thunderbolt-authorization-admin disable +systemctl --user disable --now omarchy-thunderbolt-authorization.service +echo "Previous Bolt policy restored. Saved Omarchy trust is retained for re-enabling." diff --git a/bin/omarchy-remove-security-thunderbolt-boot b/bin/omarchy-remove-security-thunderbolt-boot new file mode 100755 index 00000000..8657e527 --- /dev/null +++ b/bin/omarchy-remove-security-thunderbolt-boot @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:summary=Restore saved Thunderbolt firmware boot permissions + +set -euo pipefail + +gum confirm "Restore the previous Thunderbolt firmware boot allowlists? Device approval will remain enabled." || exit 0 +sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-disable +echo "Previous firmware boot permissions restored and verified." diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000..890d4430 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,137 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Provide internal helpers for command-scoped sudo authentication + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-security-functions is an internal function library." >&2 + exit 64 +fi + +omarchy_security_require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1 + executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1 + [[ $executable == "/usr/bin/bash" && + ( ${argv[0]:-} == "/bin/bash" || ${argv[0]:-} == "/usr/bin/bash" ) && + ${argv[1]:-} == "-p" ]] + ' omarchy-bash-startup "$$" +} + +omarchy_security_sanitize_bash_environment() { + local script=$1 + shift + local entry name environment_fd environment_pid + local -a unsets=() + + # Read the raw environment: privileged Bash ignores exported functions, but + # leaves their records for ordinary child interpreters to import later. + exec {environment_fd}< <(/usr/bin/env -0) + environment_pid=$! + while IFS= read -r -d '' entry <&"$environment_fd"; do + name=${entry%%=*} + case "$name" in + BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%) + unsets+=(-u "$name") + ;; + esac + done + exec {environment_fd}<&- + wait "$environment_pid" || return 1 + if (( ${#unsets[@]} > 0 )); then + exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@" + fi +} + +omarchy_security_require_source_root() { + local command_source command_name=${1##*/} + command_source=$(/usr/bin/readlink -e -- "$1") || return 1 + + # A runtime root selects the code used by this invocation. Accept the + # canonical checkout containing the entrypoint or the package's bin links. + if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] || + ! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] || + [[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then + echo "OMARCHY_PATH does not match this Omarchy command." >&2 + return 1 + fi +} + +omarchy_security_sudo_supports_no_update() { + local help + help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1 + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help" +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k +} + +omarchy_security_exit_with_revoked_sudo() { + local status=$1 + local message=${2:-Could not invalidate cached sudo authorization.} + + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + (( status != 0 )) || status=1 + fi + exit "$status" +} + +omarchy_security_install_signal_exit_traps() { + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +omarchy_security_install_sudo_cleanup_traps() { + OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} + trap omarchy_security_run_sudo_cleanup_trap EXIT + omarchy_security_install_signal_exit_traps +} + +omarchy_security_enable_no_update_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + if ! omarchy_security_sudo_supports_no_update; then + echo "This sudo does not support --no-update; refusing mixed-trust work." >&2 + return 1 + fi + if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then + echo "The command-scoped sudo wrapper is missing." >&2 + return 1 + fi + PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" + OMARCHY_SUDO_NO_UPDATE=1 + export PATH OMARCHY_SUDO_NO_UPDATE +} + +omarchy_security_run_sudo_cleanup_trap() { + local status=$? + + omarchy_security_exit_with_revoked_sudo "$status" \ + "${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}" +} + +omarchy_security_assert_root_directory() { + local path=$1 expected_mode=$2 canonical owner actual_mode + + [[ $path == /* && -d $path && ! -L $path ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$path") || return 1 + [[ $canonical == "$path" ]] || return 1 + read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1 + [[ $owner == "0" && $actual_mode == "$expected_mode" ]] +} + +omarchy_security_prepare_private_root_directory() { + local path=$1 parent=$2 + + omarchy_security_assert_root_directory "$parent" 755 || return 1 + if [[ -e $path || -L $path ]]; then + omarchy_security_assert_root_directory "$path" 700 + else + /usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1 + omarchy_security_assert_root_directory "$path" 700 + fi +} diff --git a/bin/omarchy-setup-security-thunderbolt-authorization b/bin/omarchy-setup-security-thunderbolt-authorization new file mode 100755 index 00000000..45eb2652 --- /dev/null +++ b/bin/omarchy-setup-security-thunderbolt-authorization @@ -0,0 +1,42 @@ +#!/bin/bash + +# omarchy:summary=Require approval for new Thunderbolt PCIe accessories +# omarchy:args=[--quiet|status|recover] + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh" + +if [[ ${1:-} == "status" ]]; then + tb_status +elif [[ ${1:-} == "recover" && $# == 1 ]]; then + sudo /usr/bin/omarchy-thunderbolt-authorization-admin recover +elif [[ $# == 0 || ( $# == 1 && $1 == "--quiet" ) ]]; then + if [[ ${1:-} != "--quiet" ]]; then + if [[ -f $TB_PENDING ]]; then tb_status; fi + echo "First setup, including a deferred setup, trusts connected accessories. Re-enabling a completed setup preserves saved trust." + gum confirm "Require approval for new Thunderbolt accessories?" || exit 0 + fi + # System units and rules are shipped by omarchy-settings. + sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy + sudo systemctl daemon-reload + action=enable + if [[ ${1:-} == "--quiet" ]]; then + action=migrate + fi + sudo /usr/bin/omarchy-thunderbolt-authorization-admin "$action" + systemctl --user daemon-reload + systemctl --user enable --now omarchy-thunderbolt-authorization.service + if [[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]; then + for ((attempt=0; attempt<10; attempt++)); do + if tb_public_snapshot >/dev/null 2>&1; then break; fi + sleep 1 + done + tb_status + elif [[ -f $TB_PENDING ]]; then + tb_status + fi +else + echo "Usage: omarchy-setup-security-thunderbolt-authorization [--quiet|status|recover]" >&2 + exit 1 +fi diff --git a/bin/omarchy-setup-security-thunderbolt-boot b/bin/omarchy-setup-security-thunderbolt-boot new file mode 100755 index 00000000..23909db3 --- /dev/null +++ b/bin/omarchy-setup-security-thunderbolt-boot @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:summary=Clear supported Thunderbolt firmware boot allowlists +# omarchy:args=[recover] + +set -euo pipefail + +if [[ ${1:-} == "recover" && $# == 1 ]]; then + sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-recover +elif [[ $# == 0 ]]; then + gum style --bold "Thunderbolt at Boot" + echo "This trusts connected accessories for use after Linux starts, then clears the firmware boot allowlist." + echo "Trusted Thunderbolt keyboards, storage, and network adapters may be unavailable at disk unlock." + echo "You must be able to unlock and recover without Thunderbolt accessories." + echo "All controllers must be connected and support a boot allowlist in user or secure mode." + echo "Older snapshots or another operating system may restore firmware boot permissions." + gum confirm "Clear firmware boot access with these recovery limits?" || exit 0 + sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-enable + echo "Firmware boot allowlists cleared and verified. Current connections were not disconnected." +else + echo "Usage: omarchy-setup-security-thunderbolt-boot [recover]" >&2 + exit 1 +fi diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 087a6169..7f266648 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -320,6 +320,12 @@ scrub_factory_accounts() { sanitize_factory_baseline() { local factory="$1" btrfs property set -ts "$factory" ro false + if [[ -d $factory/var/lib/omarchy/thunderbolt-authorization || -f $factory/etc/omarchy/thunderbolt-authorization.enabled || -f $factory/etc/omarchy/thunderbolt-authorization.pending ]]; then + if ! /usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not reset Thunderbolt authorization in the factory baseline" + fi + fi if ! scrub_factory_accounts "$factory"; then btrfs property set -ts "$factory" ro true @@ -368,6 +374,13 @@ stage_full_reset() { scrub_factory_accounts "$next" || fail "could not remove account credentials from the factory system (see $LOG_FILE)" + # Leave the new owner's input available before interactive enrollment. Clear + # inherited accessory identities in both roots; never change the live daemon. + if [[ -d $next/var/lib/omarchy/thunderbolt-authorization || -f $next/etc/omarchy/thunderbolt-authorization.enabled || -f $next/etc/omarchy/thunderbolt-authorization.pending ]]; then + /usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$next" || + fail "could not reset Thunderbolt authorization in the staged root" + fi + # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and # /etc/shadow, which the new wheel user could mount and read — and a second diff --git a/bin/omarchy-thunderbolt-authorization-admin b/bin/omarchy-thunderbolt-authorization-admin new file mode 100755 index 00000000..28eec766 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-admin @@ -0,0 +1,17 @@ +#!/bin/bash -p + +# omarchy:summary=Manage system Thunderbolt authorization settings +# omarchy:hidden=true + +# This root entrypoint uses installed code and rejects unsafe Bash startup. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS +source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh +source /usr/share/omarchy/install/helpers/thunderbolt-setup.sh +tb_admin "$@" diff --git a/bin/omarchy-thunderbolt-authorization-approve b/bin/omarchy-thunderbolt-authorization-approve new file mode 100755 index 00000000..30b86db0 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-approve @@ -0,0 +1,25 @@ +#!/bin/bash -p + +# omarchy:summary=Approve one verified Thunderbolt connection +# omarchy:hidden=true + +# pkexec permits this exact program for an active local wheel user. Suppress +# startup injection before reading input; it cannot dispatch general root work. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS +source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh + +[[ $# == 2 && ( $1 == "true" || $1 == "false" ) ]] || exit 64 +(( ${#2} <= 16384 )) || exit 64 +jq -e 'type == "object"' <<< "$2" >/dev/null +exec 9> "$TB_LOCK" +flock -x 9 +result=$(tb_approve "$2" "$1" true) +tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$result" +printf '%s\n' "$result" diff --git a/bin/omarchy-thunderbolt-authorization-boot-enabled b/bin/omarchy-thunderbolt-authorization-boot-enabled new file mode 100755 index 00000000..3fb023d8 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-boot-enabled @@ -0,0 +1,8 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Check whether Thunderbolt firmware boot access is restricted + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +tb_public_snapshot | jq -e .boot_protection >/dev/null diff --git a/bin/omarchy-thunderbolt-authorization-daemon b/bin/omarchy-thunderbolt-authorization-daemon new file mode 100755 index 00000000..39f3dd0e --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-daemon @@ -0,0 +1,16 @@ +#!/bin/bash -p + +# omarchy:summary=Restore trusted Thunderbolt devices and publish their status +# omarchy:hidden=true + +# Keep root startup independent of a caller's environment or checkout. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS +source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh +tb_daemon diff --git a/bin/omarchy-thunderbolt-authorization-review b/bin/omarchy-thunderbolt-authorization-review new file mode 100755 index 00000000..6b555464 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-review @@ -0,0 +1,10 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Handle Thunderbolt device approval review + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh" +[[ $# == 1 ]] || exit 64 +tb_review "$1" diff --git a/bin/omarchy-thunderbolt-authorization-watch b/bin/omarchy-thunderbolt-authorization-watch new file mode 100755 index 00000000..20746c75 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-watch @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Handle Thunderbolt device approval watch + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh" +tb_watch diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 9018db72..184e8118 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -179,6 +179,8 @@ "setup.plugin.remove": {"icon":"󰭌","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"}, "setup.security": {"icon":"","label":"Security"}, "setup.config": {"icon":"","label":"Config"}, + "setup.security.thunderbolt": {"icon":"󱐋","label":"Thunderbolt","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization"}, + "setup.security.thunderbolt-boot": {"icon":"󱐋","label":"Thunderbolt Boot","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-boot"}, "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"}, @@ -298,6 +300,8 @@ "remove.windows": {"icon":"󰍲","label":"Windows","when":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm remove'"}, "remove.preinstalls": {"icon":"󰏓","label":"Preinstalls","when":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-preinstalls"}, "remove.security": {"icon":"","label":"Security","title":"Remove"}, + "remove.security.thunderbolt": {"icon":"󱐋","label":"Thunderbolt","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled || -f /etc/omarchy/thunderbolt-authorization.pending ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-authorization"}, + "remove.security.thunderbolt-boot": {"icon":"󱐋","label":"Thunderbolt Boot","when":"omarchy-thunderbolt-authorization-boot-enabled","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-boot"}, "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"}, diff --git a/default/polkit/org.omarchy.thunderbolt.policy b/default/polkit/org.omarchy.thunderbolt.policy new file mode 100644 index 00000000..e849f583 --- /dev/null +++ b/default/polkit/org.omarchy.thunderbolt.policy @@ -0,0 +1,14 @@ + + + + + Approve a connected Thunderbolt accessory + Authentication is required to approve this Thunderbolt accessory + /usr/bin/omarchy-thunderbolt-authorization-approve + + auth_admin + auth_admin + auth_admin + + + diff --git a/default/systemd/user/omarchy-thunderbolt-authorization.service b/default/systemd/user/omarchy-thunderbolt-authorization.service new file mode 100644 index 00000000..b7c808c5 --- /dev/null +++ b/default/systemd/user/omarchy-thunderbolt-authorization.service @@ -0,0 +1,16 @@ +[Unit] +Description=Offer approval for blocked Thunderbolt accessories +After=graphical-session.target +PartOf=graphical-session.target +ConditionEnvironment=WAYLAND_DISPLAY +ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.enabled +ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.pending + +[Service] +ExecStart=/usr/bin/omarchy-thunderbolt-authorization-watch +Restart=always +RestartSec=2 +UMask=0077 + +[Install] +WantedBy=graphical-session.target diff --git a/docs/thunderbolt-authorization.md b/docs/thunderbolt-authorization.md new file mode 100644 index 00000000..82ce8b8e --- /dev/null +++ b/docs/thunderbolt-authorization.md @@ -0,0 +1,43 @@ +# Thunderbolt authorization + +Omarchy uses Bolt for Thunderbolt/USB4 PCIe authorization. USB functions remain separate and need USB authorization. The end-user controls and firmware limitations are documented in [the Security manual](../manual/48-security.md#thunderbolt-device-authorization). + +## Policy and services + +Bolt's default IOMMU policy can enroll new devices without asking. Omarchy keeps `AuthMode=disabled` in `/var/lib/boltd/boltd.conf` and authorizes individual devices through Bolt's D-Bus API. This does not disable Bolt or prevent explicit authorization. Setting only `DefaultPolicy=manual` would still leave IOMMU auto-enrollment enabled. + +The root `omarchy-thunderbolt-authorization.service` holds explicit consent in `/var/lib/omarchy/thunderbolt-authorization/policy.json`. Bolt can import devices authorized by firmware, so its database is not treated as consent. Initial setup captures currently connected accessories; subsequent enables preserve that inventory, including an initially empty inventory. Always-allow approvals authorize, read back the real status, enroll with manual policy, verify storage, and save consent. Trusted devices are restored even without an unlocked graphical session. No operation globally enables Bolt to authorize one device. + +Omarchy records which Bolt entries it enrolled, including their store timestamps, separately from consent. Removing protection converts only matching, successfully approved entries from manual to automatic policy before restoring Bolt's global mode; this includes disconnected accessories. Existing manual records and externally recreated records retain their policies. Secure keys are kept. The handoff verifies the stored policies after restarting Bolt and checkpoints policies, firmware allowlists, configuration and services for rollback. Automatic policies resume Bolt's normal firmware allowlist behavior, so every controller must be online for a recoverable handoff. A completed handoff clears enrollment ownership while retaining the trust list for re-enabling. + +The Bolt startup drop-in sets the persisted mode before hardware enumeration whenever `/etc/omarchy/thunderbolt-authorization.enabled` exists. The Omarchy service checks the live mode again. Firmware security `none` remains a bypass: software cannot prevent driver loading after firmware has already opened a PCIe tunnel. This produces a warning instead of disconnecting active storage. + +Secure-capable devices require both a stored Bolt record and a saved key for automatic trust. An explicit permanent approval pins the current sysfs directory and key descriptor, seeds an empty kernel key before authorization, and verifies Bolt imported it before saving consent. Automatic reconciliation cannot initialize a missing key and retains its read-only sysfs sandbox. First activation is deferred when a captured secure-capable device lacks a saved Bolt key: the `.pending` marker starts a setup notification, while existing Bolt configuration and services remain untouched. Installation can finish; only explicit setup after safe disconnection clears the pending state. Initial identity capture uses Bolt's numeric device/vendor fallback when descriptive sysfs attributes are absent. + +The runtime is Bash, with `busctl --json=short` for Bolt's structured D-Bus replies and `jq` for JSON. The root daemon publishes a root-owned snapshot in `/run/omarchy-thunderbolt-authorization`. The user service polls that snapshot and refuses unavailable, stale, or mismatched generations. It does not infer final policy from presence signals. Requests use opaque tokens and bind the policy-service generation, Bolt bus owner, device path, connection timestamp, sysfs inode, and device identity. The inode distinguishes same-second reconnects. Device text is displayed as untrusted data. Failed delivery retries; lost approval responses retain intent and offer another notification. Watcher restarts preserve unfinished intent only while the exact connection and policy-service incarnation remain unchanged. A changed device or daemon requires a new request. + +The terminal dialog saves an approval intent; the background watcher submits it through `pkexec` to the fixed installed `/usr/bin/omarchy-thunderbolt-authorization-approve` helper. Polkit grants active local wheel users this individual approval action. The helper validates its arguments and the live connection, then independently verifies authorization and any saved trust before returning success. Root entrypoints use protected Bash startup and fixed installed libraries; they discard caller-supplied D-Bus addresses. A shared lock serializes setup, reconciliation, and approvals. General setup uses a separate administrator command invoked with `sudo` from the terminal. Blanket Bolt management requires administrator authentication while protection is enabled. These controls protect against unsolicited accessories, not malicious software already running as an administrator. Identifiers can be forged; Bolt's secure connection keys provide additional verification on supported hardware. + +## Firmware boot access + +The optional boot control applies only to online controllers with a firmware BootACL in `user` or `secure` mode. It captures connected accessories, changes stored policies to manual, clears each allowlist, and independently reads back the results. Reconciliation keeps firmware imports and returning controllers from silently restoring automatic boot permissions. Unsupported or offline controllers produce explicit warnings/errors. + +Changes retain the previous policy and firmware lists. A failed transition restores matching state, or leaves a recovery checkpoint and reports failure. Device-authorization setup has a separate checkpoint that includes service state and firmware settings when boot protection was active. Restore commands are described in the manual. Firmware modified by another OS or an older snapshot is outside the current system's control; this is not an early-boot kernel authorization switch. + +Deferred owner setup leaves ordinary Bolt behavior in place until the owner finishes provisioning. Factory reset disables the copied service and removes previous-owner trust, recovery data, Bolt identities, and keys from both staged and retained roots. It does not change the live daemon. + +## Validation + +Run policy, notification, rollback, deferred-owner, and real offline-systemctl tests with: + +```sh +bash test/shell.d/thunderbolt-authorization-test.sh +``` + +The optional integration suite runs the installed `boltd` against upstream's UMockdev Thunderbolt fixture and a private D-Bus. Its hardware simulator uses Python/GObject; all Omarchy policy code and test assertions execute Bash. It needs `python-gobject`, `python-dbusmock`, `umockdev`, Bolt, and a Bolt source checkout (validated with 0.9.11): + +```sh +umockdev-wrapper env BOLT_TEST_SOURCE=/path/to/bolt bash test/shell.d/thunderbolt-authorization-test.sh +``` + +It covers user/secure modes, IOMMU default denial, verified permanent and one-time approval, daemon restart, stale requests, firmware ACL restoration, and a separate Bash daemon publishing status after reconnecting to Bolt. Removal cases exercise the real setup and approval functions, automatic reconnection with retained secure keys, preservation of existing manual policies, disconnected accessories, and rollback of policies, firmware ACLs and the real private approval daemon after a setup failure. A service-control bridge manages only the fixture's private processes. These tests simulate kernel devices and firmware. They do not demonstrate physical DMA isolation or real firmware enforcement during boot. Systemd packaging, the installed approval helper, and the actual desktop Polkit rules require installed-guest validation; source-only dev linking does not install system units or rules. diff --git a/etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules b/etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules new file mode 100644 index 00000000..3f37d36a --- /dev/null +++ b/etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules @@ -0,0 +1,17 @@ +polkit.addRule(function(action, subject) { + if (action.id === "org.omarchy.thunderbolt.approve" && + subject.active && subject.local && subject.isInGroup("wheel")) { + return polkit.Result.YES; + } + // Bolt's vendor rule grants wheel passwordless management, including enabling + // automatic approval of every IOMMU device. Keep blanket policy changes behind + // administrator authentication; the narrow Omarchy action approves one device. + if (action.id === "org.freedesktop.bolt.manage" && subject.user !== "root") { + try { + polkit.spawn(["/usr/bin/test", "-f", "/etc/omarchy/thunderbolt-authorization.enabled"]); + return polkit.Result.AUTH_ADMIN; + } catch (error) { + // Protection is disabled: retain the packaged Bolt permission rules. + } + } +}); diff --git a/etc/systemd/system/bolt.service.d/omarchy-authorization.conf b/etc/systemd/system/bolt.service.d/omarchy-authorization.conf new file mode 100644 index 00000000..96b2645b --- /dev/null +++ b/etc/systemd/system/bolt.service.d/omarchy-authorization.conf @@ -0,0 +1,3 @@ +[Service] +# Runs before Bolt enumerates hardware, including D-Bus/udev activation. +ExecStartPre=/usr/bin/omarchy-thunderbolt-authorization-admin guard diff --git a/etc/systemd/system/omarchy-thunderbolt-authorization.service b/etc/systemd/system/omarchy-thunderbolt-authorization.service new file mode 100644 index 00000000..c2b5e7a6 --- /dev/null +++ b/etc/systemd/system/omarchy-thunderbolt-authorization.service @@ -0,0 +1,26 @@ +[Unit] +Description=Restore approved Thunderbolt devices and handle new approvals +Wants=bolt.service +After=bolt.service polkit.service +ConditionPathExists=/etc/omarchy/thunderbolt-authorization.enabled + +[Service] +Type=simple +ExecStart=/usr/bin/omarchy-thunderbolt-authorization-daemon +RuntimeDirectory=omarchy-thunderbolt-authorization +RuntimeDirectoryMode=0755 +Restart=on-failure +RestartSec=2 +UMask=0077 +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/omarchy/thunderbolt-authorization /run/lock +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/install/config/all.sh b/install/config/all.sh index 3ff8d66a..09b23293 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -10,3 +10,4 @@ run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" +run_logged "$OMARCHY_INSTALL/config/thunderbolt-authorization.sh" diff --git a/install/config/thunderbolt-authorization.sh b/install/config/thunderbolt-authorization.sh new file mode 100644 index 00000000..e2e4f2c5 --- /dev/null +++ b/install/config/thunderbolt-authorization.sh @@ -0,0 +1,10 @@ +echo "Configure Thunderbolt device authorization" + +install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy + +# The builder's accessories must not become the new owner's trust policy. +# Keep ordinary Bolt behavior until interactive owner setup has finished. +if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then + /usr/bin/omarchy-thunderbolt-authorization-admin prepare + systemctl enable omarchy-thunderbolt-authorization.service +fi diff --git a/install/helpers/thunderbolt-policy.sh b/install/helpers/thunderbolt-policy.sh new file mode 100644 index 00000000..f8f93030 --- /dev/null +++ b/install/helpers/thunderbolt-policy.sh @@ -0,0 +1,319 @@ +# Shared by the root controller and session commands. These paths are fixed at +# privileged entrypoints; tests may source the functions in an isolated fixture. +TB_STATE=/var/lib/omarchy/thunderbolt-authorization/policy.json +TB_CONFIG=/var/lib/boltd/boltd.conf +TB_MARKER=/etc/omarchy/thunderbolt-authorization.enabled +TB_PENDING=/etc/omarchy/thunderbolt-authorization.pending +TB_RUNTIME=/run/omarchy-thunderbolt-authorization +TB_LOCK=/run/lock/omarchy-thunderbolt-authorization.lock +TB_SERVICE=omarchy-thunderbolt-authorization.service +TB_BOLT=org.freedesktop.bolt +TB_MANAGER=org.freedesktop.bolt1.Manager +TB_DEVICE=org.freedesktop.bolt1.Device +TB_DOMAIN=org.freedesktop.bolt1.Domain +TB_PATH=/org/freedesktop/bolt +TB_SYSFS=/sys/bus/thunderbolt/devices +TB_TRUST_JQ='def trusted($state): $state.trusted[.Uid] == {Uid,Name,Vendor} and + ((.Secure | not) or (.Stored and (.Key | IN("have","new"))));' + +tb_fail() { printf '%s\n' "$*" >&2; return 1; } + +tb_json_write() { + local target=$1 mode=${2:-600} temporary + temporary=$(mktemp "${target%/*}/.thunderbolt.XXXXXX") || return 1 + if jq -cS . > "$temporary" && chmod "$mode" "$temporary" && sync "$temporary" && + mv -fT -- "$temporary" "$target" && sync "${target%/*}"; then + return 0 + else + rm -f -- "$temporary" + return 1 + fi +} + +tb_state() { + jq -ce 'select(.version == 1 and (.trusted | type == "object") and (.enabled | type == "boolean"))' "$TB_STATE" +} + +tb_bus() { + busctl --system --auto-start=no --timeout=10 --json=short "$@" +} + +tb_owner() { + tb_bus call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$TB_BOLT" | jq -er '.data[0]' +} + +tb_properties() { + tb_bus call "$1" "$2" org.freedesktop.DBus.Properties GetAll s "$3" | jq -ce '.data[0] | map_values(.data)' +} + +tb_set() { + local owner=$1 path=$2 interface=$3 key=$4 value=$5 signature=${6:-s} current + local -a values=() + current=$(tb_properties "$owner" "$path" "$interface") || return 1 + if ! jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null; then + if [[ $signature == "as" ]]; then + mapfile -t values < <(jq -r '.[]' <<< "$value") + tb_bus set-property "$owner" "$path" "$interface" "$key" as "${#values[@]}" "${values[@]}" >/dev/null || return 1 + else + tb_bus set-property "$owner" "$path" "$interface" "$key" s "$(jq -r . <<< "$value")" >/dev/null || return 1 + fi + fi + # Bolt rejects no-op BootACL writes; even skipped writes are read back. + current=$(tb_properties "$owner" "$path" "$interface") || return 1 + jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null || + tb_fail "Bolt did not retain the requested $key" +} + +tb_inventory() { + local owner manager paths path item inode domains='[]' devices='[]' stored='[]' + owner=$(tb_owner) || return 1 + manager=$(tb_properties "$owner" "$TB_PATH" "$TB_MANAGER") || return 1 + paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDomains | jq -cer '.data[0]') || return 1 + while IFS= read -r path; do + item=$(tb_properties "$owner" "$path" "$TB_DOMAIN" | jq -c --arg path "$path" '. + {path:$path}') || return 1 + domains=$(jq -c --argjson item "$item" '. + [$item]' <<< "$domains") || return 1 + done < <(jq -r '.[]' <<< "$paths") + paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDevices | jq -cer '.data[0]') || return 1 + while IFS= read -r path; do + item=$(tb_properties "$owner" "$path" "$TB_DEVICE" | jq -c --arg path "$path" '. + {path:$path}') || return 1 + [[ $(jq -r .Type <<< "$item") == "peripheral" ]] || continue + if jq -e .Stored <<< "$item" >/dev/null; then + stored=$(jq -c --argjson item "$item" '. + [$item]' <<< "$stored") || return 1 + fi + [[ $(jq -r .Status <<< "$item") != "disconnected" ]] || continue + inode=$(stat -Lc '%i' -- "$(jq -r .SysfsPath <<< "$item")") || return 1 + item=$(jq -c --arg inode "$inode" '. + {inode:$inode}' <<< "$item") || return 1 + devices=$(jq -c --argjson item "$item" '. + [$item]' <<< "$devices") || return 1 + done < <(jq -r '.[]' <<< "$paths") + [[ $(tb_owner) == "$owner" ]] || { tb_fail "Bolt restarted while reading its inventory"; return 1; } + jq -cn --arg owner "$owner" --argjson manager "$manager" --argjson domains "$domains" \ + --argjson devices "$devices" --argjson stored "$stored" ' + {owner:$owner,manager:$manager,domains:$domains,stored:$stored,devices:[$devices[] | + . + {Secure:((.Domain as $domain | any($domains[]; .Uid == $domain and .SecurityLevel == "secure")) + and (.AuthFlags | contains("nokey") | not))}]}' +} + +tb_read_inventory() { + local inventory + inventory=$(tb_inventory) || return 1 + jq -e '.manager.AuthMode == "disabled"' <<< "$inventory" >/dev/null || { + tb_fail "Bolt automatic authorization is enabled; protection is not active" + return 1 + } + printf '%s\n' "$inventory" +} + +tb_identity() { + jq -c --arg owner "$1" --arg generation "$2" \ + '{owner:$owner,generation:$generation,path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime}' +} + +tb_snapshot() { + local state inventory generation recovery=false + state=$(tb_state) || return 1 + inventory=$(tb_read_inventory) || return 1 + generation=$(< "$TB_RUNTIME/generation") || return 1 + [[ -f ${TB_STATE%/*}/boot-recovery.json ]] && recovery=true + jq -c --argjson state "$state" --arg generation "$generation" --argjson recovery "$recovery" \ + --arg error "${1:-}" --argjson time "$(date +%s)" "$TB_TRUST_JQ"' + .owner as $owner | + [.devices[] | . as $d | { + identity: ({owner:$owner,generation:$generation} + ($d | {path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime})), + trusted: ($d | trusted($state)), + status:.Status,stored:.Stored,policy:.Policy,flags:.AuthFlags + }] as $devices | + {available:true,time:$time,owner:$owner,generation:$generation,devices:$devices,error:$error, + boot_protection:($state.boot_protection // false), + domains:[.domains[] | {Uid,SecurityLevel,IOMMU,BootACL}], + warnings: ([ + (if $recovery then "A firmware boot-access change needs recovery. Boot protection is not confirmed." else empty end), + (.domains[] | if .SecurityLevel == "none" then + "This controller allows PCIe connections in firmware. Select user authorization in firmware settings; Omarchy cannot block its devices before their drivers load." + else empty end), + (.domains[] | .SecurityLevel as $level | select(["none","user","secure","dponly","usbonly","nopcie"] | index($level) | not) | + "The controller authorization mode could not be verified."), + (.domains[] | select($state.boot_protection and (.SysfsPath == "" or + (.SecurityLevel != "user" and .SecurityLevel != "secure") or (.BootACL | length == 0) or any(.BootACL[]; . != ""))) | + "The firmware boot allowlist is not confirmed empty on every controller. Check Thunderbolt Boot before relying on boot protection."), + ($devices[] | select((.status | startswith("authorized")) and (.trusted | not) and (.flags | contains("boot"))) | + "An untrusted device was authorized by firmware before Linux started. Disconnect it safely to apply approval on its next connection.") + ] | unique)}' <<< "$inventory" +} + +tb_current() { + local expected=$1 inventory owner generation device identity + inventory=$(tb_read_inventory) || return 1 + owner=$(jq -r .owner <<< "$inventory") + generation=$(< "$TB_RUNTIME/generation") || return 1 + while IFS= read -r device; do + identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1 + if jq -e --argjson expected "$expected" '. == $expected' <<< "$identity" >/dev/null; then + jq -cn --arg owner "$owner" --argjson device "$device" '{owner:$owner,device:$device}' + return 0 + fi + done < <(jq -c '.devices[]' <<< "$inventory") + tb_fail "That Thunderbolt device was removed or changed. Open its latest notification." +} + +tb_prepare_key() ( + local identity=$1 current key key_fd + current=$(tb_current "$identity") || return 1 + # Bolt creates keys for stored devices itself. For a new device, disabled + # AuthMode prevents enrollment before authorization. Seed the kernel key so + # Authorize provisions it and the subsequent enrollment imports it into Bolt. + jq -e '.device.Secure and (.device.Stored | not)' <<< "$current" >/dev/null || return 0 + cd -- "$(jq -r .device.SysfsPath <<< "$current")" || return 1 + [[ $(stat -Lc '%i' .) == "$(jq -r .device.inode <<< "$current")" && + $(cat unique_id) == "$(jq -r .device.Uid <<< "$current")" ]] || { + tb_fail 'The Thunderbolt connection changed before key setup'; return 1; + } + # Pin the attribute in the original directory; do not follow a replacement + # connection at the same path, or truncate an existing key on a retry. + exec {key_fd}<>key || return 1 + key=$(cat key) || return 1 + if [[ -z $key ]]; then + [[ $(cat authorized) == "0" ]] || { tb_fail 'Reconnect the device before saving secure approval'; return 1; } + key=$(openssl rand -hex 32) || return 1 + [[ $key =~ ^[0-9a-f]{64}$ ]] || return 1 + tb_current "$identity" >/dev/null || return 1 + printf '%s' "$key" >&"$key_fd" || return 1 + [[ $(cat key) == "$key" ]] || { tb_fail 'Could not verify the Thunderbolt key'; return 1; } + else + [[ $key =~ ^[0-9a-fA-F]{64}$ ]] || { tb_fail 'The Thunderbolt key is invalid'; return 1; } + fi +) + +tb_approve() { + local identity=$1 permanent=$2 initialize=${3:-false} current owner path status state + state=$(tb_state) || return 1 + jq -e .enabled <<< "$state" >/dev/null || { tb_fail "Thunderbolt protection is disabled"; return 1; } + current=$(tb_current "$identity") || return 1 + owner=$(jq -r .owner <<< "$current") + path=$(jq -r .device.path <<< "$current") + status=$(jq -r .device.Status <<< "$current") + case "$status" in + connected|auth-error) + if [[ $permanent == "true" ]]; then + if [[ $initialize == "true" ]]; then + tb_prepare_key "$identity" || return 1 + else + jq -e '(.device.Secure | not) or (.device.Stored and (.device.Key | IN("have","new")))' <<< "$current" >/dev/null || { + tb_fail 'A secure key requires explicit device approval'; return 1; + } + fi + fi + current=$(tb_current "$identity") || return 1 + tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null || return 1 ;; + authorized|authorized-newkey|authorized-secure) ;; + *) tb_fail "Device authorization is still in progress; try again"; return 1 ;; + esac + current=$(tb_current "$identity") || return 1 + jq -e '.device.Status | IN("authorized","authorized-newkey","authorized-secure")' <<< "$current" >/dev/null || { + tb_fail "Could not confirm device authorization; try again"; return 1; + } + if [[ $permanent == "true" ]]; then + if ! jq -e .device.Stored <<< "$current" >/dev/null; then + # Save enrollment intent before Bolt writes its record, so a lost reply + # can be retried without confusing our record with a pre-existing one. + state=$(jq --argjson device "$(jq -c .device <<< "$current")" \ + '.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state") || return 1 + tb_json_write "$TB_STATE" <<< "$state" || return 1 + tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$(jq -r .device.Uid <<< "$current")" manual '' >/dev/null || return 1 + fi + current=$(tb_current "$identity") || return 1 + jq -e .device.Stored <<< "$current" >/dev/null || { tb_fail "Could not confirm saved device information; try again"; return 1; } + jq -e '(.device.Secure | not) or (.device.Key | IN("have","new"))' <<< "$current" >/dev/null || { + tb_fail 'Secure approval has no saved key. Safely disconnect, reconnect, and approve the device again.'; return 1; + } + jq --argjson device "$(jq -c .device <<< "$current")" ' + if .enrolled[$device.Uid] == ($device | {Uid,Name,Vendor}) then + .enrolled[$device.Uid] = ($device | {Uid,Name,Vendor,StoreTime}) + else . end | + .trusted[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state" | + tb_json_write "$TB_STATE" || return 1 + fi + tb_snapshot +} + +tb_boot_apply() { + local saved=$1 inventory owner item uid policies acls value path + inventory=$(tb_inventory) || return 1 + owner=$(jq -r .owner <<< "$inventory") + jq -e --argjson saved "$saved" ' + (($saved.policies | keys) - [.stored[].Uid] | length == 0) and + (($saved.acls | keys) - [.domains[] | select(.SysfsPath != "") | .Uid] | length == 0)' <<< "$inventory" >/dev/null || { + tb_fail "Reconnect the original controllers and restore missing Bolt records before recovery"; return 1; + } + while IFS= read -r item; do + uid=$(jq -r .Uid <<< "$item") + value=$(jq -c --arg uid "$uid" '.policies[$uid] // empty' <<< "$saved") || return 1 + [[ -n $value ]] || continue + tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DEVICE" Policy "$value" || return 1 + done < <(jq -c '.stored[]' <<< "$inventory") + # Changing a device policy can update firmware ACLs, so restore ACLs last. + while IFS= read -r item; do + uid=$(jq -r .Uid <<< "$item") + value=$(jq -c --arg uid "$uid" '.acls[$uid] // empty' <<< "$saved") || return 1 + [[ -n $value ]] || continue + [[ $(jq length <<< "$value") == "$(jq '.BootACL | length' <<< "$item")" ]] || { + tb_fail "Firmware boot allowlist capacity changed"; return 1; + } + tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DOMAIN" BootACL "$value" as || return 1 + done < <(jq -c '.domains[]' <<< "$inventory") +} + +tb_reconcile() { + local inventory state owner generation device identity error='' saved + state=$(tb_state) || return 1 + inventory=$(tb_read_inventory) || return 1 + owner=$(jq -r .owner <<< "$inventory") + generation=$(< "$TB_RUNTIME/generation") || return 1 + if jq -e .boot_protection <<< "$state" >/dev/null; then + saved=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries), + acls:(.domains | map(select(.SysfsPath != "" and (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length>0))) | + map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1 + tb_boot_apply "$saved" || return 1 + fi + while IFS= read -r device; do + identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1 + if ! tb_approve "$identity" true >/dev/null; then + error='A trusted Thunderbolt device could not be restored. Check the system service log.' + fi + done < <(jq -c --argjson state "$state" "$TB_TRUST_JQ"'.devices | sort_by(.SysfsPath | length)[] | + select((.Status == "connected" or .Status == "auth-error") and (.AuthFlags | contains("nopcie") | not)) | + # Never silently initialize a missing secure binding on a later connection. + select(trusted($state))' <<< "$inventory") + tb_snapshot "$error" +} + +tb_daemon() { + local snapshot error + install -d -m 755 "$TB_RUNTIME" + cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation" + chmod 644 "$TB_RUNTIME/generation" + while [[ -f $TB_MARKER ]]; do + ( + flock -x 9 + if snapshot=$(tb_reconcile 2> "$TB_RUNTIME/error"); then + tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$snapshot" + else + cat "$TB_RUNTIME/error" >&2 + error=$(< "$TB_RUNTIME/error") + jq -cn --arg error "$error" --argjson time "$(date +%s)" '{available:false,time:$time,error:$error}' | + tb_json_write "$TB_RUNTIME/snapshot.json" 644 + fi + ) 9> "$TB_LOCK" + sleep 2 + done +} + +tb_public_snapshot() { + local snapshot generation + snapshot=$(cat "$TB_RUNTIME/snapshot.json") || return 1 + generation=$(< "$TB_RUNTIME/generation") || return 1 + jq -e --argjson now "$(date +%s)" --arg generation "$generation" \ + '.available and .generation == $generation and .time <= $now and $now - .time < 20' <<< "$snapshot" >/dev/null || { + tb_fail 'Thunderbolt policy is unavailable or could not be verified.'; return 1; + } + printf '%s\n' "$snapshot" +} diff --git a/install/helpers/thunderbolt-review.sh b/install/helpers/thunderbolt-review.sh new file mode 100644 index 00000000..0e6913fd --- /dev/null +++ b/install/helpers/thunderbolt-review.sh @@ -0,0 +1,254 @@ +# Session-side notification and terminal dialog functions. + +tb_requests() { + TB_REQUESTS="$HOME/.local/state/omarchy/thunderbolt-authorization/requests" + mkdir -p -m 700 "$TB_REQUESTS" + chmod 700 "${TB_REQUESTS%/*}" "$TB_REQUESTS" +} + +tb_safe_text() { + jq -r 'gsub("[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]"; "?")' +} + +tb_notify() { + local token=$1 retry=${2:-false} title='Thunderbolt accessory blocked' description + description='Click to review it. Keep it blocked unless you recognize the device.' + if [[ $retry == "true" ]]; then + title='Thunderbolt approval needs retry' + description='The last approval could not be confirmed. Click to retry it.' + fi + omarchy-notification-wait 1 >/dev/null || return 1 + timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical --glyph '󱐋' \ + "$title" "$description" --exec omarchy-launch-floating-terminal-with-presentation \ + omarchy-thunderbolt-authorization-review "$token" +} + +tb_attention() { + omarchy-notification-wait 1 >/dev/null || return 1 + timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \ + 'Thunderbolt protection needs attention' 'The approval or firmware policy could not be confirmed. Click for details.' \ + --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization status +} + +tb_setup_notice() { + omarchy-notification-wait 1 >/dev/null || return 1 + timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \ + 'Finish Thunderbolt protection setup' 'A connected accessory needs secure enrollment. Protection is not enabled. Click to finish safely.' \ + --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization +} + +tb_token() { + local digest + digest=$(jq -cS --arg watch "$2" '{watch:$watch,identity:.}' <<< "$1" | sha256sum) || return 1 + printf 'request-%s\n' "${digest%% *}" +} + +tb_request_read() { + [[ -f $1 && ! -L $1 ]] || return 1 + jq -ce 'select(.identity | type == "object")' "$1" +} + +tb_deliver() { + local path=$1 request token + request=$(tb_request_read "$path") || return 1 + if ! jq -e '.notified // false' <<< "$request" >/dev/null; then + token=${path##*/} + if tb_notify "${token%.json}" "$(jq -r '.approval != null' <<< "$request")"; then + jq '.notified=true' <<< "$request" | tb_json_write "$path" + else + return 1 + fi + fi +} + +tb_request_apply() { + # The watcher has no terminal. Polkit permits this one fixed helper for an + # active local administrator; it cannot dispatch general setup operations. + pkexec --disable-internal-agent /usr/bin/omarchy-thunderbolt-authorization-approve "$1" "$2" +} + +tb_request_process() { + local path=$1 request identity permanent result error_file error + request=$(tb_request_read "$path") || return 1 + if jq -e '.pending // false' <<< "$request" >/dev/null; then + identity=$(jq -c .identity <<< "$request") + permanent=$(jq -r '.approval == "Always allow this device"' <<< "$request") + error_file=$(mktemp "$TB_REQUESTS/.error.XXXXXX") || return 1 + if result=$(tb_request_apply "$permanent" "$identity" 2> "$error_file") && + jq -e --argjson identity "$identity" --argjson permanent "$permanent" \ + 'any(.devices[]; .identity==$identity and (.status | IN("authorized","authorized-newkey","authorized-secure")) and + ($permanent == false or .trusted))' <<< "$result" >/dev/null; then + jq --argjson result "$result" '.pending=false | .result=$result | del(.error)' <<< "$request" | + tb_json_write "$path" || return 1 + else + error=$(cat "$error_file") + [[ -n $error ]] || error='Could not confirm approval. Click the retry notification.' + jq --arg error "$error" '.pending=false | .notified=false | .error=$error' <<< "$request" | + tb_json_write "$path" || return 1 + fi + rm -f -- "$error_file" + fi + tb_deliver "$path" +} + +tb_scan() { + local watch=$1 snapshot path request device identity token existing + snapshot=$(tb_public_snapshot) || return 1 + ( + flock -x 9 + for path in "$TB_REQUESTS"/request-*.json; do + [[ -e $path || -L $path ]] || continue + if ! request=$(tb_request_read "$path"); then + rm -f -- "$path" + continue + fi + identity=$(jq -c .identity <<< "$request") + device=$(jq -c --argjson identity "$identity" '.devices[] | select(.identity==$identity)' <<< "$snapshot") || exit 1 + if [[ -z $device ]] || { ! jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null && + ! jq -e '.approval != null' <<< "$request" >/dev/null; }; then + rm -- "$path" + continue + fi + if [[ $(jq -r .watch <<< "$request") != "$watch" ]]; then + rm -- "$path" + # Resume an interrupted attempt only for the same exact connection. + jq -e '.approval != null and .result == null' <<< "$request" >/dev/null || continue + token=$(tb_token "$identity" "$watch") || exit 1 + path="$TB_REQUESTS/$token.json" + request=$(jq -c --arg watch "$watch" '.watch=$watch | .notified=false' <<< "$request") || exit 1 + tb_json_write "$path" <<< "$request" || exit 1 + fi + tb_request_process "$path" || true + done + snapshot=$(tb_public_snapshot) || exit 1 + while IFS= read -r device; do + identity=$(jq -c .identity <<< "$device") + token=$(tb_token "$identity" "$watch") || exit 1 + path="$TB_REQUESTS/$token.json" + if [[ ! -f $path ]]; then + jq -cn --argjson identity "$identity" --arg watch "$watch" \ + '{identity:$identity,watch:$watch,notified:false}' | tb_json_write "$path" || exit 1 + if ! tb_deliver "$path"; then + rm -f -- "$path" + fi + fi + done < <(jq -c '.devices[] | select((.trusted | not) and (.status | IN("connected","auth-error")) and + (.flags | contains("nopcie") | not))' <<< "$snapshot") + ) 9> "$TB_REQUESTS/.lock" || return 1 + printf '%s\n' "$snapshot" +} + +tb_watch() { + local watch snapshot warning warned='' failures=0 + tb_requests + watch=$(cat /proc/sys/kernel/random/uuid) + while true; do + if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then + if [[ $warned != "pending" ]] && tb_setup_notice; then warned=pending; fi + sleep 2 + continue + fi + if snapshot=$(tb_scan "$watch"); then + failures=0 + warning=$(jq -c '[.generation,.warnings,.error]' <<< "$snapshot") + if jq -e '(.warnings | length > 0) or .error != ""' <<< "$snapshot" >/dev/null; then + if [[ $warning != "$warned" ]] && tb_attention; then + warned=$warning + fi + else + warned='' + fi + else + failures=$((failures + 1)) + if (( failures >= 3 )) && [[ $warned != "unavailable" ]] && tb_attention; then + warned=unavailable + fi + fi + sleep 2 + done +} + +tb_find_device() { + local snapshot + snapshot=$(tb_public_snapshot) || return 1 + jq -ce --argjson identity "$1" '.devices[] | select(.identity==$identity)' <<< "$snapshot" || + tb_fail 'That Thunderbolt device was removed or changed. Open its latest notification.' +} + +tb_review() { + local token=$1 path request identity device choice latest index + [[ $token =~ ^request-[0-9a-f]{64}$ ]] || { tb_fail 'Invalid or expired Thunderbolt request'; return 1; } + tb_requests + path="$TB_REQUESTS/$token.json" + request=$(tb_request_read "$path") || return 1 + identity=$(jq -c .identity <<< "$request") + device=$(tb_find_device "$identity") || return 1 + choice=$(jq -r '.approval // empty' <<< "$request") + if [[ -z $choice ]]; then + jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || { tb_fail 'This device is no longer blocked'; return 1; } + gum style --foreground 212 --bold 'Blocked Thunderbolt accessory' + printf '\nThese details come from the device and can be forged:\n' + for index in Name Vendor Uid; do + printf '%s: ' "$index" + jq --arg key "$index" '.[$key]' <<< "$identity" | tb_safe_text + done + printf '\nApproval gives this accessory access to its PCIe drivers.\n\n' + choice=$(gum choose 'Allow once' 'Always allow this device' 'Keep blocked') || return 0 + fi + case "$choice" in + 'Allow once'|'Always allow this device'|'Keep blocked') ;; + *) tb_fail 'Invalid approval choice'; return 1 ;; + esac + ( + flock -x 9 + latest=$(tb_request_read "$path") || exit 1 + [[ $latest == "$request" ]] || { tb_fail 'This request changed. Open the latest notification.'; exit 1; } + device=$(tb_find_device "$identity") || exit 1 + if [[ $choice == "Keep blocked" ]]; then + printf 'Thunderbolt accessory remains blocked.\n' + else + if ! jq -e '.approval != null' <<< "$request" >/dev/null; then + jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || exit 1 + fi + jq --arg choice "$choice" '.approval=$choice | .pending=true | del(.error,.result)' <<< "$request" | + tb_json_write "$path" + fi + ) 9> "$TB_REQUESTS/.lock" || return 1 + if [[ $choice != "Keep blocked" ]]; then + for ((index=0; index<60; index++)); do + latest=$(tb_request_read "$path") || return 1 + if jq -e '.result != null' <<< "$latest" >/dev/null; then + rm -- "$path" + if [[ $choice == "Always allow this device" ]]; then + printf 'Thunderbolt accessory trusted.\n' + else + printf 'Thunderbolt accessory allowed for this connection.\n' + fi + return 0 + elif jq -e '.error != null' <<< "$latest" >/dev/null; then + jq .error <<< "$latest" | tb_safe_text >&2 + return 1 + fi + sleep 1 + done + tb_fail 'Approval is still pending. The watcher will retain and retry the request.' + fi +} + +tb_status() { + local snapshot + if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then + printf 'Thunderbolt protection is deferred. Previous Bolt behavior remains active.\nKeep a keyboard/input device available independently of Thunderbolt. Safely disconnect the Thunderbolt accessories, then run Setup > Security > Thunderbolt. Reconnect each accessory and choose Always allow to save its secure key.\n' + return 0 + fi + snapshot=$(tb_public_snapshot) || return 1 + printf 'Thunderbolt device approval is enabled.\nTrusted accessories reconnect automatically; new accessories require approval.\n' + if jq -e '.domains | length == 0' <<< "$snapshot" >/dev/null; then + printf 'No Thunderbolt controller is currently visible. Its firmware policy will be checked when it appears.\n' + fi + jq '.warnings[], (.error | select(. != ""))' <<< "$snapshot" | while IFS= read -r warning; do + printf 'Attention: ' + tb_safe_text <<< "$warning" + done + printf 'Firmware boot access is separate; see the Security manual before relying on pre-boot protection.\n' +} diff --git a/install/helpers/thunderbolt-setup.sh b/install/helpers/thunderbolt-setup.sh new file mode 100644 index 00000000..70ffc48e --- /dev/null +++ b/install/helpers/thunderbolt-setup.sh @@ -0,0 +1,398 @@ +# Root setup functions. The caller sources thunderbolt-policy.sh first. + +tb_config_authmode() { + local path=$1 + if [[ -f $path ]]; then + awk ' + /^[[:space:]]*\[/ { section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) } + section && /^[[:space:]]*AuthMode[[:space:]]*=/ { + sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); mode=$0 + } + END { print mode == "" ? "enabled" : mode } + ' "$path" + else + printf 'enabled\n' + fi +} + +tb_config_write() { + local path=$1 mode=$2 temporary + [[ $mode == "enabled" || $mode == "disabled" ]] || return 1 + install -d -m 700 "${path%/*}" || return 1 + temporary=$(mktemp "${path%/*}/.config.XXXXXX") || return 1 + if awk -v mode="$mode" ' + /^[[:space:]]*\[/ { + section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) + if (section) { found=1; print; print "AuthMode = " mode; next } + } + section && /^[[:space:]]*AuthMode[[:space:]]*=/ { next } + { print } + END { if (!found) print "\n[config]\nAuthMode = " mode } + ' "$(if [[ -e $path ]]; then printf '%s' "$path"; else printf /dev/null; fi)" > "$temporary" && + sync "$temporary" && mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then + return 0 + else + rm -f -- "$temporary" + return 1 + fi +} + +tb_guard() { + if [[ -f $TB_MARKER ]]; then + tb_state | jq -e .enabled >/dev/null || { tb_fail 'Thunderbolt policy and enable marker disagree'; return 1; } + tb_config_write "$TB_CONFIG" disabled + fi +} + +tb_capture() { + local path uid name vendor trusted='{}' + for path in "$TB_SYSFS"/*; do + [[ -f $path/unique_id && $path != *-0 ]] || continue + uid=$(cat "$path/unique_id") || return 1 + # Bolt uses the numeric attribute when a descriptive name is absent. + if [[ -e $path/device_name ]]; then + name=$(cat "$path/device_name") || return 1 + else + name=$(cat "$path/device") || return 1 + fi + if [[ -e $path/vendor_name ]]; then + vendor=$(cat "$path/vendor_name") || return 1 + else + vendor=$(cat "$path/vendor") || return 1 + fi + [[ -n $uid ]] || { tb_fail 'A connected Thunderbolt device has no identity'; return 1; } + trusted=$(jq -c --arg uid "$uid" --arg name "$name" --arg vendor "$vendor" \ + '.[$uid] = {Uid:$uid,Name:$name,Vendor:$vendor}' <<< "$trusted") || return 1 + done + printf '%s\n' "$trusted" +} + +tb_capture_ready() { + local fresh=${1:-false} offline=${2:-false} path uid inode inventory='' + # Do not relax an active policy, or capture new devices when re-enabling an + # existing policy. This preflight only guards the initial trust inventory. + [[ ! -f $TB_MARKER ]] || return 0 + if [[ -f $TB_STATE && ! -f $TB_PENDING && $fresh != "true" ]]; then + tb_state | jq -e '.enrollment_pending // false' >/dev/null || return 0 + fi + for path in "$TB_SYSFS"/*; do + [[ -f $path/unique_id && $path != *-0 && -e $path/key ]] || continue + uid=$(cat "$path/unique_id") || return 1 + [[ $uid =~ ^[0-9a-fA-F-]{36}$ ]] || { tb_fail 'A connected Thunderbolt device has an invalid identity'; return 1; } + path=$(realpath -e -- "$path") || return 1 + inode=$(stat -Lc '%i' -- "$path") || return 1 + # A key file alone can be orphaned after a failed Bolt record write. Ask + # Bolt whether this exact connection has a stored record and key. Offline + # prepare must not accept the live installer's daemon as the target store. + if [[ $offline != "true" && -z $inventory ]]; then + inventory=$(tb_inventory) || inventory='{}' + fi + if [[ $offline == "true" ]] || ! jq -e --arg uid "$uid" --arg path "$path" --arg inode "$inode" \ + 'any(.devices[]?; .Uid==$uid and .SysfsPath==$path and .inode==$inode and .Stored and (.Key | IN("have","new")))' <<< "$inventory" >/dev/null; then + # An active keyless dock cannot receive a key without disconnecting it. + # Preserve input/storage and finish installation with setup deferred. + install -d -m 755 "${TB_PENDING%/*}" || return 1 + install -m 644 /dev/null "$TB_PENDING" || return 1 + printf 'Thunderbolt protection is deferred: safely disconnect accessories without saved secure keys, keep independent input available, then run Setup > Security > Thunderbolt.\n' >&2 + return 2 + fi + done +} + +tb_prepare() { + local fresh=${1:-false} state='{}' trusted mode + if [[ -f $TB_STATE ]]; then + state=$(tb_state) || return 1 + fi + if [[ ! -f $TB_STATE || -f $TB_PENDING || $fresh == "true" ]] || jq -e '.enrollment_pending // false' <<< "$state" >/dev/null; then + trusted=$(tb_capture) || return 1 + mode=$(jq -r '.original_authmode // empty' <<< "$state") || return 1 + [[ -n $mode ]] || mode=$(tb_config_authmode "$TB_CONFIG") || return 1 + [[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; } + state=$(jq -c --arg mode "$mode" --argjson trusted "$trusted" \ + 'del(.enrollment_pending) | . + {version:1,enabled:true,trusted:$trusted,original_authmode:$mode}' <<< "$state") || return 1 + else + mode=$(jq -r .original_authmode <<< "$state") || return 1 + [[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; } + state=$(jq -c '.enabled=true' <<< "$state") || return 1 + fi + install -d -m 700 "${TB_STATE%/*}" || return 1 + tb_json_write "$TB_STATE" <<< "$state" || return 1 + tb_config_write "$TB_CONFIG" disabled || return 1 + install -d -m 755 "${TB_MARKER%/*}" || return 1 + install -m 644 /dev/null "$TB_MARKER" +} + +tb_boot_saved() { + jq -c '{policies:(.stored | map({key:.Uid,value:.Policy}) | from_entries), + acls:(.domains | map({key:.Uid,value:.BootACL}) | from_entries)}' +} + +tb_boot_change() { + local enabled=$1 state inventory saved after desired backup="${TB_STATE%/*}/boot-recovery.json" + state=$(tb_state) || return 1 + jq -e .enabled <<< "$state" >/dev/null || { tb_fail 'Enable Thunderbolt device authorization first'; return 1; } + if [[ $enabled == "false" ]] && ! jq -e .boot_protection <<< "$state" >/dev/null; then + return 0 + fi + [[ ! -e $backup ]] || { tb_fail 'Recover the previous firmware boot-access change before retrying'; return 1; } + inventory=$(tb_read_inventory) || return 1 + jq -e '.domains | length > 0 and all(.[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || { + tb_fail 'Connect every Thunderbolt controller before changing firmware boot access'; return 1; + } + saved=$(tb_boot_saved <<< "$inventory" | jq -c --argjson state "$state" '. + {state:$state}') || return 1 + if [[ $enabled == "true" ]]; then + jq -e 'all(.domains[]; (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length > 0))' <<< "$inventory" >/dev/null || { + tb_fail 'This controller has no supported firmware boot allowlist. Disable Thunderbolt pre-boot/PCIe boot support in firmware settings; Omarchy cannot verify that setting.'; return 1; + } + desired=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries), + acls:(.domains | map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1 + after=$(jq -c --argjson saved "$saved" --argjson devices "$(jq -c .devices <<< "$inventory")" ' + if .boot_protection then . else .boot_original=($saved | {policies,acls}) end | + .boot_protection=true | .trusted += ($devices | map({key:.Uid,value:{Uid,Name,Vendor}}) | from_entries)' <<< "$state") || return 1 + else + desired=$(jq -ce .boot_original <<< "$state") || return 1 + after=$(jq -c 'del(.boot_original) | .boot_protection=false' <<< "$state") || return 1 + fi + tb_json_write "$backup" <<< "$saved" || return 1 + if tb_boot_apply "$desired" && tb_json_write "$TB_STATE" <<< "$after"; then + rm -- "$backup" + else + if tb_boot_apply "$saved" && tb_json_write "$TB_STATE" <<< "$state"; then + rm -- "$backup" || return 1 + tb_fail 'Firmware change failed; previous state restored' + else + tb_fail "Firmware change failed; recovery is incomplete. Recovery data retained at $backup" + fi + return 1 + fi +} + +tb_boot_recover() { + local backup="${TB_STATE%/*}/boot-recovery.json" saved + saved=$(jq -ce . "$backup") || return 1 + tb_boot_apply "$saved" || return 1 + jq .state <<< "$saved" | tb_json_write "$TB_STATE" || return 1 + rm -- "$backup" +} + +tb_unit_state() { + local properties + properties=$(systemctl show -p ActiveState -p UnitFileState "$1") || return 1 + jq -Rn '[inputs | split("=") | {key:.[0],value:.[1]}] | from_entries | + {active:(.ActiveState == "active"),enabled:(.UnitFileState == "enabled")}' <<< "$properties" +} + +tb_handoff_policies() { + local state=$1 + # StoreTime distinguishes a record recreated outside Omarchy. A completed + # enrollment and matching consent are both required, including offline + # accessories. Never promote somebody else's manual policy. + jq -c --argjson state "$state" '{policies:([.stored[] | + select(.Policy == "manual" and .StoreTime > 0 and + $state.enrolled[.Uid] == {Uid,Name,Vendor,StoreTime} and + $state.trusted[.Uid] == {Uid,Name,Vendor}) | + {key:.Uid,value:"auto"}] | from_entries),acls:{}}' +} + +tb_config_checkpoint() { + local backup="${TB_STATE%/*}/setup-recovery.json" path original files='{}' units='{}' unit value saved inventory data mode state + [[ ! -e $backup && ! -e ${TB_STATE%/*}/boot-recovery.json ]] || { + tb_fail 'A previous setup change needs recovery before continuing'; return 1; + } + for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do + original=null + if [[ -e $path ]]; then + data=$(base64 -w0 -- "$path") || return 1 + mode=$(stat -c '%a' -- "$path") || return 1 + original=$(jq -cn --arg data "$data" --arg mode "$mode" '{data:$data,mode:$mode}') || return 1 + fi + files=$(jq -c --arg path "$path" --argjson original "$original" '.[$path]=$original' <<< "$files") || return 1 + done + for unit in bolt.service "$TB_SERVICE"; do + value=$(tb_unit_state "$unit") || return 1 + units=$(jq -c --arg unit "$unit" --argjson value "$value" '.[$unit]=$value' <<< "$units") || return 1 + done + saved=$(jq -cn --argjson files "$files" --argjson units "$units" '{files:$files,units:$units}') || return 1 + state='{}' + if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi + # A handoff to auto also changes Bolt's firmware allowlists. Checkpoint the + # policies and ACLs even when the optional boot protection was never used. + if jq -e '.enabled and (.boot_protection or ((.enrolled // {}) | length > 0))' <<< "$state" >/dev/null; then + inventory=$(tb_read_inventory) || return 1 + jq -e 'all(.domains[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || { + tb_fail 'Reconnect every Thunderbolt controller before changing device policies'; return 1; + } + saved=$(jq -c --argjson firmware "$(tb_boot_saved <<< "$inventory")" '.firmware=$firmware' <<< "$saved") || return 1 + fi + install -d -m 700 "${TB_STATE%/*}" || return 1 + tb_json_write "$backup" <<< "$saved" +} + +tb_config_restore() { + local backup="${TB_STATE%/*}/setup-recovery.json" saved path original temporary unit + saved=$(jq -ce . "$backup") || return 1 + systemctl stop "$TB_SERVICE" bolt.service || return 1 + for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do + original=$(jq -c --arg path "$path" '.files[$path]' <<< "$saved") || return 1 + if [[ $original == "null" ]]; then + rm -f -- "$path" || return 1 + else + mkdir -p -- "${path%/*}" || return 1 + temporary=$(mktemp "${path%/*}/.restore.XXXXXX") || return 1 + if jq -r .data <<< "$original" | base64 -d > "$temporary" && + chmod "$(jq -r .mode <<< "$original")" "$temporary" && sync "$temporary" && + mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then + : + else + rm -f -- "$temporary" + return 1 + fi + fi + done + if jq -e .firmware <<< "$saved" >/dev/null; then + systemctl start bolt.service || return 1 + tb_boot_apply "$(jq -c .firmware <<< "$saved")" || return 1 + fi + rm -f -- "${TB_STATE%/*}/boot-recovery.json" || return 1 + if jq -e --arg unit "$TB_SERVICE" '.units[$unit].enabled' <<< "$saved" >/dev/null; then + systemctl enable "$TB_SERVICE" || return 1 + else + systemctl disable "$TB_SERVICE" || return 1 + fi + for unit in bolt.service "$TB_SERVICE"; do + if jq -e --arg unit "$unit" '.units[$unit].active' <<< "$saved" >/dev/null; then + systemctl start "$unit" || return 1 + else + systemctl stop "$unit" || return 1 + fi + done + rm -- "$backup" +} + +tb_transaction() { + tb_config_checkpoint || return 1 + if "$@"; then + rm -- "${TB_STATE%/*}/setup-recovery.json" + else + if tb_config_restore; then + tb_fail 'Thunderbolt setup failed; previous configuration restored' + else + tb_fail 'Thunderbolt setup failed; restoration is incomplete. Run the setup recovery command.' + fi + return 1 + fi +} + +tb_enable() { + systemctl stop "$TB_SERVICE" bolt.service || return 1 + tb_prepare "${1:-false}" || return 1 + systemctl daemon-reload || return 1 + systemctl start bolt.service || return 1 + tb_read_inventory >/dev/null || return 1 + systemctl enable --now "$TB_SERVICE" +} + +tb_disable() { + local state mode policies + systemctl stop "$TB_SERVICE" || return 1 + tb_boot_change false || return 1 + state=$(tb_state) || return 1 + mode=$(jq -er .original_authmode <<< "$state") || return 1 + policies=$(tb_read_inventory | tb_handoff_policies "$state") || return 1 + tb_boot_apply "$policies" || return 1 + systemctl stop bolt.service || return 1 + tb_config_write "$TB_CONFIG" "$mode" || return 1 + rm -f -- "$TB_MARKER" || return 1 + jq '.enabled=false | del(.enrolled)' <<< "$state" | tb_json_write "$TB_STATE" || return 1 + systemctl disable "$TB_SERVICE" || return 1 + systemctl start bolt.service || return 1 + tb_inventory | jq -e --arg mode "$mode" --argjson policies "$policies" ' + .manager.AuthMode==$mode and + (([.stored[] | {key:.Uid,value:.Policy}] | from_entries) as $actual | + all($policies.policies | to_entries[]; $actual[.key]==.value))' >/dev/null +} + +tb_cancel_pending() { + local state + if [[ -f $TB_STATE ]]; then + state=$(tb_state) || return 1 + else + state=$(jq -cn --arg mode "$(tb_config_authmode "$TB_CONFIG")" \ + '{version:1,enabled:false,trusted:{},enrollment_pending:true,original_authmode:$mode}') || return 1 + fi + install -d -m 700 "${TB_STATE%/*}" || return 1 + jq '.enabled=false | .enrollment_pending=true' <<< "$state" | tb_json_write "$TB_STATE" || return 1 + rm -- "$TB_PENDING" +} + +tb_reset_root() { + local root directory + root=$(realpath -e -- "$1") || return 1 + [[ $root != "/" && -d $root ]] || { tb_fail 'Refusing to reset the running system'; return 1; } + [[ -e $root$TB_MARKER || -e $root$TB_PENDING || -d $root${TB_STATE%/*} ]] || return 0 + # Resolve fixed paths inside the cloned root, never through an outside link. + for directory in etc etc/omarchy var var/lib var/lib/omarchy var/lib/boltd; do + [[ ! -L $root/$directory ]] || { tb_fail 'Refusing a linked factory policy directory'; return 1; } + done + systemctl --root="$root" disable "$TB_SERVICE" || return 1 + rm -f -- "$root$TB_MARKER" || return 1 + rm -f -- "$root$TB_PENDING" || return 1 + rm -rf -- "$root${TB_STATE%/*}" || return 1 + for directory in devices keys domains; do + rm -rf -- "$root/var/lib/boltd/$directory" || return 1 + done + tb_config_write "$root/var/lib/boltd/boltd.conf" enabled +} + +tb_admin() { + local action=${1:-} status=0 state + if [[ $action != "guard" ]]; then + exec 9> "$TB_LOCK" + flock -x 9 || return 1 + fi + case "$action" in + prepare|enable|owner|migrate) + # A deferred setup needs explicit consent after the accessories have + # been disconnected; a later migration must not silently finish it. + if [[ -f $TB_PENDING && $action != "enable" ]]; then return 0; fi + if [[ $action == "migrate" && -f $TB_STATE ]] && tb_state | jq -e '.enabled == false' >/dev/null; then return 0; fi + if tb_capture_ready "$(if [[ $action == "owner" ]]; then echo true; else echo false; fi)" \ + "$(if [[ $action == "prepare" ]]; then echo true; else echo false; fi)"; then + : + else + status=$? + if (( status == 2 )); then return 0; else return "$status"; fi + fi ;; + esac + case "$action" in + guard) tb_guard ;; + prepare) tb_prepare && rm -f -- "$TB_PENDING" ;; + enable) tb_transaction tb_enable && rm -f -- "$TB_PENDING" ;; + owner) tb_transaction tb_enable true && rm -f -- "$TB_PENDING" ;; + migrate) + state='{"enabled":true}' + if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi + if jq -e .enabled <<< "$state" >/dev/null; then + tb_transaction tb_enable + fi ;; + disable) + if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then + tb_cancel_pending + else + tb_transaction tb_disable + fi ;; + recover) tb_config_restore ;; + boot-enable|boot-disable|boot-recover) + systemctl stop "$TB_SERVICE" || return 1 + case "$action" in + boot-enable) tb_boot_change true || status=$? ;; + boot-disable) tb_boot_change false || status=$? ;; + boot-recover) tb_boot_recover || status=$? ;; + esac + systemctl start "$TB_SERVICE" || return 1 + return "$status" ;; + reset-root) [[ $# == 2 ]] && tb_reset_root "$2" ;; + *) tb_fail 'Unknown Thunderbolt setup action' ;; + esac +} diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 2bf1fb8a..1cf68380 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -20,6 +20,7 @@ systemctl --user enable --now \ omarchy-migrate-notify.service \ omarchy-fcitx5.service \ omarchy-crash-watch.service \ - omarchy-usb-authorization.service + omarchy-usb-authorization.service \ + omarchy-thunderbolt-authorization.service omarchy-hook-install theme-set /usr/share/owe/10-owe-sync diff --git a/manual/48-security.md b/manual/48-security.md index 6525317c..cc0b5b60 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -48,3 +48,23 @@ Be clear-eyed about this one: while it's on, anything running as your user can d The public key for all ISO signatures and Omarchy repo package is `40DFB630FF42BCFFB047046CF0134EE680CAC571` ([verify at openpgp.org](https://keys.openpgp.org/search?q=pkgs%40omarchy.org)). The `omarchy/omarchy-keyring` package contains this as well and will be used to rollout any potential updates seamlessly. You can find the signature for any ISO release by adding .sig to the URL. Like https://iso.omarchy.org/omarchy-x.x.x.iso.sig. + +## Thunderbolt device authorization + +Omarchy requires approval before connecting new Thunderbolt and USB4 PCIe accessories on controllers that support authorization. Devices connected during initial setup are trusted. New devices show a persistent notification with **Allow once**, **Always allow this device**, and **Keep blocked**. Trusted devices reconnect automatically, including while the screen is locked. IOMMU memory protection stays enabled; it does not count as consent to load a device's driver. + +On controllers in secure mode, permanent approval also saves the accessory's authentication key. If a connected accessory has no saved key during initial setup, Omarchy defers Thunderbolt protection and keeps the previous Bolt behavior so your keyboard and storage remain usable. A **Finish Thunderbolt protection setup** notification explains how to continue: keep input available independently of Thunderbolt, safely disconnect the accessories, enable protection, then reconnect and approve each one. Losing a previously saved secure key requires a new explicit approval; Omarchy does not silently create a replacement key while restoring trusted devices. + +_Setup > Security > Thunderbolt_ enables protection. _Remove > Security > Thunderbolt_ restores the previous Bolt behavior, which may automatically approve new devices when IOMMU protection is available. Re-enabling keeps your saved trust list. Omarchy never disconnects active storage when changing these settings. + +When you remove protection, devices enrolled by Omarchy for permanent approval reconnect through Bolt's automatic policy, keeping their secure keys. Devices with a manual Bolt policy from before setup retain that policy. Bolt also resumes managing firmware boot allowlists for automatically approved devices. Keep all Thunderbolt controllers connected during removal so Omarchy can restore their settings if the change fails. + +Firmware can authorize PCIe accessories before Linux runs. A controller configured with security mode `none` bypasses software approval; select user authorization or secure authorization in its firmware settings. Omarchy reports these limitations instead of claiming that the controller is protected. Existing firmware boot allowlists can also authorize devices early. USB accessories attached through a dock require separate USB authorization. + +Approval does not repair a vulnerable driver. Device names and identifiers can be forged; secure connection keys are used where Bolt and the hardware support them. + +_Setup > Security > Thunderbolt Boot_ can clear and verify supported firmware boot allowlists. It captures connected accessories for authorization after Linux starts, prevents Bolt from repopulating those allowlists from automatic device policies, and retains the previous lists for _Remove > Security > Thunderbolt Boot_. This option requires every controller to be connected and to expose a boot allowlist in `user` or `secure` mode. Other hardware needs firmware settings instead; Omarchy cannot verify those settings automatically. + +Enable this only if you can unlock and recover without any Thunderbolt accessories. Even a trusted keyboard or disk may remain unavailable until the policy service starts. Older snapshots and other operating systems can restore their own firmware permissions. A failed change restores the previous settings when possible; an incomplete restoration retains recovery data. Reconnect the original controllers and run `omarchy setup security thunderbolt-boot recover` to retry that recovery. + +If enabling or removing device authorization fails and reports incomplete restoration, run `omarchy setup security thunderbolt-authorization recover` before trying again. This restores the configuration and service state saved before that change. diff --git a/migrations/1790344599.sh b/migrations/1790344599.sh new file mode 100644 index 00000000..ee48034d --- /dev/null +++ b/migrations/1790344599.sh @@ -0,0 +1,2 @@ +echo "Require approval for new Thunderbolt accessories" +omarchy-setup-security-thunderbolt-authorization --quiet diff --git a/test/shell.d/fixtures/thunderbolt/bolt-exercise.sh b/test/shell.d/fixtures/thunderbolt/bolt-exercise.sh new file mode 100644 index 00000000..9f853698 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/bolt-exercise.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# Runs against the private real boltd created by bolt-integration.py. No policy, +# busctl, firmware reads, or writes are mocked here. +set -euo pipefail +source "$ROOT/install/helpers/thunderbolt-policy.sh" +source "$ROOT/install/helpers/thunderbolt-setup.sh" +TB_STATE="$TB_TEST_DIR/policy.json" +TB_RUNTIME="$TB_TEST_DIR/runtime" +mkdir -p "$TB_RUNTIME" +check() { jq -e "$1" "$2" >/dev/null; } +reject() { if "$@"; then echo 'Unexpected approval success' >&2; exit 1; fi; } +case "$1" in + approve) + echo '{"version":1,"enabled":true,"trusted":{}}' | tb_json_write "$TB_STATE" + cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation" + tb_snapshot > "$TB_TEST_DIR/before.json" + capture=$(tb_capture) + tb_inventory | jq -e --arg uid "$TB_TEST_UID" --argjson capture "$capture" \ + '.devices[] | select(.Uid==$uid) | $capture[$uid]=={Uid,Name,Vendor}' >/dev/null + check 'all(.devices[]; .status=="connected" and (.stored|not))' "$TB_TEST_DIR/before.json" + id=$(jq -c --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .identity' "$TB_TEST_DIR/before.json") + printf '%s\n' "$id" > "$TB_TEST_DIR/identity.json" + tb_approve "$id" true true > "$TB_TEST_DIR/after.json" + jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized" and .policy=="manual"' "$TB_TEST_DIR/after.json" >/dev/null + jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null + check 'all(.domains[0].BootACL[]; .=="")' "$TB_TEST_DIR/after.json" + inventory=$(tb_inventory) + if [[ $TB_TEST_SECURITY == "secure" ]]; then + jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.Uid==$uid) | .Stored and .Key=="have"' <<< "$inventory" >/dev/null + [[ -s $TB_TEST_KEYS/$TB_TEST_UID ]] + sha256sum "$TB_TEST_KEYS/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum" + fi + owner=$(jq -r .owner <<< "$inventory") + device=$(jq -c --arg uid "$TB_TEST_UID" '.stored[] | select(.Uid==$uid)' <<< "$inventory") + domain=$(jq -c '.domains[0]' <<< "$inventory") + device_path=$(jq -r .path <<< "$device"); domain_path=$(jq -r .path <<< "$domain") + tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"auto"' + acl=$(jq -c --arg uid "$TB_TEST_UID" '.BootACL | map("") | .[0]=$uid' <<< "$domain") + tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$acl" as + tb_boot_change true + check .boot_protection "$TB_STATE" + tb_inventory > "$TB_TEST_DIR/inventory.json" + check 'all(.domains[0].BootACL[]; .=="") and .stored[0].Policy=="manual"' "$TB_TEST_DIR/inventory.json" + tb_boot_change false + check '.boot_protection==false' "$TB_STATE" + tb_inventory > "$TB_TEST_DIR/inventory.json" + jq -e --argjson acl "$acl" '.domains[0].BootACL==$acl and .stored[0].Policy=="auto"' "$TB_TEST_DIR/inventory.json" >/dev/null + jq --arg other "$TB_TEST_OTHER" 'del(.trusted[$other])' "$TB_STATE" | tb_json_write "$TB_STATE" + tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"manual"' + tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$(jq -c 'map("")' <<< "$acl")" as + ;; + restart) + cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation" + reject tb_approve "$(cat "$TB_TEST_DIR/identity.json")" true + tb_reconcile > "$TB_TEST_DIR/after.json" + jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized"' "$TB_TEST_DIR/after.json" >/dev/null + jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null + tb_inventory | jq -e '.manager.AuthMode=="disabled"' >/dev/null + if [[ $TB_TEST_SECURITY == "secure" ]]; then + # Bolt must challenge with the originally saved key, not provision a new + # key merely because a reconnecting device advertises the same identity. + tb_inventory | jq -e --arg uid "$TB_TEST_UID" \ + '.devices[] | select(.Uid==$uid) | .Key=="have" and (.AuthFlags | contains("secure"))' >/dev/null + sha256sum --status -c "$TB_TEST_DIR/key-checksum" + fi + ;; + daemon) + TB_MARKER="$TB_TEST_DIR/enabled" TB_LOCK="$TB_TEST_DIR/root.lock" + touch "$TB_MARKER" + tb_daemon + ;; + status) + snapshot=$(tb_public_snapshot) + jq -e --arg owner "$(tb_owner)" '.owner==$owner' <<< "$snapshot" >/dev/null + printf '%s\n' "$snapshot" + ;; + once) + snapshot=$(tb_public_snapshot) + id=$(jq -c --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .identity' <<< "$snapshot") + exec 9> "$TB_TEST_DIR/root.lock" + flock -x 9 + tb_approve "$id" false > "$TB_TEST_DIR/once.json" + jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="authorized" and (.trusted|not)' "$TB_TEST_DIR/once.json" >/dev/null + ;; + *) exit 64 ;; +esac diff --git a/test/shell.d/fixtures/thunderbolt/bolt-integration.py b/test/shell.d/fixtures/thunderbolt/bolt-integration.py new file mode 100644 index 00000000..77a111bc --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/bolt-integration.py @@ -0,0 +1,185 @@ +"""Only the hardware fixture is Python: upstream Bolt uses UMockdev/GObject. + +All Omarchy behavior and assertions run in bolt-exercise.sh against real boltd, +real busctl, a private system bus, and disposable sysfs/firmware/store state. +""" +import importlib.machinery +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import time +import unittest + +root = Path(sys.argv.pop(1)) +loader = importlib.machinery.SourceFileLoader('bolt_upstream', str(Path(os.environ['BOLT_TEST_SOURCE']) / 'tests/test-integration')) +spec = importlib.util.spec_from_loader(loader.name, loader) +u = importlib.util.module_from_spec(spec) +loader.exec_module(u) + + +class ApprovalIntegration(u.BoltTest): + def exercise(self, security, numeric=False): + self.user_config(AuthMode='disabled', DefaultPolicy='manual') + _, host = self.add_domain_host(security=security, iommu='1') + path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0') + _, other = self.add_device(host, 2, 'Unknown', 'Example', authorized=0, key=None, boot='0') + if numeric: + # Linux omits these files when the peripheral has no descriptive names. + for attribute in ('device_name', 'vendor_name'): + Path(self.testbed.get_root_dir(), path.lstrip('/'), attribute).unlink() + self.daemon_start() + self.polkitd_start() + self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage']) + env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(), + 'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other, + 'TB_TEST_KEYS': str(Path(self.dbpath, 'keys')), 'TB_TEST_SECURITY': security} + command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh')] + subprocess.run(command + ['approve'], env=env, check=True) + self.daemon_stop() + self.testbed.set_attribute(path, 'authorized', '0') + if security == 'secure': + self.testbed.set_attribute(path, 'key', '\n') + self.daemon_start() + subprocess.run(command + ['restart'], env=env, check=True) + # The real Bash daemon publishes a fresh readable snapshot and reconnects + # to Bolt after a restart. No custom Omarchy D-Bus server is involved. + child = subprocess.Popen(command + ['daemon'], env=env) + try: + for _ in range(100): + result = subprocess.run(command + ['status'], env=env, capture_output=True) + if result.returncode == 0: + break + time.sleep(0.1) + else: + raise RuntimeError('Bash daemon did not publish a valid snapshot') + subprocess.run(command + ['once'], env=env, check=True) + self.daemon_stop() + self.daemon_start() + for _ in range(100): + if subprocess.run(command + ['status'], env=env, capture_output=True).returncode == 0: + break + time.sleep(0.1) + else: + raise RuntimeError('Bash daemon did not publish the new Bolt generation') + finally: + child.terminate() + child.wait(timeout=15) + self.daemon_stop() + + def test_user_approval(self): + self.exercise('user') + + def test_secure_approval(self): + self.exercise('secure') + + def test_numeric_identity(self): + self.exercise('user', numeric=True) + + def removal(self, security, boot=False, disconnected=False): + self.user_config(AuthMode='disabled') + _, host = self.add_domain_host(security=security, iommu='1') + path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0') + other_path, other = self.add_device(host, 2, 'Existing', 'Example', authorized=0, key=None, boot='0') + self.daemon_start() + self.polkitd_start() + self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage']) + env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(), + 'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other, + 'TB_TEST_STORE': self.dbpath, 'TB_TEST_SECURITY': security} + command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-removal.sh')] + service = 'omarchy-thunderbolt-authorization.service' + child = None + + def operation(action, unit): + nonlocal child + if unit == 'bolt.service': + if action == 'stop': + self.daemon_stop() + elif self.daemon is None: + self.daemon_start() + elif unit == service: + if action == 'stop' and child is not None: + child.terminate() + child.wait(timeout=15) + child = None + elif action == 'start' and child is None: + child = subprocess.Popen(['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh'), 'daemon'], env=env) + else: + raise RuntimeError('Unexpected private service: ' + unit) + marker = Path(self.rundir, unit + '.active') + if action == 'start': + marker.touch() + else: + marker.unlink(missing_ok=True) + + def run(action): + process = subprocess.Popen(command + [action], env=env) + control = Path(self.rundir, 'control') + deadline = time.monotonic() + 90 + try: + while process.poll() is None: + if time.monotonic() > deadline: + raise RuntimeError('Private setup timed out: ' + action) + if control.exists(): + operation(*control.read_text().strip().split()) + control.unlink() + time.sleep(0.05) + if process.returncode: + raise subprocess.CalledProcessError(process.returncode, command + [action]) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=15) + + def wait_for(action): + for _ in range(50): + result = subprocess.run(command + [action], env=env, capture_output=True, text=True) + if result.returncode == 0: + print(result.stdout, end='', flush=True) + return + time.sleep(0.1) + raise RuntimeError(action + ': ' + result.stdout + result.stderr) + + def unplug(): + for device_path in (path, other_path): + self.testbed.uevent(device_path, 'remove') + self.testbed.remove_device(device_path) + wait_for('disconnected') + + try: + Path(self.rundir, 'bolt.service.active').touch() + run('approve') + operation('start', service) + Path(self.rundir, service + '.enabled').touch() + if boot: + run('boot-enable') + run('rollback') + if disconnected: + unplug() + run('disable') + if not disconnected: + unplug() + for number, device_uid, name in ((1, uid, 'Dock'), (2, other, 'Existing')): + attributes = ['device_name', name, 'device', '0x23', 'vendor_name', 'Example', + 'vendor', '0x23', 'authorized', '0', 'unique_id', device_uid, 'boot', '0'] + if number == 1 and security == 'secure': + attributes += ['key', '\n'] + self.testbed.add_device('thunderbolt', '0-' + str(number), host, attributes, ['DEVTYPE', 'thunderbolt_device']) + wait_for('reconnected') + finally: + operation('stop', service) + self.daemon_stop() + + def test_user_removal(self): + self.removal('user') + + def test_secure_removal(self): + self.removal('secure', boot=True, disconnected=True) + + +names = sys.argv[1:] or ('test_user_approval', 'test_secure_approval', 'test_numeric_identity', 'test_user_removal', 'test_secure_removal') +suite = unittest.TestSuite(ApprovalIntegration(name) for name in names) +result = unittest.TextTestRunner(verbosity=2).run(suite) +sys.exit(not result.wasSuccessful()) diff --git a/test/shell.d/fixtures/thunderbolt/bolt-removal.sh b/test/shell.d/fixtures/thunderbolt/bolt-removal.sh new file mode 100644 index 00000000..e4db4975 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/bolt-removal.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# Real Bolt policy/approval/setup calls. Only systemd is bridged to the private +# daemon processes owned by bolt-integration.py; never contact host systemd. +set -euo pipefail +source "$ROOT/install/helpers/thunderbolt-policy.sh" +source "$ROOT/install/helpers/thunderbolt-setup.sh" +TB_STATE="$TB_TEST_DIR/policy.json" TB_CONFIG="$TB_TEST_STORE/boltd.conf" +TB_MARKER="$TB_TEST_DIR/enabled" TB_PENDING="$TB_TEST_DIR/pending" +TB_RUNTIME="$TB_TEST_DIR/runtime" TB_LOCK="$TB_TEST_DIR/root.lock" +mkdir -p "$TB_RUNTIME" + +service_request() { + printf '%s\n' "$*" > "$TB_TEST_DIR/control.tmp" + mv "$TB_TEST_DIR/control.tmp" "$TB_TEST_DIR/control" + for (( attempt=0; attempt<300; attempt++ )); do + [[ -f $TB_TEST_DIR/control ]] || return 0 + sleep 0.05 + done + echo "Private service operation timed out: $*" >&2 + return 1 +} +systemctl() { + local action=$1 unit + shift + printf '%s\n' "$action $*" >> "$TB_TEST_DIR/services.log" + case "$action" in + show) + unit=${!#} + if [[ -f $TB_TEST_DIR/$unit.active ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi + if [[ -f $TB_TEST_DIR/$unit.enabled ]]; then echo UnitFileState=enabled; else echo UnitFileState=disabled; fi ;; + start|stop) + for unit in "$@"; do service_request "$action" "$unit" || return 1; done ;; + enable) + unit=${!#} + touch "$TB_TEST_DIR/$unit.enabled" + if [[ $1 == "--now" ]]; then service_request start "$unit"; fi ;; + disable) + if [[ -f $TB_TEST_DIR/fail-disable ]]; then rm "$TB_TEST_DIR/fail-disable"; return 1; fi + rm -f -- "$TB_TEST_DIR/$1.enabled" ;; + daemon-reload) ;; + *) echo "Unexpected systemctl action: $action $*" >&2; return 1 ;; + esac +} +check_inventory() { tb_inventory | jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" "$1" >/dev/null; } +case "$1" in + approve) + jq -cn '{version:1,enabled:true,trusted:{},original_authmode:"enabled"}' > "$TB_STATE" + echo removal-test > "$TB_RUNTIME/generation" + touch "$TB_MARKER" + identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_UID" '.devices[]|select(.identity.Uid==$uid)|.identity') + tb_approve "$identity" true true >/dev/null + # An existing manual record, even explicitly trusted through Omarchy, + # must keep the policy its administrator chose before enrollment. + inventory=$(tb_inventory) + owner=$(jq -r .owner <<< "$inventory") + path=$(jq -r --arg uid "$TB_TEST_OTHER" '.devices[]|select(.Uid==$uid)|.path' <<< "$inventory") + tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null + tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$TB_TEST_OTHER" manual '' >/dev/null + identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_OTHER" '.devices[]|select(.identity.Uid==$uid)|.identity') + tb_approve "$identity" true true >/dev/null + jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" \ + '.enrolled[$uid].StoreTime>0 and .enrolled[$other]==null and .trusted[$other]!=null' "$TB_STATE" >/dev/null + if [[ $TB_TEST_SECURITY == "secure" ]]; then + sha256sum "$TB_TEST_STORE/keys/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum" + fi ;; + boot-enable) tb_admin boot-enable ;; + rollback) + cp "$TB_STATE" "$TB_TEST_DIR/old-policy.json" + tb_inventory | tb_boot_saved > "$TB_TEST_DIR/old-firmware.json" + touch "$TB_TEST_DIR/fail-disable" + if tb_admin disable; then echo 'Failed removal unexpectedly succeeded' >&2; exit 1; fi + cmp "$TB_STATE" "$TB_TEST_DIR/old-policy.json" + cmp <(tb_inventory | tb_boot_saved | jq -cS .) <(jq -cS . "$TB_TEST_DIR/old-firmware.json") + [[ -f $TB_MARKER && -f $TB_TEST_DIR/$TB_SERVICE.active && -f $TB_TEST_DIR/$TB_SERVICE.enabled && ! -f $TB_TEST_DIR/setup-recovery.json ]] + check_inventory '.manager.AuthMode=="disabled"' + echo 'Removal failure restored policy, firmware ACLs and approval service' ;; + disable) + tb_admin disable + [[ ! -f $TB_MARKER && ! -f $TB_TEST_DIR/$TB_SERVICE.active && ! -f $TB_TEST_DIR/$TB_SERVICE.enabled ]] + jq -e '.enabled==false and .enrolled==null' "$TB_STATE" >/dev/null + check_inventory '.manager.AuthMode=="enabled" and any(.stored[]; .Uid==$uid and .Policy=="auto") and any(.stored[]; .Uid==$other and .Policy=="manual")' + if [[ $TB_TEST_SECURITY == "secure" ]]; then sha256sum --status -c "$TB_TEST_DIR/key-checksum"; fi + echo 'Removal handed our record back to Bolt and preserved existing manual policy' ;; + disconnected) + check_inventory 'any(.stored[]; .Uid==$uid and .Status=="disconnected") and any(.stored[]; .Uid==$other and .Status=="disconnected")' ;; + reconnected) + check_inventory 'any(.devices[]; .Uid==$uid and .Policy=="auto" and (.Status|startswith("authorized"))) and any(.devices[]; .Uid==$other and .Policy=="manual" and .Status=="connected")' + if [[ $TB_TEST_SECURITY == "secure" ]]; then + check_inventory 'any(.devices[]; .Uid==$uid and .Key=="have" and (.AuthFlags|contains("secure")))' + sha256sum --status -c "$TB_TEST_DIR/key-checksum" + fi + echo "Automatic reconnect succeeded in $TB_TEST_SECURITY mode; pre-existing manual device stayed blocked" ;; + *) exit 64 ;; +esac diff --git a/test/shell.d/fixtures/thunderbolt/policy-test.sh b/test/shell.d/fixtures/thunderbolt/policy-test.sh new file mode 100644 index 00000000..de41c622 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/policy-test.sh @@ -0,0 +1,640 @@ +#!/bin/bash + +# These behavior cases were first run as the existing Python assertions against +# the Bash implementation. Fixtures replace only Bolt, systemd and desktop I/O. +set -euo pipefail +source "$ROOT/test/shell.d/base-test.sh" +source "$ROOT/install/helpers/thunderbolt-policy.sh" +source "$ROOT/install/helpers/thunderbolt-setup.sh" +source "$ROOT/install/helpers/thunderbolt-review.sh" + +fixture() { + T=$(mktemp -d) + trap 'rm -rf -- "$T"' EXIT + export HOME="$T/home" + TB_STATE="$T/policy.json" TB_CONFIG="$T/boltd.conf" TB_MARKER="$T/enabled" + TB_PENDING="$T/pending" TB_SYSFS="$T/sysfs" TB_KEYS="$T/keys" + TB_RUNTIME="$T/runtime" TB_LOCK="$T/root.lock" + mkdir -p "$TB_RUNTIME" "$HOME" "$TB_SYSFS" "$TB_KEYS" + echo generation-a > "$TB_RUNTIME/generation" + echo '{"version":1,"enabled":true,"trusted":{},"original_authmode":"enabled"}' > "$TB_STATE" + cat > "$T/inventory" <<'JSON' +{"owner":":1.5","manager":{"AuthMode":"disabled"},"domains":[{"Uid":"host","SecurityLevel":"user","IOMMU":true,"SysfsPath":"/sys/domain","path":"/domain","BootACL":["device-1",""]}],"devices":[{"Uid":"device-1","Name":"Dock","Vendor":"Vendor","Parent":"host","SysfsPath":"/sys/devices/test/0-1","ConnectTime":1,"Status":"connected","Stored":false,"Policy":"default","AuthFlags":"","path":"/devices/1","inode":"1"}]} +JSON + : > "$T/operations"; : > "$T/units"; : > "$T/notifications" + echo '{}' > "$T/capture" + tb_requests +} + +edit_inventory() { jq "$@" "$T/inventory" > "$T/next"; mv "$T/next" "$T/inventory"; } +edit_state() { jq "$@" "$TB_STATE" | tb_json_write "$TB_STATE"; } +check() { jq -e "$1" "$2" >/dev/null || fail "$1" "$(cat "$2")"; } +reject() { if ("$@") > "$T/output" 2> "$T/error"; then fail "unexpected success: $*"; fi; } +identity() { tb_snapshot | jq -c '.devices[0].identity'; } + +tb_inventory() { + [[ ! -f $T/inventory-failure ]] || { tb_fail 'inventory unavailable'; return 1; } + jq '. + {stored:[.devices[] | select(.Stored)]}' "$T/inventory" +} +tb_properties() { jq -c --arg path "$2" '(.devices + .domains)[] | select(.path==$path)' "$T/inventory"; } +tb_bus() { + local command=$1 path=$3 member=$5 value + printf '%s\n' "$member" >> "$T/operations" + case "$member" in + Authorize) + [[ ! -f $T/authorize-failure ]] || return 1 + if [[ ! -f $T/authorize-noop ]]; then edit_inventory --arg path "$path" '(.devices[] | select(.path==$path)).Status="authorized"'; fi ;; + EnrollDevice) + if [[ ! -f $T/enroll-noop ]]; then + edit_inventory '(.devices[0] | .Stored=true | .Policy="manual" | .StoreTime=100) as $d | .devices[0]=$d' + if [[ -f $TB_SYSFS/0-1/key && $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ && ! -f $T/key-store-failure ]]; then + edit_inventory '.devices[0].Key="have"' + fi + fi + if [[ -f $T/enroll-lost-reply ]]; then rm "$T/enroll-lost-reply"; return 1; fi ;; + Policy|BootACL) + if [[ $member == "BootACL" && -f $T/boot-failure ]] || [[ $member == "Policy" && ${7:-} == "auto" && -f $T/restore-failure ]]; then + tb_fail 'firmware rejected write'; return 1 + fi + [[ $member != "Policy" || ! -f $T/policy-noop ]] || return 0 + if [[ $member == "BootACL" ]]; then + shift 7 + value=$(jq -cn '$ARGS.positional' --args "$@") + else + value=$(jq -cn --arg value "$7" '$value') + fi + edit_inventory --arg path "$path" --arg key "$member" --argjson value "$value" \ + '((.devices[],.domains[]) | select(.path==$path))[$key]=$value' + if [[ $member == "Policy" ]]; then + # Real Bolt changes firmware ACLs when a stored policy changes. + edit_inventory --arg path "$path" --arg value "$7" ' + (.devices[] | select(.path==$path) | .Uid) as $uid | + .domains[].BootACL |= (if $value=="auto" and (index($uid)==null) then + (index("") // 0) as $slot | .[$slot]=$uid + elif $value!="auto" then map(if .==$uid then "" else . end) else . end)' + fi ;; + *) tb_fail "Unexpected Bolt call: $*"; return 1 ;; + esac +} +tb_capture() { [[ ! -f $T/capture-failure ]] && cat "$T/capture"; } +tb_unit_state() { jq -cn --arg unit "$1" '{active:($unit=="bolt.service"),enabled:false}'; } +systemctl() { + if [[ $1 == --root=* ]]; then command systemctl "$@"; return; fi + printf '%s\n' "$*" >> "$T/units" + if [[ -f $T/unit-failure && $* == "$(cat "$T/unit-failure")" ]]; then + [[ -f $T/repeat-failure ]] || rm "$T/unit-failure" + tb_fail 'systemd failed'; return 1 + fi + if [[ $* == "start bolt.service" && -f $TB_CONFIG ]]; then + edit_inventory --arg mode "$(tb_config_authmode "$TB_CONFIG")" '.manager.AuthMode=$mode' + if [[ ! -f $TB_MARKER && -f $T/restart-policy-failure ]]; then + rm "$T/restart-policy-failure" + edit_inventory '.devices[0].Policy="manual"' + fi + fi +} +tb_public_snapshot() { cat "$T/public"; } +tb_notify() { + echo "$1" >> "$T/notifications" + if [[ -f $T/notify-failure ]]; then rm "$T/notify-failure"; return 1; fi +} +tb_attention() { echo attention >> "$T/notifications"; } +gum() { if [[ $1 == "choose" ]]; then cat "$T/choice"; fi; } +tb_request_apply() { + jq --argjson identity "$2" --argjson permanent "$1" \ + '(.devices[] | select(.identity==$identity)) |= (.status="authorized" | .trusted=$permanent | .stored=$permanent)' "$T/public" > "$T/next" + mv "$T/next" "$T/public" + [[ ! -f $T/lost-reply ]] || { tb_fail 'reply lost'; return 1; } + cat "$T/public" +} +request() { local paths=("$TB_REQUESTS"/request-*.json); printf '%s\n' "${paths[0]}"; } +request_count() { local paths; shopt -s nullglob; paths=("$TB_REQUESTS"/request-*.json); echo "${#paths[@]}"; } +public_fixture() { tb_snapshot > "$T/public"; } +boot_fixture() { edit_inventory '.devices[0] |= (.Stored=true | .Policy="auto")'; } +review_tick() { + local path watch + path=$(request); watch=$(jq -r .watch "$path") + tb_scan "$watch" >/dev/null +} + +test_iommu_is_not_consent() { + edit_inventory '.devices[0] |= (.Stored=true | .Policy="iommu")' + tb_reconcile > "$T/result" + [[ ! -s $T/operations ]]; check '.devices[0].trusted==false' "$T/result" +} +test_once_is_not_saved() { + tb_approve "$(identity)" false > "$T/result" + check '.warnings==[]' "$T/result"; check '.trusted=={}' "$TB_STATE" + edit_inventory '.devices[0].Status="connected"' + tb_reconcile >/dev/null + [[ $(cat "$T/operations") == "Authorize" ]] +} +test_always_survives_restart() { + tb_approve "$(identity)" true >/dev/null + edit_inventory '.owner=":1.9" | .devices[0] |= (.Status="connected" | .inode="2")' + echo generation-b > "$TB_RUNTIME/generation" + tb_reconcile > "$T/result" + check '.devices[0].status=="authorized" and .devices[0].trusted' "$T/result" + check '.manager.AuthMode=="disabled"' "$T/inventory" +} +test_enrollment_lost_reply_retains_ownership() { + touch "$T/enroll-lost-reply" + reject tb_approve "$(identity)" true + check '.trusted=={} and .enrolled["device-1"].StoreTime==null' "$TB_STATE" + tb_approve "$(identity)" true >/dev/null + check '.enrolled["device-1"].StoreTime==100 and .trusted["device-1"]!=null' "$TB_STATE" +} +secure_fixture() { + mkdir -p "$TB_SYSFS/0-1" + echo 11111111-2222-3333-4444-555555555555 > "$TB_SYSFS/0-1/unique_id" + echo 0 > "$TB_SYSFS/0-1/authorized" + : > "$TB_SYSFS/0-1/key" + edit_inventory --arg path "$TB_SYSFS/0-1" --arg inode "$(stat -Lc %i "$TB_SYSFS/0-1")" \ + '.domains[0].SecurityLevel="secure" | .devices[0] |= (.Secure=true | .Key="missing" | + .Uid="11111111-2222-3333-4444-555555555555" | .SysfsPath=$path | .inode=$inode)' +} +test_secure_approval_initializes_before_trust() { + secure_fixture + tb_approve "$(identity)" true true > "$T/result" + [[ $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ ]] + check '.devices[0].trusted and .devices[0].stored' "$T/result" + cp "$TB_SYSFS/0-1/key" "$T/original-key" + tb_approve "$(identity)" true true >/dev/null + cmp "$TB_SYSFS/0-1/key" "$T/original-key" +} +test_secure_automatic_restore_cannot_initialize_keys() { + secure_fixture + jq '.devices[0] | {(.Uid):{Uid,Name,Vendor}}' "$T/inventory" > "$T/trusted" + edit_state --slurpfile trusted "$T/trusted" '.trusted=$trusted[0]' + tb_reconcile > "$T/result" + check '.devices[0].trusted==false' "$T/result" + [[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]] + reject tb_approve "$(identity)" true + [[ ! -s $T/operations ]] +} +test_secure_missing_saved_key_cannot_claim_success() { + secure_fixture + touch "$T/key-store-failure" + reject tb_approve "$(identity)" true true + check '.trusted=={}' "$TB_STATE" + tb_snapshot > "$T/result"; check '.devices[0].trusted==false' "$T/result" +} +test_secure_key_failure_does_not_authorize() { + secure_fixture + openssl() { return 1; } + reject tb_approve "$(identity)" true true + [[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]] + unset -f openssl + echo invalid > "$TB_SYSFS/0-1/key" + reject tb_approve "$(identity)" true true + [[ ! -s $T/operations ]] +} +test_authorized_keyless_device_requires_explicit_reconnect() { + secure_fixture + edit_inventory '.devices[0].Status="authorized"' + echo 1 > "$TB_SYSFS/0-1/authorized" + reject tb_approve "$(identity)" true true + rg -q 'disconnect, reconnect' "$T/error" + [[ ! -s $TB_SYSFS/0-1/key ]]; check '.trusted=={}' "$TB_STATE" + edit_inventory '.devices[0].Status="connected"' + tb_reconcile >/dev/null + [[ $(cat "$T/operations") == "EnrollDevice" ]] +} +test_stale_identity_rejected() { + local id field + id=$(identity) + for field in inode Name ConnectTime; do + cp "$T/inventory" "$T/original" + edit_inventory --arg field "$field" '.devices[0][$field]="replacement"' + reject tb_approve "$id" true + mv "$T/original" "$T/inventory" + done + edit_inventory '.owner=":1.9"'; reject tb_approve "$id" true + [[ ! -s $T/operations ]] +} +test_false_success_rejected() { + local id; id=$(identity) + touch "$T/authorize-noop"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE" + rm "$T/authorize-noop"; touch "$T/enroll-noop" + reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE" + rm "$T/enroll-noop"; tb_approve "$id" true > "$T/result" + check '.devices[0].trusted' "$T/result" + [[ $(rg -c '^Authorize$' "$T/operations") == 2 ]] +} +test_write_failure_retains_retry() { + local id; id=$(identity) + eval "$(declare -f tb_json_write | sed '1s/tb_json_write/disk_write/')" + tb_json_write() { [[ ! -f $T/disk-full ]] && disk_write "$@"; } + touch "$T/disk-full"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE" + rm "$T/disk-full"; tb_approve "$id" true > "$T/result" + check '.devices[0].trusted' "$T/result" + [[ $(rg -c '^Authorize$' "$T/operations") == 1 ]] +} +test_firmware_bypass_warns() { + edit_inventory '.domains[0].SecurityLevel="none" | .devices[0] |= (.Status="authorized" | .AuthFlags="boot")' + tb_snapshot > "$T/result"; check '.warnings|length==2' "$T/result"; [[ ! -s $T/operations ]] +} +test_global_enable_not_protected() { + edit_inventory '.manager.AuthMode="enabled"' + reject tb_snapshot; rg -q 'not active' "$T/error" + reject tb_reconcile +} +test_display_only_needs_no_approval() { + edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}' + edit_inventory '.devices[0].AuthFlags="nopcie"' + tb_reconcile > "$T/result"; check '.error==""' "$T/result"; [[ ! -s $T/operations ]] +} +test_empty_capture_preserved() { + rm "$TB_STATE"; tb_prepare + touch "$T/capture-failure"; tb_prepare + check '.trusted=={}' "$TB_STATE"; [[ $(tb_config_authmode "$TB_CONFIG") == "disabled" ]] +} +test_capture_failure_does_not_publish() { + rm "$TB_STATE"; touch "$T/capture-failure"; reject tb_prepare + [[ ! -e $TB_STATE && ! -e $TB_MARKER ]] +} +test_capture_optional_names_match_numeric_identity() { + source "$ROOT/install/helpers/thunderbolt-setup.sh" + mkdir -p "$TB_SYSFS/0-1" + echo device-1 > "$TB_SYSFS/0-1/unique_id" + echo 0x1234 > "$TB_SYSFS/0-1/device" + echo 0xab > "$TB_SYSFS/0-1/vendor" + tb_capture > "$T/result" + check '.["device-1"]=={Uid:"device-1",Name:"0x1234",Vendor:"0xab"}' "$T/result" + echo Dock > "$TB_SYSFS/0-1/device_name" + tb_capture > "$T/result"; check '.["device-1"].Name=="Dock" and .["device-1"].Vendor=="0xab"' "$T/result" + rm "$TB_SYSFS/0-1/device_name"; echo Vendor > "$TB_SYSFS/0-1/vendor_name" + tb_capture > "$T/result"; check '.["device-1"].Name=="0x1234" and .["device-1"].Vendor=="Vendor"' "$T/result" + mkdir "$TB_SYSFS/0-1/device_name" + reject tb_capture +} +test_first_activation_defers_without_touching_bolt() { + secure_fixture; rm "$TB_STATE" + printf '[config]\nAuthMode=enabled\n' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original" + tb_admin owner + [[ -f $TB_PENDING && ! -f $TB_STATE && ! -f $TB_MARKER && ! -s $T/units ]] + cmp "$TB_CONFIG" "$T/original" + rm -rf "$TB_SYSFS/0-1" + tb_admin migrate + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] + tb_admin enable + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_saved_secure_binding_allows_initial_capture() { + secure_fixture; rm "$TB_STATE" + edit_inventory '.devices[0] |= (.Stored=true | .Key="have")' + tb_admin owner + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_key_without_record_defers_initial_capture() { + secure_fixture; rm "$TB_STATE" + edit_inventory '.devices[0].Key="have"' + tb_admin owner + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] +} +test_offline_prepare_cannot_trust_installers_bolt() { + secure_fixture; rm "$TB_STATE" + edit_inventory '.devices[0] |= (.Stored=true | .Key="have")' + tb_admin prepare + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] +} +test_deferred_opt_out_retains_initial_enrollment() { + secure_fixture; rm "$TB_STATE" + tb_admin owner + tb_admin disable + [[ ! -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] + check '.enabled==false and .enrollment_pending==true' "$TB_STATE" + tb_admin migrate + [[ ! -f $TB_PENDING && ! -s $T/units ]] + tb_admin enable + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] + rm -rf "$TB_SYSFS/0-1" + echo '{"new-device":{}}' > "$T/capture" + tb_admin enable + check '.enabled and .enrollment_pending==null and .trusted=={"new-device":{}}' "$TB_STATE" + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_pending_notice_retries_without_claiming_protection() { + touch "$TB_PENDING" + local count=0 notices=0 + sleep() { count=$((count + 1)); (( count < 3 )) || exit 99; } + tb_setup_notice() { notices=$((notices + 1)); (( notices > 1 )) && echo pending >> "$T/notifications"; } + reject tb_watch + [[ $(cat "$T/notifications") == "pending" ]] + tb_status > "$T/result"; rg -q 'protection is deferred' "$T/result" +} +test_active_policy_is_not_deferred() { + secure_fixture; touch "$TB_MARKER" + tb_admin owner + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_pending_only_factory_reset_reaches_both_roots() { + local function_name root="$T/top/@factory" + local TOP_MNT="$T/top" NEXT_NAME=@next PROVISIONING_DIR=/var/lib/omarchy/provisioning LOG_FILE="$T/reset.log" swap_done=0 + mkdir -p "$root/etc/omarchy" "$root/etc/systemd/system" "$root/usr/bin" \ + "$root/usr/share/omarchy/install/provisioning" "$T/top/@" + touch "$root/etc/omarchy/thunderbolt-authorization.pending" "$root/usr/bin/omarchy-provision-owner" + chmod +x "$root/usr/bin/omarchy-provision-owner" + echo 'root:x:0:0:root:/root:/bin/bash' > "$root/etc/passwd" + cp "$ROOT/install/provisioning/omarchy-provision-owner.service" \ + "$ROOT/install/provisioning/omarchy-system-factory-reset-finish.service" "$root/usr/share/omarchy/install/provisioning/" + cp "$ROOT/etc/systemd/system/omarchy-thunderbolt-authorization.service" "$root/etc/systemd/system/" + for function_name in install_provisioning_units scrub_factory_accounts sanitize_factory_baseline stage_full_reset; do + eval "$(sed -n "/^$function_name() {$/,/^}/p" "$ROOT/bin/omarchy-system-factory-reset")" + done + log() { :; } + btrfs() { + if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then cp -a "$3" "$4" + elif [[ $1 == "property" ]]; then : + else return 1; fi + } + usermod() { [[ $1 == "--root" && $2 == "$TOP_MNT/"* && $3 == "--password" && $4 == "!" && $5 == "root" ]]; } + systemd-id128() { echo new-machine-id; } + encrypted_install() { return 1; } + rebuild_next_boot() { :; } + sync() { :; } + systemctl() { + [[ ( $1 == "--root=$TOP_MNT/@next" || $1 == "--root=$TOP_MNT/@factory" ) && $2 == "disable" && $3 == "$TB_SERVICE" ]] || return 1 + command systemctl "$@" + } + function /usr/bin/omarchy-thunderbolt-authorization-admin() { + [[ $1 == "reset-root" ]] || return 1 + printf '%s\n' "$2" >> "$T/reset-roots" + ( + source "$ROOT/install/helpers/thunderbolt-policy.sh" + source "$ROOT/install/helpers/thunderbolt-setup.sh" + tb_reset_root "$2" + ) + } + stage_full_reset + [[ $(wc -l < "$T/reset-roots") == 2 && $swap_done == 1 ]] + [[ ! -f $TOP_MNT/@/etc/omarchy/thunderbolt-authorization.pending && + ! -f $TOP_MNT/@factory/etc/omarchy/thunderbolt-authorization.pending ]] + [[ -f $TOP_MNT/@/var/lib/omarchy/provisioning/pending ]] +} +test_config_preserves_settings() { + rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG" + tb_prepare; rg -q '^DefaultPolicy=manual$' "$TB_CONFIG" + check '.original_authmode=="enabled"' "$TB_STATE" +} +test_fresh_owner_replaces_trust() { + rm "$TB_STATE"; echo '{"old":{}}' > "$T/capture"; tb_prepare + echo '{"new":{}}' > "$T/capture"; tb_prepare true + check '.trusted=={"new":{}} and .original_authmode=="enabled"' "$TB_STATE" +} +test_enable_rollback() { + rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG" + cp "$TB_CONFIG" "$T/original"; echo 'start bolt.service' > "$T/unit-failure" + reject tb_transaction tb_enable + cmp "$TB_CONFIG" "$T/original"; [[ ! -e $TB_STATE && ! -e $TB_MARKER && ! -e $T/setup-recovery.json ]] + [[ $(tail -2 "$T/units") == $'start bolt.service\nstop omarchy-thunderbolt-authorization.service' ]] +} +test_failed_restore_keeps_checkpoint() { + rm "$TB_STATE"; echo '[config]' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original" + echo 'stop omarchy-thunderbolt-authorization.service bolt.service' > "$T/unit-failure"; touch "$T/repeat-failure" + reject tb_transaction tb_enable; rg -q 'restoration is incomplete' "$T/error" + [[ -f $T/setup-recovery.json ]]; reject tb_transaction tb_enable; rg -q 'needs recovery' "$T/error" + echo partial > "$TB_CONFIG"; rm "$T/unit-failure" + tb_config_restore; cmp "$TB_CONFIG" "$T/original" +} +test_disable_restores_matching_boot_state() { + boot_fixture; tb_prepare; tb_boot_change true; cp "$TB_STATE" "$T/original" + echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure" + reject tb_transaction tb_disable + cmp "$TB_STATE" "$T/original"; [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] + check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory" +} +test_disable_hands_off_only_our_records() { + tb_prepare + tb_approve "$(identity)" true >/dev/null + edit_inventory '.devices += [(.devices[0] | .Uid="existing" | .path="/devices/2" | .Policy="manual")]' + tb_approve "$(tb_snapshot | jq -c '.devices[1].identity')" true >/dev/null + tb_admin disable + check '.enabled==false and .enrolled==null and (.trusted|length)==2' "$TB_STATE" + check '.manager.AuthMode=="enabled" and .devices[0].Policy=="auto" and .devices[1].Policy=="manual"' "$T/inventory" + [[ ! -f $TB_MARKER && ! -f $T/setup-recovery.json ]] + # After a completed handoff, an administrator may choose manual themselves. + edit_inventory '.devices[0].Policy="manual"' + tb_admin enable + tb_admin disable + check 'all(.devices[]; .Policy=="manual")' "$T/inventory" +} +test_disable_hands_off_disconnected_device() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.devices[0].Status="disconnected"' + tb_admin disable + check '.devices[0].Policy=="auto"' "$T/inventory" +} +test_disable_preserves_recreated_manual_record() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.devices[0].StoreTime=200' + tb_admin disable + check '.devices[0].Policy=="manual"' "$T/inventory" +} +test_disable_preserves_original_disabled_mode() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_state '.original_authmode="disabled"' + tb_admin disable + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="auto"' "$T/inventory" +} +test_disable_handoff_after_boot_protection() { + tb_prepare; tb_boot_change true + tb_approve "$(identity)" true >/dev/null + tb_admin disable + check '.enabled==false and .boot_protection==false' "$TB_STATE" + check '.devices[0].Policy=="auto" and (.domains[0].BootACL|index("device-1"))!=null' "$T/inventory" +} +test_disable_handoff_rolls_back_policies_and_acl() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.domains[0].BootACL=["unrelated",""]' + cp "$TB_STATE" "$T/original" + echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure" + reject tb_admin disable + cmp "$TB_STATE" "$T/original" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["unrelated",""]' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_handoff_failure_keeps_protection() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + tb_unit_state() { echo '{"active":true,"enabled":true}'; } + touch "$T/restore-failure" + reject tb_admin disable + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] + ! rg -q '^disable ' "$T/units" + [[ $(tail -1 "$T/units") == "start omarchy-thunderbolt-authorization.service" ]] +} +test_disable_handoff_verifies_policy_writes() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + touch "$T/policy-noop" + reject tb_admin disable + rg -q 'did not retain the requested Policy' "$T/error" + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_handoff_verifies_policy_after_restart() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + touch "$T/restart-policy-failure" + reject tb_admin disable + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_handoff_incomplete_rollback_is_recoverable() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure" + touch "$T/boot-failure" + reject tb_admin disable + rg -q 'restoration is incomplete' "$T/error" + [[ -f $T/setup-recovery.json ]] + rm "$T/boot-failure" + tb_admin recover + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["device-1",""]' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_offline_controller_is_unchanged() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.domains[0].SysfsPath=""' + reject tb_admin disable + rg -q 'Reconnect every Thunderbolt controller' "$T/error" + check '.enabled' "$TB_STATE" + [[ ! -s $T/units && ! -f $T/setup-recovery.json ]] +} +test_boot_policy_reconnection_and_restore() { + boot_fixture; tb_boot_change true + check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory" + check '.boot_protection and .trusted["device-1"]!=null' "$TB_STATE" + edit_inventory '.devices[0].Policy="auto" | .domains[0].BootACL=["device-1",""]' + tb_snapshot > "$T/result"; check '.warnings|length>0' "$T/result" + tb_reconcile > "$T/result"; check '.error==""' "$T/result" + check '.domains[0].BootACL==["",""]' "$T/inventory" + tb_boot_change false + check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1",""]' "$T/inventory" +} +test_boot_change_rollback() { + boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]' + cp "$TB_STATE" "$T/original"; touch "$T/boot-failure" + reject tb_boot_change true + check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1","other"]' "$T/inventory" + cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]] +} +test_boot_recovery_retained() { + boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]' + cp "$TB_STATE" "$T/original"; touch "$T/boot-failure" "$T/restore-failure" + reject tb_boot_change true; rg -q 'recovery is incomplete' "$T/error"; [[ -f $T/boot-recovery.json ]] + rm "$T/boot-failure" "$T/restore-failure"; tb_boot_recover + cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]] +} +test_unsupported_boot_cannot_succeed() { + boot_fixture; cp "$T/inventory" "$T/original" + local expression + for expression in '.SysfsPath=""' '.SecurityLevel="none"' '.BootACL=[]'; do + cp "$T/original" "$T/inventory"; edit_inventory ".domains[0] |= ($expression)" + reject tb_boot_change true + done + [[ ! -s $T/operations ]] +} +test_install_defers_owner() { + install() { :; } + function /usr/bin/omarchy-thunderbolt-authorization-admin() { echo "helper $*"; } + export OMARCHY_PATH=$ROOT OMARCHY_INSTALL_USER='' + source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output" + ! rg -q helper "$T/output"; [[ ! -s $T/units ]] + OMARCHY_INSTALL_USER=lab + source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output" + rg -q 'helper prepare' "$T/output"; rg -q '^enable omarchy-thunderbolt-authorization.service$' "$T/units" +} +test_offline_reset_scrubs_private_state() { + source "$ROOT/install/helpers/thunderbolt-policy.sh" + local root="$T/clone" unit="$TB_SERVICE" + mkdir -p "$root/etc/systemd/system" "$root${TB_STATE%/*}" "$root${TB_MARKER%/*}" + cp "$ROOT/etc/systemd/system/$unit" "$root/etc/systemd/system/" + command systemctl --root="$root" enable "$unit" >/dev/null 2>&1 + touch "$root$TB_MARKER" "$root$TB_STATE" "$root${TB_STATE%/*}/setup-recovery.json" + local kind + for kind in devices keys domains; do mkdir -p "$root/var/lib/boltd/$kind"; touch "$root/var/lib/boltd/$kind/old"; done + tb_reset_root "$root" + [[ ! -e $root$TB_MARKER && ! -e $root${TB_STATE%/*} && ! -e $root/etc/systemd/system/multi-user.target.wants/$unit ]] + [[ $(ls "$root/var/lib/boltd") == "boltd.conf" && $(tb_config_authmode "$root/var/lib/boltd/boltd.conf") == "enabled" ]] +} +test_live_reset_refused() { reject tb_reset_root /; } +test_trusted_reconnect_no_alert() { + edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}' + public_fixture; tb_scan watch1 >/dev/null; [[ ! -s $T/notifications ]] +} +test_unavailable_warns_once() { + tb_scan() { return 1; } + sleep() { count=$((count+1)); ((count<4)) || exit 99; } + local count=0 + reject tb_watch + [[ $(cat "$T/notifications") == "attention" ]] +} +test_failed_delivery_retries_other_devices() { + edit_inventory '.devices += [(.devices[0] | .Uid="device-2")]'; public_fixture; touch "$T/notify-failure" + tb_scan watch1 >/dev/null; [[ $(request_count) == 1 ]] + tb_scan watch1 >/dev/null; [[ $(request_count) == 2 && $(wc -l < "$T/notifications") == 3 ]] +} +test_restarts_restore_requests() { + public_fixture; tb_scan watch1 >/dev/null; local old; old=$(request) + tb_scan watch1 >/dev/null; [[ $(wc -l < "$T/notifications") == 1 ]] + edit_inventory '.owner=":1.9"'; public_fixture + tb_scan watch1 >/dev/null; [[ $(request) != "$old" ]] + tb_scan watch2 >/dev/null; [[ $(request_count) == 1 && $(wc -l < "$T/notifications") == 3 ]] +} +test_inventory_failure_preserves_request() { + public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request) + rm "$T/public"; reject tb_scan watch1; [[ -f $path ]] +} +test_replacement_during_dialog_rejected() { + public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request) + gum() { + if [[ $1 == "choose" ]]; then + jq '.devices[0].identity.Uid="replacement"' "$T/public" > "$T/next"; mv "$T/next" "$T/public" + echo 'Always allow this device' + fi + } + reject tb_review "${path##*/}" # A filename is not a valid token. + reject tb_review "$(basename "$path" .json)" + check '.devices[0].status=="connected"' "$T/public" +} +test_lost_reply_keeps_approval_intent() { + public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request) + echo 'Always allow this device' > "$T/choice"; touch "$T/lost-reply" + sleep() { review_tick; } + reject tb_review "$(basename "$path" .json)" + check '.approval=="Always allow this device"' "$path" + tb_scan watch1 >/dev/null; [[ -f $path && $(wc -l < "$T/notifications") == 2 ]] + tb_scan watch2 >/dev/null; [[ ! -f $path && $(wc -l < "$T/notifications") == 3 ]]; path=$(request) + rm "$T/lost-reply"; gum() { fail 'Saved approval must not ask again'; } + tb_review "$(basename "$path" .json)" >/dev/null + [[ ! -f $path ]] +} +test_disconnect_and_untrusted_text() { + edit_inventory '.devices[0].Name="$(touch /tmp/untrusted)\u001b[2J"' + public_fixture; tb_scan watch1 >/dev/null + [[ $(basename "$(request)") =~ ^request-[0-9a-f]{64}.json$ ]] + [[ $(jq '.devices[0].Name' "$T/inventory" | tb_safe_text) != *$'\e'* ]] + edit_inventory '.devices=[]'; public_fixture; tb_scan watch1 >/dev/null; [[ $(request_count) == 0 ]] +} +test_snapshot_freshness_and_disabled_state() { + # Exercise the real public reader, including freshness after daemon failure. + source "$ROOT/install/helpers/thunderbolt-policy.sh" + TB_RUNTIME="$T/runtime" + jq -cn --argjson now "$(date +%s)" '{available:true,time:$now,generation:"generation-a"}' > "$TB_RUNTIME/snapshot.json" + tb_public_snapshot >/dev/null + jq '.time-=30' "$TB_RUNTIME/snapshot.json" > "$T/next"; mv "$T/next" "$TB_RUNTIME/snapshot.json" + reject tb_public_snapshot + echo generation-b > "$TB_RUNTIME/generation"; reject tb_public_snapshot +} +test_invalid_state_not_skipped_by_migration() { + echo '{"enabled":false}' > "$TB_STATE"; reject tb_admin migrate + [[ ! -s $T/units ]] +} + +for case in $(declare -F | awk '$3 ~ /^test_/ {print $3}'); do + (fixture; "$case") + pass "Thunderbolt ${case#test_}" +done diff --git a/test/shell.d/fixtures/thunderbolt/startup-test.sh b/test/shell.d/fixtures/thunderbolt/startup-test.sh new file mode 100644 index 00000000..4fc71f51 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/startup-test.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail +source "$ROOT/test/shell.d/base-test.sh" +fixture=$(mktemp -d) +trap 'rm -rf -- "$fixture"' EXIT +for name in admin approve daemon; do + # Map the security library and stop before service work, including when the + # suite itself runs as root. Startup checks remain the production code. + sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \ + -e 's|source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh|exit 126|' \ + "$ROOT/bin/omarchy-thunderbolt-authorization-$name" > "$fixture/entry" + chmod 755 "$fixture/entry" + printf 'set -p\n' > "$fixture/decoy" + if BASH_ENV="$fixture/decoy" /usr/bin/bash "$fixture/entry" -p; then + fail "$name accepts ordinary Bash with a decoy -p" + fi + printf 'touch "%s"\n' "$fixture/injected" > "$fixture/startup" + # The entrypoint must suppress startup files and exported functions before + # checking EUID or reaching the fixture's service-work boundary. + if BASH_ENV="$fixture/startup" "$fixture/entry" >/dev/null 2>&1; then + fail "$name passed the fixture service-work boundary" + fi + [[ ! -e $fixture/injected ]] || fail "$name executed BASH_ENV" + if /usr/bin/env 'BASH_FUNC_source%%=() { echo injected > "$TB_TEST_INJECTED"; }' \ + TB_TEST_INJECTED="$fixture/injected" "$fixture/entry" >/dev/null 2>&1; then + fail "$name passed the fixture boundary with an exported function" + fi + [[ ! -e $fixture/injected ]] || fail "$name imported an exported function" + pass "Thunderbolt $name rejects unsafe Bash startup" +done diff --git a/test/shell.d/thunderbolt-authorization-test.sh b/test/shell.d/thunderbolt-authorization-test.sh new file mode 100755 index 00000000..c10a84cf --- /dev/null +++ b/test/shell.d/thunderbolt-authorization-test.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +bash "$ROOT/test/shell.d/fixtures/thunderbolt/policy-test.sh" +bash "$ROOT/test/shell.d/fixtures/thunderbolt/startup-test.sh" + +if [[ -n ${BOLT_TEST_SOURCE:-} ]]; then + /usr/bin/python3 "$ROOT/test/shell.d/fixtures/thunderbolt/bolt-integration.py" "$ROOT" +else + skip "real boltd integration needs BOLT_TEST_SOURCE and an UMockdev environment" +fi From c3cdcb1e826a5655f87eeb787a5b28ad6e5c892b Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 29 Sep 2026 10:16:56 -0600 Subject: [PATCH 16/16] Keep USB approvals portable and preserve approval prompts --- bin/omarchy-remove-security-usb-authorization | 21 +- bin/omarchy-setup-security-usb-authorization | 84 +++++++- bin/omarchy-usb-authorization-approve | 25 +++ bin/omarchy-usb-authorization-review | 12 +- default/polkit/org.omarchy.usb.policy | 14 ++ .../user/omarchy-usb-authorization.service | 1 - etc/polkit-1/rules.d/40-omarchy-usb.rules | 6 + install/config/usb-authorization.sh | 2 + install/helpers/usb-authorization-policy.sh | 95 +++++++++ install/helpers/usb-authorization.sh | 28 ++- manual/48-security.md | 4 + migrations/1790608617.sh | 7 + .../fixtures/usb-authorization/match.cpp | 57 ++++++ test/shell.d/usb-authorization-policy-test.sh | 47 +++++ test/shell.d/usb-authorization-test.sh | 184 ++++++++++++++++-- 15 files changed, 535 insertions(+), 52 deletions(-) create mode 100755 bin/omarchy-usb-authorization-approve create mode 100644 default/polkit/org.omarchy.usb.policy create mode 100644 etc/polkit-1/rules.d/40-omarchy-usb.rules create mode 100644 install/helpers/usb-authorization-policy.sh create mode 100644 migrations/1790608617.sh create mode 100644 test/shell.d/fixtures/usb-authorization/match.cpp create mode 100755 test/shell.d/usb-authorization-policy-test.sh diff --git a/bin/omarchy-remove-security-usb-authorization b/bin/omarchy-remove-security-usb-authorization index ba77a0f0..b3961391 100755 --- a/bin/omarchy-remove-security-usb-authorization +++ b/bin/omarchy-remove-security-usb-authorization @@ -60,19 +60,22 @@ fi # fails, the command stops here and leaves the complete policy enabled. sudo omarchy-usb-authorization-boot disable +if omarchy-pkg-present usbguard; then + # Keep the watcher and its IPC access until blocking has actually stopped. + # In particular, a failed stop must not strand a running daemon without UI. + sudo systemctl disable --now usbguard.service + if systemctl is-active --quiet usbguard.service || systemctl is-enabled --quiet usbguard.service; then + echo "USBGuard is still active or enabled; approval prompts have been preserved." >&2 + exit 1 + fi + sudo omarchy-usb-authorization-restore-default + sudo usbguard remove-user "$USER" +fi + systemctl --user disable --now omarchy-usb-authorization.service 2>/dev/null || true rm -f "$unit" systemctl --user daemon-reload -if omarchy-pkg-present usbguard; then - sudo usbguard remove-user "$USER" 2>/dev/null || true -fi - -if omarchy-pkg-present usbguard; then - sudo systemctl disable --now usbguard.service 2>/dev/null || true - sudo omarchy-usb-authorization-restore-default -fi - if [[ -d $state_dir/requests ]]; then find "$state_dir/requests" -mindepth 1 -maxdepth 1 -type f -delete rmdir "$state_dir/requests" 2>/dev/null || true diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization index c059b0fb..ee68641d 100755 --- a/bin/omarchy-setup-security-usb-authorization +++ b/bin/omarchy-setup-security-usb-authorization @@ -1,20 +1,22 @@ #!/bin/bash # omarchy:summary=Ask before new USB devices can access the system -# omarchy:args=[--boot] [--yes] +# omarchy:args=[--boot] [--yes] [--review-existing] # omarchy:requires-sudo=true set -euo pipefail assume_yes=0 from_boot=0 +review_existing=0 while (( $# > 0 )); do case "$1" in --yes) assume_yes=1 ;; --boot) from_boot=1 ;; + --review-existing) review_existing=1 ;; -h | --help) - echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes]" + echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes] [--review-existing]" echo echo "Trusts USB devices that are connected now, then blocks new devices" echo "until you approve them from an Omarchy notification." @@ -25,6 +27,9 @@ while (( $# > 0 )); do echo "Older snapshots without USBGuard keep all USB disabled after startup," echo "including keyboards, network adapters, and storage. Use --boot only if" echo "disk unlock and recovery work without any USB devices." + echo + echo "--review-existing reviews connected devices with older port-bound approvals." + echo "Each portable approval requires confirmation; existing rules are preserved." exit 0 ;; *) @@ -43,6 +48,58 @@ daemon_config=/etc/usbguard/usbguard-daemon.conf source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" +if (( review_existing )); then + # Older Omarchy rules have no per-rule ownership marker. They cannot safely + # be distinguished from intentionally restricted administrator rules. + (( ! assume_yes && ! from_boot )) || { echo "Review requires individual confirmation; do not combine it with --yes or --boot." >&2; exit 2; } + policy=$(usbguard list-rules) + declare -A reviewed=() + # Keep stdin attached to the terminal for gum (and any sudo prompt). + mapfile -t policy_lines <<<"$policy" + for line in "${policy_lines[@]}"; do + if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then + rule=${line#*: } + [[ $rule == allow\ * ]] || continue + portable=$(usb_authorization_portable_rule "$rule") || continue + [[ ${portable% label *} != "$rule" ]] || continue + devices=$(usbguard list-devices "$rule") + mapfile -t candidates <<<"$devices" + for device in "${candidates[@]}"; do + [[ $device =~ ^([0-9]+):[[:space:]](allow[[:space:]].*)$ ]] || continue + id=${BASH_REMATCH[1]} current=${BASH_REMATCH[2]} + [[ ! -v reviewed[$id] ]] || continue + reviewed[$id]=1 + echo + printf '%s\n' "$current" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' + echo "This device has a location-bound approval. Details are supplied by the device." + if gum confirm "Trust this device on any USB port or hub?"; then + sudo /usr/bin/omarchy-usb-authorization-approve "$id" "$current" + fi + done + fi + done + echo "Review complete. Disconnected devices can be approved when reconnected." + exit 0 +fi + +# The watcher runs in the session's environment, which may differ from a +# command invoked in a checkout. Fail before enabling blocking on mismatched +# or incomplete installations, including dangling dev-link unit symlinks. +session_environment=$(systemctl --user show-environment) +grep -Fqx -- "OMARCHY_PATH=$OMARCHY_PATH" <<<"$session_environment" || { + echo "The session and this command use different Omarchy installations. Update the session before enabling USB protection." >&2 + exit 1 +} +for required in "$unit_source" /usr/share/omarchy/install/helpers/usb-authorization-policy.sh; do + [[ -r $required ]] || { echo "USB authorization installation is incomplete: $required" >&2; exit 1; } +done +for required in "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch" \ + "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" \ + "$OMARCHY_PATH/bin/omarchy-usb-authorization-review" \ + /usr/bin/omarchy-usb-authorization-approve; do + [[ -r $required && -x $required ]] || { echo "USB authorization command is unavailable: $required" >&2; exit 1; } +done + if (( ! assume_yes )); then if (( from_boot )); then gum style --foreground 212 --bold "USB authorization from boot" @@ -93,19 +150,27 @@ sudo usbguard add-user "$USER" \ --policy=list \ --exceptions=listen +sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy + +# Install a real file: a link into a checkout can become dangling on a branch +# switch. Start the waiting watcher before USBGuard can block new devices. +unit_temporary=$(mktemp "${unit}.XXXXXXXXXX") +install -m644 "$unit_source" "$unit_temporary" +mv -fT "$unit_temporary" "$unit" +systemctl --user daemon-reload +systemctl --user enable omarchy-usb-authorization.service +systemctl --user restart omarchy-usb-authorization.service +systemctl --user is-active --quiet omarchy-usb-authorization.service + sudo systemctl enable usbguard.service # add-user ACL changes are loaded only when the daemon starts, so restart an # existing service as well as starting a fresh one. sudo systemctl restart usbguard.service - -ln -sfn "$unit_source" "$unit" -systemctl --user daemon-reload -systemctl --user enable --now omarchy-usb-authorization.service +systemctl is-active --quiet usbguard.service +systemctl --user is-active --quiet omarchy-usb-authorization.service if (( from_boot )); then - # Take the trust snapshot only after USBGuard is enforcing the policy. Every - # ID is resolved by the daemon, and no device-controlled descriptor text is - # passed to a privileged command. + # The fixed helper independently verifies each full live device snapshot. usb_authorization_trust_present_devices sudo omarchy-usb-authorization-boot enable fi @@ -113,6 +178,7 @@ fi echo echo "USB device authorization is enabled." echo "Devices connected during setup remain trusted; new devices require approval." +echo "To review older port-bound approvals, run: omarchy-setup-security-usb-authorization --review-existing" if (( from_boot )); then echo "Boot-time authorization is enabled and takes effect after reboot." fi diff --git a/bin/omarchy-usb-authorization-approve b/bin/omarchy-usb-authorization-approve new file mode 100755 index 00000000..99ecc640 --- /dev/null +++ b/bin/omarchy-usb-authorization-approve @@ -0,0 +1,25 @@ +#!/bin/bash -p + +# omarchy:hidden=true +# omarchy:summary=Save portable trust for one verified USB device + +# This narrow Polkit boundary accepts a live device snapshot, never a policy or +# file path. Privileged Bash suppresses startup code from the caller's environment. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset USBGUARD_DAEMON_CONF USBGUARD_DEBUG +source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh + +(( $# == 2 )) || exit 64 +# Omarchy persists to this file. Refuse custom daemon storage rather than +# mistaking a stale copy of a rule for a successful persistent decision. +rules_file=$(awk -F= '/^[[:space:]]*RuleFile[[:space:]]*=/ { value=$2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); print value; exit }' /etc/usbguard/usbguard-daemon.conf) +[[ $rules_file == "/etc/usbguard/rules.conf" ]] || exit 1 +exec 8>/run/omarchy-usb-authorization.lock +flock -x 8 +usb_authorization_permanent "$1" "$2" diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review index 630e7612..aab25309 100755 --- a/bin/omarchy-usb-authorization-review +++ b/bin/omarchy-usb-authorization-review @@ -98,13 +98,11 @@ case "$choice" in jq --arg approval "$choice" '.approval = $approval' "$request" >"$temporary" mv -f "$temporary" "$request" - if [[ $current_rule == "$expected_rule" ]]; then + if [[ $choice == "Always allow this device" ]]; then + pkexec /usr/bin/omarchy-usb-authorization-approve "$id" "$current_rule" || approval_failed + elif [[ $current_rule == "$expected_rule" ]]; then # Match the complete identity, since numeric IDs can be reassigned. - options=() - if [[ $choice == "Always allow this device" ]]; then - options+=(--permanent) - fi - usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed + usbguard allow-device "$expected_rule" || approval_failed fi # Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can @@ -115,7 +113,7 @@ case "$choice" in if [[ $choice == "Always allow this device" ]]; then # Let USBGuard match policy rules: saved rules can omit the device's port. # --show-devices indents the devices matching each policy rule. - policy=$(usbguard list-rules --show-devices) || approval_failed + policy=$(usbguard list-rules --label omarchy-usb-authorization-v1 --show-devices) || approval_failed policy_allows=0 matched=0 while IFS= read -r line; do diff --git a/default/polkit/org.omarchy.usb.policy b/default/polkit/org.omarchy.usb.policy new file mode 100644 index 00000000..1f76638e --- /dev/null +++ b/default/polkit/org.omarchy.usb.policy @@ -0,0 +1,14 @@ + + + + + Trust a connected USB accessory on any port + Authentication is required to trust this USB accessory + /usr/bin/omarchy-usb-authorization-approve + + auth_admin + auth_admin + auth_admin + + + diff --git a/default/systemd/user/omarchy-usb-authorization.service b/default/systemd/user/omarchy-usb-authorization.service index bfc27a61..6a775c8f 100644 --- a/default/systemd/user/omarchy-usb-authorization.service +++ b/default/systemd/user/omarchy-usb-authorization.service @@ -8,7 +8,6 @@ ConditionEnvironment=WAYLAND_DISPLAY [Service] Type=simple -ExecCondition=/usr/bin/systemctl is-active --quiet usbguard.service ExecStart=/usr/bin/bash -lc 'exec "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch"' Restart=always RestartSec=2 diff --git a/etc/polkit-1/rules.d/40-omarchy-usb.rules b/etc/polkit-1/rules.d/40-omarchy-usb.rules new file mode 100644 index 00000000..1469681d --- /dev/null +++ b/etc/polkit-1/rules.d/40-omarchy-usb.rules @@ -0,0 +1,6 @@ +polkit.addRule(function(action, subject) { + if (action.id === "org.omarchy.usb.approve" && + subject.active && subject.local && subject.isInGroup("wheel")) { + return polkit.Result.YES; + } +}); diff --git a/install/config/usb-authorization.sh b/install/config/usb-authorization.sh index 6b0747e8..32605617 100644 --- a/install/config/usb-authorization.sh +++ b/install/config/usb-authorization.sh @@ -1,5 +1,7 @@ echo "Configure USB device authorization" +install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy + # A deferred install has only the builder's peripherals attached. The owner # needs working input before there is an account or a session for approvals. if [[ -z ${OMARCHY_INSTALL_USER:-} ]]; then diff --git a/install/helpers/usb-authorization-policy.sh b/install/helpers/usb-authorization-policy.sh new file mode 100644 index 00000000..a0d1da1e --- /dev/null +++ b/install/helpers/usb-authorization-policy.sh @@ -0,0 +1,95 @@ +# Device identity is portable; the full live rule is still used when granting +# access to the connection shown in the approval dialog. +usb_authorization_portable_rule() { + local rule="$1" token="" char quoted=0 escaped=0 index + local -a tokens=() portable=() + + # Split canonical USBGuard output without evaluating device-controlled text. + # An attribute name inside a quoted name/serial is never a rule attribute. + for (( index=0; index<${#rule}; index++ )); do + char=${rule:index:1} + if (( escaped )); then + token+="$char" + escaped=0 + elif (( quoted )) && [[ $char == '\' ]]; then + token+="$char" + escaped=1 + elif [[ $char == '"' ]]; then + token+="$char" + quoted=$((1 - quoted)) + elif (( ! quoted )) && [[ $char == " " ]]; then + if [[ -n $token ]]; then tokens+=("$token"); token=""; fi + else + token+="$char" + fi + done + (( ! quoted && ! escaped )) || return 1 + if [[ -n $token ]]; then tokens+=("$token"); fi + [[ ${tokens[0]:-} == "allow" || ${tokens[0]:-} == "block" ]] || return 1 + portable=(allow) + for (( index=1; index<${#tokens[@]}; index++ )); do + case "${tokens[index]}" in + via-port | parent-hash) + (( ++index < ${#tokens[@]} )) || return 1 + [[ ${tokens[index]} == \"*\" ]] || return 1 + ;; + label) return 1 ;; + *) portable+=("${tokens[index]}") ;; + esac + done + local IFS=" " + printf '%s label "omarchy-usb-authorization-v1"\n' "${portable[*]}" +} + +usb_authorization_permanent() { + local id="$1" expected="$2" devices current="" allowed portable policy line target new_id matches rule saved=0 + + [[ $id =~ ^[0-9]+$ && ( $expected == block\ * || $expected == allow\ * ) ]] || return 1 + (( ${#expected} <= 16384 )) || return 1 + devices=$(usbguard list-devices) || return 1 + while IFS= read -r line; do + if [[ $line == "$id: "* ]]; then current=${line#"$id: "}; fi + done <<<"$devices" + [[ $current == "$expected" ]] || return 1 + allowed="allow ${expected#* }" + portable=$(usb_authorization_portable_rule "$current") || return 1 + usbguard-rule-parser "$portable" >/dev/null || return 1 + + # Respect earlier administrator deny rules. Do not report permanent success + # when a saved allow would lose to an explicit block/reject on reconnect. + policy=$(usbguard list-rules) || return 1 + while IFS= read -r line; do + if [[ $line =~ ^[0-9]+:[[:space:]](allow|block|reject)([[:space:]]|$) ]]; then + target=${BASH_REMATCH[1]} + rule=${line#*: } + # --show-devices filters by current target, hiding deny rules for a + # temporarily allowed device. A match query compares identity instead. + matches=$(usbguard list-devices "match${rule#"$target"}") || return 1 + if grep -Fqx -- "$id: $current" <<<"$matches"; then + if [[ $target != "allow" ]]; then + echo "An administrator rule blocks this device. Review that rule before saving approval." >&2 + return 1 + fi + break + fi + fi + done <<<"$policy" + while IFS= read -r line; do + if [[ $line =~ ^([0-9]+):[[:space:]] && ${line#*: } == "$portable" ]]; then + saved=1 + fi + done <<<"$policy" + + # A retry can reuse only an exact owned rule present in both the daemon and + # its persistent file. Never remove/reorder rules around manual restrictions. + if (( ! saved )) || ! grep -Fqx -- "$portable" /etc/usbguard/rules.conf; then + new_id=$(usbguard append-rule "$portable") || return 1 + [[ $new_id =~ ^[0-9]+$ ]] || return 1 + grep -Fqx -- "$portable" /etc/usbguard/rules.conf || return 1 + fi + if [[ $current == block\ * ]]; then + usbguard allow-device "$expected" || return 1 + fi + devices=$(usbguard list-devices --allowed) || return 1 + grep -Fqx -- "$1: $allowed" <<<"$devices" +} diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh index 70e36628..a4b0606d 100644 --- a/install/helpers/usb-authorization.sh +++ b/install/helpers/usb-authorization.sh @@ -1,3 +1,5 @@ +source "${BASH_SOURCE[0]%/*}/usb-authorization-policy.sh" + usb_authorization_config_value() { local config="$1" key="$2" local awk_command=(awk) @@ -54,15 +56,24 @@ usb_authorization_require_secure_settings() { usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy || return 1 usb_authorization_require_secure_setting "$config" AuthorizedDefault none || return 1 usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false || return 1 + usb_authorization_require_secure_setting "$config" RuleFile /etc/usbguard/rules.conf || return 1 } usb_authorization_generate_policy() { - local policy="$1" + local policy="$1" generated line - if ! usbguard generate-policy >"$policy"; then + if ! generated=$(usbguard generate-policy); then echo "USBGuard could not enumerate devices; nothing was enabled." >&2 return 1 fi + : >"$policy" + while IFS= read -r line; do + if [[ $line == allow\ * ]]; then + usb_authorization_portable_rule "$line" >>"$policy" || return 1 + elif [[ -n $line ]]; then + printf '%s\n' "$line" >>"$policy" + fi + done <<<"$generated" if ! grep -q '^allow ' "$policy"; then if grep -q '^[[:space:]]*[^#[:space:]]' "$policy"; then echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 @@ -72,6 +83,7 @@ usb_authorization_generate_policy() { # Keep the file nonempty so re-enabling preserves this default-deny policy. echo '# No USB devices were present during enrollment.' >>"$policy" fi + usbguard-rule-parser -f "$policy" >/dev/null } usb_authorization_add_user() { @@ -113,16 +125,16 @@ usb_authorization_provision_owner() { } usb_authorization_trust_present_devices() { - local line id + local line id devices local count=0 - local -a devices + devices=$(usbguard list-devices) || return 1 + local -a inventory + mapfile -t inventory <<<"$devices" - mapfile -t devices < <(usbguard list-devices) - - for line in "${devices[@]}"; do + for line in "${inventory[@]}"; do if [[ $line =~ ^([0-9]+):[[:space:]] ]]; then id="${BASH_REMATCH[1]}" - usbguard allow-device --permanent "$id" + sudo /usr/bin/omarchy-usb-authorization-approve "$id" "${line#*: }" || return 1 (( ++count )) fi done diff --git a/manual/48-security.md b/manual/48-security.md index cc0b5b60..a9fe4647 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -13,6 +13,10 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked. +**Always allow this device** trusts the same device identity across USB ports and hubs, including after reboot. Changes to its identity still require approval. A hub may need its own approval before devices connected through it become available. + +Older approvals can be tied to a port or hub. Run `omarchy-setup-security-usb-authorization --review-existing` to review connected devices and confirm which should be trusted on any port. Existing rules are preserved. For a disconnected device, reconnect it and choose **Always allow this device** if prompted. + On a preinstalled machine that asks you to create your account at first boot, USB protection starts at the end of that setup. It trusts the devices connected then, so your keyboard works throughout account setup even if it differs from the installer's keyboard. You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy. diff --git a/migrations/1790608617.sh b/migrations/1790608617.sh new file mode 100644 index 00000000..7c1aa722 --- /dev/null +++ b/migrations/1790608617.sh @@ -0,0 +1,7 @@ +echo "Repair USB approval prompts and enable portable device approvals" + +# Preserve an explicit opt-out. Existing users with an active/enabled daemon +# need a working watcher even if their old checkout link has become dangling. +if systemctl is-enabled --quiet usbguard.service || systemctl is-active --quiet usbguard.service; then + omarchy-setup-security-usb-authorization --yes +fi diff --git a/test/shell.d/fixtures/usb-authorization/match.cpp b/test/shell.d/fixtures/usb-authorization/match.cpp new file mode 100644 index 00000000..5c26df69 --- /dev/null +++ b/test/shell.d/fixtures/usb-authorization/match.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include + +static std::string read(const char* path) { + std::ifstream file(path); + std::string text; + std::getline(file, text); + if (text.empty()) throw std::runtime_error("empty rule"); + return text; +} + +static std::string replace(std::string text, const std::string& from, const std::string& to) { + const auto index = text.find(from); + if (index == std::string::npos) throw std::runtime_error("missing attribute"); + text.replace(index, from.size(), to); + return text; +} + +static void check(const char* name, const usbguard::Rule& policy, const usbguard::Rule& device, bool expected) { + if (policy.appliesTo(device) != expected) throw std::runtime_error(name); + std::cout << "ok - native USBGuard: " << name << '\n'; +} + +int main(int argc, char** argv) { + if (argc != 3) return 2; + // Test the policy produced by the real Bash implementation, not a C++ copy. + const auto policy = usbguard::Rule::fromString(read(argv[1])); + const auto text = read(argv[2]); + const auto device = usbguard::Rule::fromString(text); + check("original connection", policy, device, true); + auto moved = device; + moved.setViaPort("9-9.9"); + check("another port", policy, moved, true); + moved.setParentHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + check("another hub", policy, moved, true); + auto reloaded = usbguard::Rule::fromString(policy.toString()); + check("serialized policy reload on new hub", reloaded, moved, true); + check("reconnect on original port", reloaded, device, true); + auto different = moved; + different.setHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + check("different hash denied", policy, different, false); + different = moved; + different.setSerial("different"); + check("different serial denied", policy, different, false); + different = moved; + different.setName("different"); + check("different name denied", policy, different, false); + check("different product denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046d:c53b")), false); + check("different vendor denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046e:c53a")), false); + check("different interface denied", policy, usbguard::Rule::fromString(replace(text, "03:00:00", "08:06:50")), false); + check("different connection type denied", policy, usbguard::Rule::fromString(replace(text, "with-connect-type \"unknown\"", "with-connect-type \"hotplug\"")), false); + // Existing unmarked/manual rules remain location-bound. + check("legacy rule still restricts ports and hubs", device, moved, false); +} diff --git a/test/shell.d/usb-authorization-policy-test.sh b/test/shell.d/usb-authorization-policy-test.sh new file mode 100755 index 00000000..97fc35a8 --- /dev/null +++ b/test/shell.d/usb-authorization-policy-test.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/usb-authorization-policy.sh" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT + +identity='id 046d:c53a serial "" name "USB Receiver" hash "KCUFt1MumW4Pfs/8YXWzGQB6Hsnm8qkDqFVjfY0NIBY="' +interfaces='with-interface { 03:01:01 03:01:02 03:00:00 } with-connect-type "unknown"' +location='parent-hash "m7yTNWlczwBbYn+uRP6TRsY5AceKmAZ0Et6mgy58+/o=" via-port "3-1.1.2.1"' +device="allow $identity $location $interfaces" +portable=$(usb_authorization_portable_rule "$device") +[[ $portable == "allow $identity $interfaces"' label "omarchy-usb-authorization-v1"' ]] || fail "only device location is removed" +[[ $(usb_authorization_portable_rule "${device/#allow/block}") == "$portable" ]] || fail "blocked approvals use the same portable identity" +pass "portable USB rules retain every device identity attribute" + +# A canonical escaped name cannot cause its contents to become attributes. +name='name "Receiver \" via-port \"fake\" parent-hash \"fake\" $(touch /tmp/usb-injection)"' +hostile="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $location $interfaces" +expected="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $interfaces"' label "omarchy-usb-authorization-v1"' +[[ $(usb_authorization_portable_rule "$hostile") == "$expected" ]] || fail "quoted attribute text must remain data" +if usb_authorization_portable_rule 'allow id 046d:c53a name "unterminated' >/dev/null; then fail "reject unmatched quote"; fi +if usb_authorization_portable_rule 'allow id 046d:c53a via-port' >/dev/null; then fail "reject missing location value"; fi +pass "USB portable parsing preserves escaped and hostile descriptor text" + +# Exercise the actual root entrypoint startup boundary without root work. +sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \ + -e 's|source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh|exit 126|' \ + "$ROOT/bin/omarchy-usb-authorization-approve" >"$scratch/entry" +chmod 755 "$scratch/entry" +printf 'set -p\n' >"$scratch/decoy" +if BASH_ENV="$scratch/decoy" /usr/bin/bash "$scratch/entry" -p; then fail "ordinary Bash with decoy -p is rejected"; fi +printf 'touch "%s"\n' "$scratch/injected" >"$scratch/startup" +if BASH_ENV="$scratch/startup" "$scratch/entry" >/dev/null 2>&1; then fail "startup fixture boundary"; fi +if /usr/bin/env 'BASH_FUNC_source%%=() { touch "$USB_TEST_INJECTED"; }' USB_TEST_INJECTED="$scratch/injected" "$scratch/entry" >/dev/null 2>&1; then fail "exported function fixture boundary"; fi +[[ ! -e $scratch/injected ]] || fail "privileged entrypoint executes caller startup code" +pass "USB privileged approval rejects unsafe Bash startup" + +if ! command -v g++ >/dev/null || [[ ! -f /usr/include/usbguard/Rule.hpp ]]; then + skip "native USBGuard matcher requires g++ and USBGuard development headers" + exit 0 +fi +printf '%s\n' "$device" >"$scratch/device" +printf '%s\n' "$portable" >"$scratch/policy" +g++ -std=c++17 "$ROOT/test/shell.d/fixtures/usb-authorization/match.cpp" -lusbguard -o "$scratch/match" +"$scratch/match" "$scratch/policy" "$scratch/device" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index c3d64ba3..7a10635d 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -23,6 +23,7 @@ PresentDevicePolicy=apply-policy InsertedDevicePolicy=apply-policy AuthorizedDefault=none RestoreControllerDeviceState=false +RuleFile=/etc/usbguard/rules.conf CONF cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' @@ -43,6 +44,7 @@ printf 'pkg-drop <%s>\n' "$*" >>"$CALLS" STUB cat >"$stub_bin/install" <<'STUB' #!/bin/bash +if [[ ${!#} == /usr/share/polkit-1/actions/org.omarchy.usb.policy ]]; then exit 0; fi if [[ $1 == -Dm600 && $2 == -o && $3 == root && $4 == -g && $5 == root ]]; then exec /usr/bin/install -Dm600 "$6" "$7" fi @@ -53,6 +55,17 @@ cat >"$stub_bin/systemctl" <<'STUB' printf 'systemctl <%s>\n' "$*" >>"$CALLS" if [[ $* == "show usbguard.service --property=InvocationID --value" ]]; then printf '%s\n' "${TEST_DAEMON_INVOCATION:-11111111111111111111111111111111}" +elif [[ $* == "--user show-environment" ]]; then + echo "OMARCHY_PATH=${TEST_SESSION_ROOT:-$OMARCHY_PATH}" +elif [[ $* == "--user restart omarchy-usb-authorization.service" ]]; then + [[ ${TEST_WATCHER_FAIL:-0} == 0 ]] +elif [[ $* == "restart usbguard.service" ]]; then + touch "$TEST_GUARD_ACTIVE" +elif [[ $* == "disable --now usbguard.service" ]]; then + [[ ${TEST_STOP_FAIL:-0} == 0 ]] || exit 1 + [[ ${TEST_STOP_LIES:-0} == 1 ]] || rm -f "$TEST_GUARD_ACTIVE" +elif [[ $* == "is-active --quiet usbguard.service" ]]; then + [[ -f $TEST_GUARD_ACTIVE ]] elif [[ $* == "is-enabled --quiet usbguard.service" ]]; then exit 1 fi @@ -100,11 +113,22 @@ list-devices) echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' fi ;; +append-rule) + printf 'usbguard <%s>\n' "$2" >>"$CALLS" + [[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1 + if [[ ${APPROVAL_NO_POLICY:-0} == 0 ]]; then printf '%s\n' "$2" >"$TEST_SAVED_RULE"; fi + echo 40 + ;; list-rules) - [[ ${APPROVAL_POLICY_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_ALLOWED_RULE && ${APPROVAL_POLICY_QUERY_FAIL:-0} == 1 ]]; then exit 1; fi + if [[ -n ${TEST_POLICY_LIST:-} ]]; then cat "$TEST_POLICY_LIST"; fi if [[ -f $TEST_SAVED_RULE ]]; then printf '40: %s\n' "$(<"$TEST_SAVED_RULE")" - printf '\t17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + if [[ -f $TEST_ALLOWED_RULE ]]; then + printf '\t17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + elif [[ -n ${BLOCKED_DEVICE_RULE:-} ]]; then + printf '\t17: %s\n' "$BLOCKED_DEVICE_RULE" + fi fi ;; *) @@ -140,6 +164,11 @@ elif [[ $1 == test && $2 == -s && $3 == /etc/usbguard/rules.conf ]]; then exec /usr/bin/test -s "$TEST_RULES" elif [[ $1 == install && $2 == -Dm600 && $3 == -o && $4 == root && $5 == -g && $6 == root && $8 == /etc/usbguard/rules.conf ]]; then exec /usr/bin/install -Dm600 "$7" "$TEST_RULES" +elif [[ $1 == install && ${!#} == /usr/share/polkit-1/actions/org.omarchy.usb.policy ]]; then + exit 0 +elif [[ $1 == /usr/bin/omarchy-usb-authorization-approve ]]; then + shift + exec "$USB_APPROVE_FIXTURE" "$@" elif [[ $1 == omarchy-usb-authorization-restore-default ]]; then echo 1 >"$TEST_SYSFS/usb1/authorized_default" echo 1 >"$TEST_SYSFS/1-2/authorized" @@ -165,7 +194,13 @@ choose) fi printf '%s\n' "${GUM_CHOICE:-Keep blocked}" ;; -confirm) exit 0 ;; +confirm) + if [[ ${GUM_REQUIRE_STDIN:-0} == 1 ]]; then + read -r consent + [[ $consent == yes ]] || exit 1 + fi + [[ ${GUM_DECLINE:-0} == 0 ]] + ;; esac STUB cat >"$stub_bin/omarchy-notification-send" <<'STUB' @@ -185,6 +220,16 @@ if [[ -n ${NOTIFICATION_READY_FILE:-} ]]; then [[ -e $NOTIFICATION_READY_FILE ]] fi STUB +cat >"$stub_bin/pkexec" <<'STUB' +#!/bin/bash +[[ $1 == /usr/bin/omarchy-usb-authorization-approve ]] || exit 1 +shift +exec "$USB_APPROVE_FIXTURE" "$@" +STUB +cat >"$stub_bin/usbguard-rule-parser" <<'STUB' +#!/bin/bash +exit 0 +STUB chmod +x "$stub_bin"/* export HOME="$home" @@ -196,6 +241,26 @@ export TEST_DAEMON_CONFIG="$daemon_config" export TEST_RULES="$rules" export TEST_ALLOWED_RULE="$scratch/allowed-rule" export TEST_SAVED_RULE="$scratch/saved-rule" +export TEST_GUARD_ACTIVE="$scratch/guard-active" +export USB_APPROVE_FIXTURE="$scratch/approve" +export USB_POLICY_FIXTURE="$scratch/policy.sh" +sed "s|/etc/usbguard/rules.conf|$TEST_SAVED_RULE|g" "$ROOT/install/helpers/usb-authorization-policy.sh" >"$USB_POLICY_FIXTURE" +cat >"$USB_APPROVE_FIXTURE" <<'STUB' +#!/bin/bash +set -euo pipefail +source "$USB_POLICY_FIXTURE" +# Boot inventory uses the same helper; permanent behavior is exercised below. +if [[ $1 == 4 || $1 == 5 ]]; then + printf 'boot-approve <%s> <%s>\n' "$1" "$2" >>"$CALLS" + exit 0 +fi +usb_authorization_permanent "$@" +STUB +chmod +x "$USB_APPROVE_FIXTURE" +sed -e "s|/usr/bin/omarchy-usb-authorization-approve|$USB_APPROVE_FIXTURE|g" \ + -e "s|/usr/share/omarchy/install/helpers/usb-authorization-policy.sh|$USB_POLICY_FIXTURE|g" \ + "$ROOT/bin/omarchy-setup-security-usb-authorization" >"$scratch/setup" +chmod +x "$scratch/setup" if "$ROOT/bin/omarchy-usb-authorization-restore-default" >/dev/null 2>&1; then fail "the fixed-path USB authorization restore helper requires root" @@ -210,46 +275,76 @@ fi fail "user-facing USB authorization commands do not elevate caller-selected paths" pass "USB authorization keeps privileged paths fixed" -"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-output" +"$scratch/setup" --yes >"$scratch/setup-output" -grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root" label "omarchy-usb-authorization-v1"' "$rules" || fail "USB authorization setup trusts devices present during enrollment" [[ $(stat -c %a "$rules") == 600 ]] || fail "USB authorization policy is root-private" grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || fail "USB authorization grants only the IPC access needed by the approval flow" grep -Fqx 'systemctl ' "$calls" || fail "USB authorization reloads its user ACL before the watcher starts" -[[ -L $home/.config/systemd/user/omarchy-usb-authorization.service ]] || +[[ -f $home/.config/systemd/user/omarchy-usb-authorization.service && ! -L $home/.config/systemd/user/omarchy-usb-authorization.service ]] || fail "USB authorization installs the graphical-session watcher" grep -Fqx 'pkg-add ' "$calls" || fail "USB authorization ensures USBGuard is installed" pass "USB authorization enrolls present devices before enabling default-deny" generate_count=$(grep -c '^usbguard $' "$calls") -"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-existing-output" +"$scratch/setup" --yes >"$scratch/setup-existing-output" generate_count_after=$(grep -c '^usbguard $' "$calls") (( generate_count_after == generate_count )) || fail "re-enabling preserves the existing trusted-device policy" pass "USB authorization setup preserves an existing policy" +# Failures before the watcher is ready must never start enforcement. A copied +# unit also repairs the dangling checkout link reported on the affected host. +unit="$home/.config/systemd/user/omarchy-usb-authorization.service" +rm -f "$unit" +ln -s "$scratch/deleted-checkout/service" "$unit" +"$scratch/setup" --yes >/dev/null +[[ -f $unit && ! -L $unit ]] || fail "setup repairs a dangling watcher unit" +for failure in missing-unit missing-helper session-mismatch watcher-start; do + rm -f "$TEST_GUARD_ACTIVE" + : >"$calls" + cp "$scratch/setup" "$scratch/failing-setup" + options=() + case "$failure" in + missing-unit) sed -i "s|^unit_source=.*|unit_source=$scratch/missing-unit|" "$scratch/failing-setup" ;; + missing-helper) sed -i "s|$USB_POLICY_FIXTURE|$scratch/missing-helper|g" "$scratch/failing-setup" ;; + session-mismatch) options+=(TEST_SESSION_ROOT=/missing-runtime) ;; + watcher-start) options+=(TEST_WATCHER_FAIL=1) ;; + esac + if env "${options[@]}" "$scratch/failing-setup" --yes >"$scratch/$failure.log" 2>&1; then + fail "setup must reject $failure" + fi + [[ ! -e $TEST_GUARD_ACTIVE ]] || fail "$failure starts blocking without a watcher" + ! grep -Eq 'systemctl <(enable|restart) usbguard.service>' "$calls" || fail "$failure enables enforcement" +done +"$scratch/setup" --yes >/dev/null +watch_line=$(grep -n '^systemctl <--user restart omarchy-usb-authorization.service>' "$calls" | tail -1 | cut -d: -f1) +guard_line=$(grep -n '^systemctl ' "$calls" | tail -1 | cut -d: -f1) +(( watch_line < guard_line )) || fail "watcher must start before enforcement" +pass "USB setup repairs dangling units and refuses enforcement without a working watcher" + : >"$rules" -GENERATE_EMPTY=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-output" +GENERATE_EMPTY=1 "$scratch/setup" --yes >"$scratch/setup-empty-output" [[ -s $rules ]] || fail "empty USB inventory must persist an initialized policy" ! grep -q '^allow ' "$rules" || fail "empty inventory must not add permissive rules" generate_count=$(grep -c '^usbguard $' "$calls") -"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-again-output" +"$scratch/setup" --yes >"$scratch/setup-empty-again-output" [[ $(grep -c '^usbguard $' "$calls") == "$generate_count" ]] || fail "re-enabling an empty policy must not enroll newly attached devices" : >"$rules" -if GENERATE_FAIL=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-failed-output" 2>&1; then +if GENERATE_FAIL=1 "$scratch/setup" --yes >"$scratch/setup-failed-output" 2>&1; then fail "enumeration failure must not be accepted as an empty USB inventory" fi [[ ! -s $rules ]] || fail "failed enumeration must not install a policy" pass "USB setup accepts an empty inventory while rejecting enumeration failure" : >"$calls" -"$ROOT/bin/omarchy-setup-security-usb-authorization" --boot --yes >"$scratch/setup-boot-output" -grep -Fqx 'usbguard <--permanent> <4>' "$calls" || +"$scratch/setup" --boot --yes >"$scratch/setup-boot-output" +grep -Fq 'boot-approve <4>' "$calls" || fail "boot authorization trusts the connected root controller" -grep -Fqx 'usbguard <--permanent> <5>' "$calls" || +grep -Fq 'boot-approve <5>' "$calls" || fail "boot authorization trusts the connected USB devices" grep -Fqx 'sudo ' "$calls" || fail "boot authorization uses the fixed-path boot-image helper" @@ -419,7 +514,7 @@ export OMARCHY_INSTALL_USER=tester export OMARCHY_USB_AUTHORIZATION_RULES_FILE="$rules" export OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG="$daemon_config" bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-output" -grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root" label "omarchy-usb-authorization-v1"' "$rules" || fail "fresh installation enrolls devices present during installation" grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || fail "fresh installation grants the owner narrowly scoped approval access" @@ -452,7 +547,7 @@ echo 'allow id 1234:0001 name "Builder keyboard" hash "builder"' >"$rules" owner_rule='allow id 1234:0002 name "Owner keyboard" hash "owner"' : >"$calls" GENERATED_RULE="$owner_rule" usb_authorization_provision_owner owner "$rules" "$daemon_config" -[[ $(<"$rules") == "$owner_rule" ]] || fail "owner enrollment must replace inherited builder trust" +[[ $(<"$rules") == "$owner_rule"' label "omarchy-usb-authorization-v1"' ]] || fail "owner enrollment must replace inherited builder trust" grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || fail "owner provisioning grants approval access" [[ $(tail -2 "$calls") == $'systemctl \nsystemctl ' ]] || @@ -462,7 +557,7 @@ grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy= if GENERATE_FAIL=1 usb_authorization_provision_owner owner "$rules" "$daemon_config" >"$scratch/owner-failed" 2>&1; then fail "owner enrollment must reject enumeration failure" fi -[[ $(<"$rules") == "$owner_rule" ]] || fail "failed enrollment preserves the old policy" +[[ $(<"$rules") == "$owner_rule"' label "omarchy-usb-authorization-v1"' ]] || fail "failed enrollment preserves the old policy" ! grep -q '^systemctl' "$calls" || fail "failed owner enrollment must not enable enforcement" : >"$calls" sed 's/ImplicitPolicyTarget=block/ImplicitPolicyTarget=allow/' "$daemon_config" >"$scratch/insecure-daemon.conf" @@ -622,10 +717,37 @@ USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) token=$(basename "${request%.json}") GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-always-output" -grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" +grep -Fq 'usbguard ' "$calls") +"$USB_APPROVE_FIXTURE" 17 "allow ${malicious_rule#block }" +[[ $(grep -c '^usbguard ' "$calls") == "$append_count" ]] || fail "confirmed permanent retry must preserve rule ordering without duplicate rules" pass "USB review supports explicit persistent trust" rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +# Unmarked rules might be intentional manual restrictions. Review is opt-in, +# never accepted through --yes, and only the selected live identity is added. +legacy_rule='allow id 046d:c53a name "Receiver" hash "receiver" parent-hash "hub-a" via-port "1-2"' +printf '%s\n' "$legacy_rule" >"$TEST_ALLOWED_RULE" +printf '12: %s\n\t17: %s\n' "$legacy_rule" "$legacy_rule" >"$scratch/legacy-policy" +cp "$scratch/legacy-policy" "$scratch/legacy-before" +if "$scratch/setup" --review-existing --yes >/dev/null 2>&1; then fail "legacy migration requires individual consent"; fi +TEST_POLICY_LIST="$scratch/legacy-policy" GUM_DECLINE=1 "$scratch/setup" --review-existing >/dev/null +[[ ! -e $TEST_SAVED_RULE ]] || fail "declining migration adds portable trust" +printf 'yes\nyes\nyes\n' | TEST_POLICY_LIST="$scratch/legacy-policy" GUM_REQUIRE_STDIN=1 "$scratch/setup" --review-existing >/dev/null +[[ $(<"$TEST_SAVED_RULE") == 'allow id 046d:c53a name "Receiver" hash "receiver" label "omarchy-usb-authorization-v1"' ]] || fail "legacy review saves only identity constraints" +cmp -s "$scratch/legacy-policy" "$scratch/legacy-before" || fail "legacy review rewrites existing manual rules" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + +printf '12: block id 07a6:8513\n\t17: %s\n' "$malicious_rule" >"$scratch/deny-policy" +if TEST_POLICY_LIST="$scratch/deny-policy" "$USB_APPROVE_FIXTURE" 17 "$malicious_rule" >/dev/null 2>&1; then fail "permanent approval must respect earlier manual denial"; fi +[[ ! -e $TEST_SAVED_RULE && ! -e $TEST_ALLOWED_RULE ]] || fail "manual denial changed policy or authorization" +printf '%s\n' "allow ${malicious_rule#block }" >"$TEST_ALLOWED_RULE" +if TEST_POLICY_LIST="$scratch/deny-policy" "$USB_APPROVE_FIXTURE" 17 "allow ${malicious_rule#block }" >/dev/null 2>&1; then fail "temporary allow cannot hide an earlier manual denial"; fi +rm -f "$TEST_ALLOWED_RULE" +printf '12: block\n' >"$scratch/deny-all-policy" +if TEST_POLICY_LIST="$scratch/deny-all-policy" "$USB_APPROVE_FIXTURE" 17 "$malicious_rule" >/dev/null 2>&1; then fail "unconditional manual denial must be preserved"; fi +pass "USB legacy review requires consent and preserves manual policy" + USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ @@ -724,11 +846,12 @@ if APPROVAL_NO_POLICY=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy fail "permanent approval requires saved policy" fi allow_count=$(grep -c '^usbguard ' "$calls") -if GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then +if APPROVAL_NO_POLICY=1 GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then fail "retry cannot downgrade permanent approval when policy is missing" fi [[ -f $request && $(jq -r .approval "$request") == 'Always allow this device' ]] || fail "missing policy retains original permanent intent" [[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "retry must not repeat authorization on an allowed device" +printf '%s\n' "allow ${malicious_rule#block }" >"$TEST_ALLOWED_RULE" for failure in BLOCKED_QUERY_FAIL APPROVAL_QUERY_FAIL; do if env "$failure=1" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/query-retry" 2>&1; then fail "retry must fail when inventory cannot be read" @@ -803,6 +926,18 @@ echo 0 >"$sysfs/usb1/authorized_default" echo 0 >"$sysfs/1-2/authorized" export TEST_SYSFS="$sysfs" +for failure in TEST_STOP_FAIL TEST_STOP_LIES; do + touch "$TEST_GUARD_ACTIVE" + : >"$calls" + if env "$failure=1" "$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/$failure.log" 2>&1; then + fail "removal must reject $failure" + fi + [[ -f $unit ]] || fail "failed daemon stop removes watcher" + ! grep -q '^usbguard ' "$calls" || fail "failed daemon stop revokes watcher access" + ! grep -q '^systemctl <--user disable' "$calls" || fail "failed daemon stop disables watcher" +done +pass "USB removal preserves approval prompts if stopping enforcement fails" + "$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/remove-output" [[ $(<"$sysfs/usb1/authorized_default") == 1 ]] || fail "removal restores root-hub default authorization" @@ -817,3 +952,16 @@ grep -Fqx 'sudo ' "$calls" || [[ ! -e $home/.config/systemd/user/omarchy-usb-authorization.service ]] || fail "removal deletes the graphical-session watcher" pass "USB authorization removal restores the original default-allow behavior" + +cat >"$stub_bin/omarchy-setup-security-usb-authorization" <<'STUB' +#!/bin/bash +printf 'migrate-setup <%s>\n' "$*" >>"$CALLS" +STUB +chmod +x "$stub_bin/omarchy-setup-security-usb-authorization" +: >"$calls" +bash -euo pipefail "$ROOT/migrations/1790608617.sh" >/dev/null +! grep -q '^migrate-setup' "$calls" || fail "migration overrides USB opt-out" +touch "$TEST_GUARD_ACTIVE" +bash -euo pipefail "$ROOT/migrations/1790608617.sh" >/dev/null +grep -Fqx 'migrate-setup <--yes>' "$calls" || fail "migration repairs an active daemon without a watcher" +pass "USB migration repairs enabled protection and preserves opt-out"