Skip to main content

max / alloy

16.8 KB · 387 lines History Blame Raw
1 #!/bin/sh
2 # alloy-usb-gate — drop USB enforcement while this machine has no keyboard.
3 #
4 # Step 4 of the `alloy usb` work, and the part that makes deny-unknown safe to
5 # turn on at all. USBGuard's default posture (deny anything matching no rule)
6 # is correct right up to the moment the thing it denies is how the person types,
7 # and then it is a machine nobody can log into. This is the release valve:
8 # whenever the machine has zero usable keyboards, enforcement comes off and the
9 # console says so.
10 #
11 # Continuous rather than boot-only, which is why a udev rule drives it as well
12 # as a boot-time run. A keyboard that dies mid-session is the same lockout with
13 # a running session behind it, and a check that only ran at boot would sit there
14 # having already passed.
15 #
16 # ## What counts as a keyboard, and it is the one thing worth measuring twice
17 #
18 # `ID_INPUT_KEYBOARD`, which udev's own `input_id` builtin sets, and NOT
19 # `ID_INPUT_KEY`. Measured on fw13 2026-08-22, and the difference is the whole
20 # check:
21 #
22 # ID_INPUT_KEY=1 Power Button
23 # ID_INPUT_KEY=1 Video Bus
24 # ID_INPUT_KEY=1 Wireless Radio Control
25 # ID_INPUT_KEY=1 Consumer Control
26 # ID_INPUT_KEY=1 ID_INPUT_KEYBOARD=1 AT Translated Set 2 keyboard
27 # ID_INPUT_KEY=1 ID_INPUT_KEYBOARD=1 8BitDo Retro Keyboard Receiver
28 #
29 # Four things on this laptop emit key events and are not keyboards. Counting
30 # them would keep the gate shut on a machine whose only real keyboard had just
31 # been denied, which is the inverted failure and the expensive one: the gate
32 # would report itself working while the machine was already lost.
33 #
34 # The same reading answers the i8042 half the ruling asks for, and answers it
35 # by construction rather than by a special case. The internal Framework
36 # keyboard is `AT Translated Set 2 keyboard` at `isa0060/serio0`, BUS_I8042 and
37 # not BUS_USB, so USBGuard has no jurisdiction over it; it is present in the
38 # input subsystem no matter what the USB policy says, so it is counted, so the
39 # gate stays shut. A check written over `/sys/bus/usb` instead would see no USB
40 # keyboard on a Framework and open the gate permanently on every one of them.
41 #
42 # A denied USB keyboard is not in this list either, and that is also by
43 # construction: deauthorizing a device unbinds its interfaces, so it stops
44 # being an input device at all. So "how many keyboards can this person type on"
45 # and "how many keyboards does the kernel show" are the same question.
46 #
47 # ## Suspending enforcement is not stopping the daemon
48 #
49 # Measured on usbguard-1.1.4-1.fc43 rather than assumed, and it is the finding
50 # that shaped this script: `RestoreControllerDeviceState=false` in the shipped
51 # config, and the shipped unit has no `ExecStop` at all. Nothing restores device
52 # authorization when the daemon goes away. So a gate that stopped the daemon and
53 # stopped there would leave every denied device denied, including the keyboard
54 # it opened for, and would do nothing whatsoever for the person it exists to
55 # rescue.
56 #
57 # Opening the gate is therefore three acts in this order: stop the daemon, so it
58 # does not immediately undo the next step; authorize everything on the bus; and
59 # set `authorized_default` so a keyboard plugged in *after* the gate opened comes
60 # up usable too. Everything, not just the keyboards — a denied device exposes no
61 # interfaces, so there is no way to tell which of them was going to be the
62 # keyboard, and the machine is already in the state this exists to get out of.
63 #
64 # ## It never arms anything
65 #
66 # The gate closes by starting `usbguard.service`, and it will only ever do that
67 # to undo an open it performed itself, which is what the stamp file records. On
68 # a machine where enforcement was never turned on there is no stamp, so this
69 # script reads the keyboard count and does nothing at all, forever. That is
70 # deliberate: arming is the person's decision from `alloy usb` (or the preset
71 # line, once the bench tests have run), and a boot-path script that could turn
72 # deny-everything on by itself would be a far worse hazard than the one this
73 # closes.
74 #
75 # ## The flap guard
76 #
77 # The ruling says enforcement resumes when a keyboard is present. Ordinarily
78 # that is right and uneventful: the policy was seeded from the devices that were
79 # attached, so unplugging a known keyboard and plugging it back in resumes
80 # cleanly. The case it does not cover is a keyboard the policy has never heard
81 # of. There the sequence is: gate closes, daemon starts, daemon denies the new
82 # keyboard, keyboard count returns to zero, gate opens. Forever, several times a
83 # second.
84 #
85 # So a close that is followed by another open inside FLAP_WINDOW seconds latches
86 # the gate open and says why on the console. Failing open is the correct
87 # direction for a lockout valve, and a machine that is honestly unprotected and
88 # usable beats one thrashing its own USB bus.
89 #
90 # ## Keeping the rescue keyboard, ruled 2026-08-27
91 #
92 # The latch is not the end state any more. Once the gate has latched and the
93 # keyboard has come back onto the bus, the gate writes a permanent allow rule
94 # for it and resumes enforcement, so the machine ends up protected and usable
95 # with no command for the user to find. GoingsOn alloy `94ae2ea0`.
96 #
97 # **Only while latched, and that is the whole safety property.** The latch means
98 # this machine has already demonstrated it has no other keyboard, which is the
99 # state where an attacker's prerequisite — make every existing keyboard
100 # disappear — has already been met, so refusing buys nothing; and it is the only
101 # state where somebody is certainly standing at the machine plugging something
102 # in. A rule written outside it would hand any keyboard-shaped device a
103 # permanent allow on a machine that was working fine.
104 #
105 # The threat model, recorded so it is not re-litigated: an attacker with
106 # physical access who can make every existing keyboard disappear gets a
107 # permanent allow for a keyboard-shaped device. On a laptop the internal i8042
108 # keyboard is not removable without opening the machine, so this mostly bites
109 # desktops and docked machines. Taken deliberately, against leaving a locked-out
110 # user with enforcement off and a command they can only learn from a console
111 # message.
112 #
113 # It happens on the run after the latch rather than in the same one, because at
114 # the moment of latching the keyboard is denied and therefore invisible: a
115 # denied device exposes no interfaces, so there is nothing to write a rule
116 # about. Opening the gate authorizes it, the kernel re-enumerates it, and that
117 # is a udev event, which is another run of this script — with the keyboard
118 # present and identifiable.
119 #
120 # One attempt per latch, recorded in RESCUED. If the rule is written and the
121 # daemon still denies the keyboard, the gate reopens and stays open rather than
122 # writing another rule every minute at a device it is not actually matching.
123 #
124 # Dependency-light on purpose: sh, udevadm, systemctl, and usbguard for the
125 # one policy write. It runs on a machine
126 # whose keyboard has just gone away, which is not the moment to discover that
127 # something it reaches for is missing.
128
129 set -u
130
131 STATE=/run/alloy
132 STAMP=$STATE/usb-gate-open # exists => this script suspended enforcement
133 CLOSED=$STATE/usb-gate-closed # when the gate last resumed it, for the flap guard
134 LATCH=$STATE/usb-gate-latched # exists => flapping, stay open and stop deciding
135 RESCUED=$STATE/usb-gate-rescued # a permanent rule was already written this latch
136 FLAP_WINDOW=60
137
138 UNIT=usbguard.service
139 POLICY=/etc/usbguard/rules.conf
140
141 # Every write here is best-effort and nothing is fatal. This is on the recovery
142 # path, and a gate that aborts halfway leaves the machine in the state it was
143 # called to fix.
144 say() {
145 # VT1 is where the greeter is, so that is where a person with no keyboard is
146 # looking. /dev/console is the fallback for a machine that is not using VT1
147 # for the greeter, and for an install that has not reached one yet.
148 printf '%s\n' "$1" > /dev/tty1 2>/dev/null \
149 || printf '%s\n' "$1" > /dev/console 2>/dev/null \
150 || true
151 # The journal gets it too, for the case where the screen scrolled or nobody
152 # was in the room.
153 printf 'alloy-usb-gate: %s\n' "$1" >&2
154 }
155
156 # How many devices the kernel considers a keyboard. See the header: the property
157 # is ID_INPUT_KEYBOARD, and ID_INPUT_KEY is the trap.
158 keyboard_count() {
159 count=0
160 for device in /sys/class/input/input*; do
161 [ -e "$device" ] || continue
162 if udevadm info --query=property --path="${device#/sys}" 2>/dev/null \
163 | grep -qx 'ID_INPUT_KEYBOARD=1'; then
164 count=$((count + 1))
165 fi
166 done
167 printf '%s\n' "$count"
168 }
169
170 # Undo every deauthorization on the bus, and stop new attachments arriving
171 # denied. Only ever called with the daemon already stopped.
172 authorize_everything() {
173 for flag in /sys/bus/usb/devices/*/authorized_default; do
174 [ -w "$flag" ] && printf '1\n' > "$flag" 2>/dev/null
175 done
176 for flag in /sys/bus/usb/devices/*/authorized; do
177 [ -w "$flag" ] && printf '1\n' > "$flag" 2>/dev/null
178 done
179 return 0
180 }
181
182 armed() {
183 systemctl is-active --quiet "$UNIT" 2>/dev/null
184 }
185
186 # The USB ids of every keyboard the kernel can see, one `vvvv:pppp` per line.
187 #
188 # Read off the same input-subsystem query the count uses, so the two agree about
189 # what a keyboard is. A keyboard with no USB ids is an internal one — the i8042
190 # on a laptop — and it needs no rule, because USBGuard has no jurisdiction over
191 # it. It drops out here by having no properties to print rather than by a case.
192 keyboard_usb_ids() {
193 for device in /sys/class/input/input*; do
194 [ -e "$device" ] || continue
195 udevadm info --query=property --path="${device#/sys}" 2>/dev/null \
196 | awk -F= '
197 /^ID_INPUT_KEYBOARD=1$/ { keyboard = 1 }
198 /^ID_VENDOR_ID=/ { vendor = $2 }
199 /^ID_MODEL_ID=/ { model = $2 }
200 END { if (keyboard && vendor != "" && model != "") print vendor ":" model }
201 '
202 done | sort -u
203 }
204
205 # Write a permanent allow rule for every keyboard now on the bus.
206 #
207 # The rule text comes from `usbguard generate-policy` rather than being composed
208 # here. It is the one verb that does not go through the daemon's IPC socket
209 # (measured on usbguard-1.1.4), which is what makes it the only one available at
210 # this moment: the gate is open, so the daemon is stopped. It also emits exactly
211 # the fields usbguard matches on, which a rule assembled by hand out of sysfs
212 # would be guessing at.
213 #
214 # Selected by USB id, so what is kept is "this model of keyboard" rather than
215 # every device attached while the machine was in trouble. `DeviceRulesWithPort`
216 # is false by default, so the rule is not tied to the socket it was plugged
217 # into, which is what a keyboard that moves ports needs.
218 #
219 # Returns success only if something was actually appended. A failure here leaves
220 # the gate open, which is the correct direction: unprotected and usable.
221 allow_keyboards() {
222 ids=$(keyboard_usb_ids)
223 [ -n "$ids" ] || return 1
224 generated=$(usbguard generate-policy 2>/dev/null) || return 1
225 [ -n "$generated" ] || return 1
226
227 # A rule is one line and lines are what is being iterated, so IFS is set to
228 # a newline for the loop rather than left at whitespace: every generated
229 # rule contains spaces and a device name that may contain several.
230 written=0
231 saved_ifs=$IFS
232 IFS='
233 '
234 for id in $ids; do
235 for line in $(printf '%s\n' "$generated" | grep -F "id $id "); do
236 [ -n "$line" ] || continue
237 # Idempotent: a second run over the same keyboard must not grow the
238 # policy file a line at a time.
239 if [ -e "$POLICY" ] && grep -qxF "$line" "$POLICY" 2>/dev/null; then
240 continue
241 fi
242 printf '%s\n' "$line" >> "$POLICY" 2>/dev/null || { IFS=$saved_ifs; return 1; }
243 written=$((written + 1))
244 done
245 done
246 IFS=$saved_ifs
247
248 [ "$written" -gt 0 ] || return 1
249 say ""
250 say "The keyboard you attached has been added to this machine's USB policy"
251 say "permanently, and enforcement is coming back on with it allowed:"
252 say ""
253 for id in $ids; do
254 say " $id"
255 done
256 say ""
257 say "To undo that, remove its line from $POLICY."
258 say ""
259 return 0
260 }
261
262 open_gate() {
263 # Order matters: stopping first is what keeps the daemon from re-denying
264 # each device as the loop below authorizes it.
265 systemctl stop "$UNIT" 2>/dev/null || true
266 authorize_everything
267 mkdir -p "$STATE" 2>/dev/null || true
268 : > "$STAMP" 2>/dev/null || true
269 say ""
270 say "USB device authorization is OFF: this machine has no keyboard."
271 say ""
272 say "Every USB device has been re-authorized so you can attach one and"
273 say "type. Enforcement comes back by itself once a keyboard is present."
274 say ""
275 }
276
277 close_gate() {
278 # The timestamp goes down before the start, because the flap it feeds is
279 # measured from the attempt rather than from a success.
280 mkdir -p "$STATE" 2>/dev/null || true
281 date +%s > "$CLOSED" 2>/dev/null || true
282 # The stamp comes off only if the daemon really came back. Removing it
283 # first and then failing to start would leave a machine with enforcement
284 # off, the bus authorized, and nothing recording that the gate is what did
285 # it — so the next run would see a state it thinks it never made and leave
286 # it there.
287 if systemctl start "$UNIT" 2>/dev/null; then
288 # RESCUED goes with it: enforcement is back on, so the episode this
289 # gate was in is over and a future latch gets its own one attempt.
290 rm -f "$STAMP" "$RESCUED" 2>/dev/null || true
291 say "USB device authorization is back on: a keyboard is present."
292 else
293 say "A keyboard is present, but USB device authorization could not be"
294 say "turned back on. It stays off. See: systemctl status $UNIT"
295 fi
296 }
297
298 # A close followed by another open inside the window means the keyboard that
299 # closed the gate is not in the policy, so resuming denies it and we are back
300 # here. See the header.
301 flapping() {
302 [ -r "$CLOSED" ] || return 1
303 closed_at=$(cat "$CLOSED" 2>/dev/null) || return 1
304 now=$(date +%s 2>/dev/null) || return 1
305 case "$closed_at" in
306 ''|*[!0-9]*) return 1 ;;
307 esac
308 [ $((now - closed_at)) -lt "$FLAP_WINDOW" ]
309 }
310
311 latch() {
312 mkdir -p "$STATE" 2>/dev/null || true
313 : > "$LATCH" 2>/dev/null || true
314 say ""
315 say "USB device authorization is OFF and staying off."
316 say ""
317 say "The keyboard you attached is not in this machine's USB policy, so"
318 say "turning enforcement back on denies it again. Rather than do that in"
319 say "a loop, it is left off."
320 say ""
321 say "The keyboard is being re-authorized now. As soon as the kernel sees"
322 say "it again, it is added to the policy permanently and enforcement comes"
323 say "back on by itself, with the keyboard working."
324 say ""
325 say "If that does not happen, enforcement stays off and this is the way in:"
326 say ""
327 say " alloy usb"
328 say ""
329 }
330
331 keyboards=$(keyboard_count)
332
333 if [ -e "$LATCH" ]; then
334 # Latched open after a flap. The only thing that supersedes it is a person
335 # turning enforcement back on themselves, from `alloy usb` or by hand —
336 # which means they have dealt with the keyboard the policy did not know,
337 # and the gate goes back to being an ordinary gate.
338 #
339 # Reading that off the daemon rather than off a second stamp matters. An
340 # earlier version cleared the latch only when the stamp was gone, and the
341 # stamp is not removed by a person running `systemctl start usbguard`, so
342 # the latch survived forever and the gate never protected that machine
343 # again: enforcement on, valve permanently disabled, and nothing said so.
344 if armed; then
345 rm -f "$LATCH" "$STAMP" "$RESCUED" 2>/dev/null || true
346 elif [ "$keyboards" -gt 0 ] && [ ! -e "$RESCUED" ]; then
347 # The rescue keyboard is back on the bus, because opening the gate
348 # authorized it. This is the one moment a permanent rule may be
349 # written: see the header. The stamp goes down before the attempt, so a
350 # write that appears to work and does not still costs one round rather
351 # than one per minute forever.
352 : > "$RESCUED" 2>/dev/null || true
353 if allow_keyboards; then
354 rm -f "$LATCH" 2>/dev/null || true
355 close_gate
356 else
357 say ""
358 say "The keyboard could not be added to the USB policy, so device"
359 say "authorization stays off. See: alloy usb"
360 say ""
361 fi
362 exit 0
363 else
364 exit 0
365 fi
366 fi
367
368 if [ "$keyboards" -eq 0 ]; then
369 if armed; then
370 if flapping; then
371 open_gate
372 latch
373 else
374 open_gate
375 fi
376 fi
377 # Not armed and no keyboard: nothing to suspend, and nothing to say. The
378 # machine is in whatever state its owner left it in.
379 exit 0
380 fi
381
382 if [ -e "$STAMP" ]; then
383 close_gate
384 fi
385
386 exit 0
387