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"