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" \