Skip to content

Commit db6ab5a

Browse files
committed
Resume USB approval verification after transient failures
1 parent 90d604f commit db6ab5a

2 files changed

Lines changed: 172 additions & 39 deletions

File tree

‎bin/omarchy-usb-authorization-review‎

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,72 +12,103 @@ if [[ ! $token =~ ^request-[0-9a-f]{64}$ ]]; then
1212
fi
1313

1414
request="$HOME/.local/state/omarchy/usb-authorization/requests/$token.json"
15+
# Serialize request updates with watcher events, but release the lock while the
16+
# user chooses. A changed request must never inherit an old dialog's approval.
17+
if [[ ! -d ${request%/*} ]]; then
18+
echo "This USB authorization request is no longer available." >&2
19+
exit 1
20+
fi
21+
exec 9>"${request%/*}/.lock"
22+
flock 9
1523
if [[ ! -f $request || -L $request ]]; then
1624
echo "This USB authorization request is no longer available." >&2
1725
exit 1
1826
fi
1927

20-
id=$(jq -er '.id | select(test("^[0-9]+$"))' "$request")
21-
expected_rule=$(jq -er '.rule | select(type == "string" and length > 0)' "$request")
22-
read_blocked_rule() {
23-
local line
28+
snapshot=$(cat "$request")
29+
id=$(jq -er '.id | select(test("^[0-9]+$"))' <<<"$snapshot")
30+
expected_rule=$(jq -er '.rule | select(type == "string" and startswith("block "))' <<<"$snapshot")
31+
choice=$(jq -er '.approval // "" | select(. == "" or . == "Allow once" or . == "Always allow this device")' <<<"$snapshot")
32+
allowed_rule="allow ${expected_rule#block }"
33+
34+
approval_failed() {
35+
echo "Could not confirm USB approval. The request has been kept; try again." >&2
36+
exit 1
37+
}
38+
39+
read_current_rule() {
40+
local devices line
2441

42+
current_rule=""
43+
devices=$(usbguard list-devices) || approval_failed
2544
while IFS= read -r line; do
2645
if [[ $line == "$id: "* ]]; then
27-
printf '%s\n' "${line#"$id: "}"
28-
return 0
46+
current_rule="${line#"$id: "}"
47+
return
2948
fi
30-
done < <(usbguard list-devices --blocked)
31-
32-
return 1
49+
done <<<"$devices"
3350
}
3451

35-
current_rule=$(read_blocked_rule || true)
36-
37-
if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then
52+
validate_device() {
53+
read_current_rule
54+
if [[ $current_rule == "$expected_rule" ]] ||
55+
[[ -n $choice && $current_rule == "$allowed_rule" ]]; then
56+
return
57+
fi
3858
rm -f "$request"
3959
echo "That USB device was removed or changed. Connect it again to review it." >&2
4060
exit 1
41-
fi
61+
}
62+
63+
validate_device
4264

43-
gum style --foreground 212 --bold "Blocked USB accessory"
44-
echo
45-
echo "The following details come from the device and can be forged:"
46-
printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?'
47-
echo
65+
if [[ -z $choice ]]; then
66+
flock -u 9
67+
gum style --foreground 212 --bold "Blocked USB accessory"
68+
echo
69+
echo "The following details come from the device and can be forged:"
70+
printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?'
71+
echo
4872

49-
choice=$(gum choose \
50-
"Allow once" \
51-
"Always allow this device" \
52-
"Keep blocked") || exit 130
73+
choice=$(gum choose \
74+
"Allow once" \
75+
"Always allow this device" \
76+
"Keep blocked") || exit 130
5377

54-
if [[ $choice != "Keep blocked" ]]; then
55-
current_rule=$(read_blocked_rule || true)
56-
if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then
78+
flock 9
79+
if [[ ! -f $request || -L $request || $(cat "$request") != "$snapshot" ]]; then
80+
echo "This USB authorization request changed. Open the latest notification to review it." >&2
81+
exit 1
82+
fi
83+
# A fresh choice can authorize only the blocked identity shown in the dialog.
84+
read_current_rule
85+
if [[ $current_rule != "$expected_rule" ]]; then
5786
rm -f "$request"
5887
echo "That USB device was removed or changed. Connect it again to review it." >&2
5988
exit 1
6089
fi
6190
fi
6291

63-
approval_failed() {
64-
echo "Could not confirm USB approval. The request has been kept; try again." >&2
65-
exit 1
66-
}
67-
6892
case "$choice" in
6993
"Allow once" | "Always allow this device")
70-
# Target the complete identity rule instead of USBGuard's numeric ID. IDs can
71-
# be reassigned when the daemon restarts while the review dialog is open.
72-
options=()
73-
if [[ $choice == "Always allow this device" ]]; then
74-
options+=(--permanent)
94+
# Save intent before applying policy: a later query can fail after the device
95+
# was authorized. Retry must verify that approval, including persistent trust.
96+
temporary=$(mktemp "${request%/*}/.request.XXXXXXXXXX")
97+
trap 'rm -f "$temporary"' EXIT
98+
jq --arg approval "$choice" '.approval = $approval' "$request" >"$temporary"
99+
mv -f "$temporary" "$request"
100+
101+
if [[ $current_rule == "$expected_rule" ]]; then
102+
# Match the complete identity, since numeric IDs can be reassigned.
103+
options=()
104+
if [[ $choice == "Always allow this device" ]]; then
105+
options+=(--permanent)
106+
fi
107+
usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed
75108
fi
76-
usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed
77109

78110
# Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can
79111
# return success without authorizing anything. Verify the exact device.
80-
allowed_rule="allow ${expected_rule#block }"
81112
allowed=$(usbguard list-devices --allowed) || approval_failed
82113
grep -Fqx -- "$id: $allowed_rule" <<<"$allowed" || approval_failed
83114

@@ -107,6 +138,7 @@ case "$choice" in
107138
"Keep blocked")
108139
echo "USB accessory remains blocked."
109140
;;
141+
*) exit 1 ;;
110142
esac
111143

112144
rm -f "$request"

‎test/shell.d/usb-authorization-test.sh‎

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ list-devices)
7474
printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")"
7575
fi
7676
elif [[ ${2:-} == "--blocked" ]]; then
77+
[[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1
7778
[[ ! -f $TEST_ALLOWED_RULE ]] || exit 0
7879
if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi
7980
if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then
@@ -84,6 +85,17 @@ list-devices)
8485
printf '17: %s\n' "$rule"
8586
fi
8687
else
88+
[[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1
89+
if [[ -f $TEST_ALLOWED_RULE ]]; then
90+
[[ ${APPROVAL_QUERY_FAIL:-0} == 0 ]] || exit 1
91+
printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")"
92+
elif [[ -n ${BLOCKED_DEVICE_RULE:-} && ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then
93+
rule="$BLOCKED_DEVICE_RULE"
94+
if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then
95+
rule=$(<"$BLOCKED_DEVICE_RULE_FILE")
96+
fi
97+
printf '17: %s\n' "$rule"
98+
fi
8799
echo '4: allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"'
88100
echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"'
89101
fi
@@ -142,6 +154,12 @@ cat >"$stub_bin/gum" <<'STUB'
142154
case "$1" in
143155
style) exit 0 ;;
144156
choose)
157+
echo "gum choose" >>"$CALLS"
158+
[[ ${GUM_CANCEL:-0} == 0 ]] || exit 130
159+
if [[ -n ${GUM_REPLACE_REQUEST:-} ]]; then
160+
jq '.generation = "replacement-generation"' "$GUM_REPLACE_REQUEST" >"$GUM_REPLACE_REQUEST.new"
161+
mv "$GUM_REPLACE_REQUEST.new" "$GUM_REPLACE_REQUEST"
162+
fi
145163
if [[ -n ${GUM_REASSIGN_RULE:-} ]]; then
146164
printf '%s\n' "$GUM_REASSIGN_RULE" >"$BLOCKED_DEVICE_RULE_FILE"
147165
fi
@@ -648,7 +666,7 @@ pass "USB review binds approval to the device snapshot"
648666

649667
for choice in 'Allow once' 'Always allow this device'; do
650668
for failure in APPROVAL_SILENT_FAIL APPROVAL_EXIT_FAIL APPROVAL_QUERY_FAIL APPROVAL_REPLACEMENT_RULE; do
651-
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
669+
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request"
652670
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
653671
value=1
654672
[[ $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
660678
done
661679
done
662680
for failure in APPROVAL_NO_POLICY APPROVAL_POLICY_QUERY_FAIL; do
663-
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
681+
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request"
682+
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
664683
if env "$failure=1" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-no-policy" 2>&1; then
665684
fail "permanent approval must reject $failure"
666685
fi
@@ -672,6 +691,88 @@ GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-revie
672691
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
673692
pass "USB approvals verify device and permanent policy before consuming requests"
674693

694+
# Exercise retries without resetting the device state left by the first attempt.
695+
for choice in 'Allow once' 'Always allow this device'; do
696+
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
697+
failure=APPROVAL_QUERY_FAIL
698+
[[ $choice != 'Always allow this device' ]] || failure=APPROVAL_POLICY_QUERY_FAIL
699+
if env "$failure=1" GUM_CHOICE="$choice" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-first" 2>&1; then
700+
fail "transient verification failure must retain the approval"
701+
fi
702+
[[ -s $TEST_ALLOWED_RULE && -f $request ]] || fail "fixture must retain an already-allowed device"
703+
[[ $(jq -r .approval "$request") == "$choice" ]] || fail "retry must remember the selected approval"
704+
[[ $(stat -c %a "$request") == 600 ]] || fail "saved approval intent stays private"
705+
allow_count=$(grep -c '^usbguard <allow-device>' "$calls")
706+
prompt_count=$(grep -c '^gum choose' "$calls")
707+
GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-second"
708+
[[ ! -e $request ]] || fail "verified approval retry consumes the request"
709+
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "already-allowed retry must not reapply authorization"
710+
[[ $(grep -c '^gum choose' "$calls") == "$prompt_count" ]] || fail "retry must resume the original approval without another choice"
711+
if [[ $choice == 'Always allow this device' ]]; then
712+
grep -Fq 'added to the trusted policy' "$scratch/retry-second" || fail "permanent retry must finish permanent verification"
713+
else
714+
grep -Fq 'allowed until it is disconnected' "$scratch/retry-second" || fail "once retry must finish temporary verification"
715+
fi
716+
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
717+
done
718+
pass "USB approval retries resume successful temporary and permanent authorization"
719+
720+
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
721+
if APPROVAL_NO_POLICY=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy" 2>&1; then
722+
fail "permanent approval requires saved policy"
723+
fi
724+
allow_count=$(grep -c '^usbguard <allow-device>' "$calls")
725+
if GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then
726+
fail "retry cannot downgrade permanent approval when policy is missing"
727+
fi
728+
[[ -f $request && $(jq -r .approval "$request") == 'Always allow this device' ]] || fail "missing policy retains original permanent intent"
729+
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "retry must not repeat authorization on an allowed device"
730+
for failure in BLOCKED_QUERY_FAIL APPROVAL_QUERY_FAIL; do
731+
if env "$failure=1" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/query-retry" 2>&1; then
732+
fail "retry must fail when inventory cannot be read"
733+
fi
734+
[[ -f $request ]] || fail "inventory query failures must preserve pending approvals"
735+
done
736+
printf '%s\n' "allow ${replacement_rule#block }" >"$TEST_ALLOWED_RULE"
737+
if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-retry" 2>&1; then
738+
fail "retry must reject a replacement allowed device"
739+
fi
740+
[[ ! -e $request ]] || fail "a proven replacement consumes the stale request"
741+
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "replacement retry must not authorize anything"
742+
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
743+
pass "USB approval retries preserve permanent intent and validate allowed identity"
744+
745+
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
746+
if BLOCKED_QUERY_FAIL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/fresh-query-failure" 2>&1; then
747+
fail "fresh review must fail on inventory query failure"
748+
fi
749+
[[ -f $request ]] || fail "fresh inventory failure must not delete the request"
750+
jq '.approval = "invalid"' "$request" >"$scratch/invalid-approval"
751+
mv "$scratch/invalid-approval" "$request"
752+
if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/invalid-intent" 2>&1; then
753+
fail "invalid saved intent must not authorize"
754+
fi
755+
jq 'del(.approval)' "$request" >"$scratch/fresh-request"
756+
mv "$scratch/fresh-request" "$request"
757+
if GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/cancel" 2>&1; then
758+
fail "canceled review must not authorize"
759+
fi
760+
jq -e '.approval == null' "$request" >/dev/null || fail "canceling must not store an approval"
761+
if GUM_REPLACE_REQUEST="$request" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-request" 2>&1; then
762+
fail "open dialog must not approve a replaced request"
763+
fi
764+
[[ $(jq -r .generation "$request") == 'replacement-generation' ]] || fail "stale dialog must leave replacement request intact"
765+
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "canceled or superseded requests cannot authorize"
766+
rm -f "$request"
767+
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
768+
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
769+
fail "failed apply must retain a blocked request"
770+
fi
771+
GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/blocked-retry-second"
772+
[[ -s $TEST_SAVED_RULE && ! -e $request ]] || fail "blocked retry must apply its saved permanent choice"
773+
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
774+
pass "USB review preserves transient failures and rejects canceled or replaced dialogs"
775+
675776
notification_count=$(grep -c '^notification' "$calls")
676777
printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked"
677778
NOTIFICATION_READY_FILE="$scratch/notification-ready" \

0 commit comments

Comments
 (0)