From 90d604f41d6ae52c6e53ca26ab3de311c7580420 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Thu, 17 Sep 2026 12:04:02 -0600 Subject: [PATCH] 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"