Skip to main content

max / alloy

Keep the keyboard that rescued a latched machine The latch left a machine honestly unprotected and usable, with the way back out written on a console message somebody had to still be reading. Once the gate has latched and the keyboard is on the bus again, the gate now adds it to the policy permanently and turns enforcement back on with it allowed. Only while latched, and that is the whole safety property: the latch means this machine has already demonstrated it has no other keyboard, so the attacker's prerequisite is met either way and refusing buys nothing, and it is the only state where somebody is certainly standing at the machine. It happens on the run after the latch, because at the moment of latching the keyboard is denied and a denied device exposes no interfaces - there is nothing to write a rule about. Opening the gate authorizes it, the kernel re-enumerates it, and that is a udev event and another run. The rule text comes from usbguard generate-policy, the one verb that does not go through the daemon's IPC socket, which is what makes it available with the daemon stopped. One attempt per latch: a rule that is written and still does not match would otherwise grow the policy file once a minute while the machine stays unusable.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 18:12 UTC
Commit: 781a8a44565c83ecc529617232f2045fb5618adf
Parent: f1d46a8
3 files changed, +247 insertions, -8 deletions
M docs/STACK.md +1 -1
@@ -419,7 +419,7 @@
419 419
420 420 It cannot arm anything, which is what makes it safe to enable on every client install including the overwhelming majority where usbguard is not running. The script only ever starts usbguard to undo a stop it recorded itself in a stamp file under `/run`. On a machine where nobody turned enforcement on it reads the keyboard count and exits.
421 421
422 - One case the ruling did not foresee, and the gate refuses to loop on it. A keyboard the policy has never heard of is denied the moment enforcement resumes, which returns the count to zero and reopens the gate, several times a second. A close followed by another open inside sixty seconds therefore latches the gate open and says so on VT1. Failing open is the right direction for a lockout valve. Whether the rescuing keyboard should instead be allowed permanently on the spot is a real decision with a security cost, and it is filed rather than taken.
422 + One case the ruling did not foresee, and the gate refuses to loop on it. A keyboard the policy has never heard of is denied the moment enforcement resumes, which returns the count to zero and reopens the gate, several times a second. A close followed by another open inside sixty seconds therefore latches the gate open and says so on VT1. Failing open is the right direction for a lockout valve. **The latch is not where it ends, ruled 2026-08-27:** once the gate has latched and the keyboard is back on the bus, the gate writes a permanent allow rule for it (from `usbguard generate-policy`, the one verb that does not need the daemon) and resumes enforcement, so the machine ends up protected and usable with no command for the user to find. Only while latched, which is the safety property: the latch means the machine has already demonstrated it has no other keyboard, so an attacker's prerequisite is already met and refusing buys nothing. The cost, taken deliberately: someone with physical access who can make every keyboard disappear gets a permanent allow for a keyboard-shaped device, which mostly bites desktops rather than laptops with an unremovable i8042 keyboard.
423 423
424 424 **The verbs are all in the CLI, so there is nothing to front but a CLI.** `usbguard list-devices`, `allow-device`, `block-device`, `append-rule`, and `generate-policy` cover the whole of it, and `allow-device --permanent` is exactly the "save this device" action the ruling describes. `usbguard watch --exec <path>` runs a program per plug event and is the plug-then-prompt hook, which means the console needs no D-Bus client: the same correction already made for `alloy bluetooth`, and it holds here for the same reason, that the log pane can only show an argv if there was one. `DeviceRulesWithPort=false` is already the default, so a permanent rule is keyed on the device rather than the socket it was in, which is what the ruling asks for and what a device that moves ports requires.
425 425
@@ -85,11 +85,44 @@
85 85 # So a close that is followed by another open inside FLAP_WINDOW seconds latches
86 86 # the gate open and says why on the console. Failing open is the correct
87 87 # direction for a lockout valve, and a machine that is honestly unprotected and
88 - # usable beats one thrashing its own USB bus. Whether the rescuing keyboard
89 - # should instead be allowed permanently on the spot is a real decision with a
90 - # security cost, and it is filed rather than taken here.
88 + # usable beats one thrashing its own USB bus.
91 89 #
92 - # Dependency-light on purpose: sh, udevadm, systemctl. It runs on a machine
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
93 126 # whose keyboard has just gone away, which is not the moment to discover that
94 127 # something it reaches for is missing.
95 128
@@ -99,9 +132,11 @@
99 132 STAMP=$STATE/usb-gate-open # exists => this script suspended enforcement
100 133 CLOSED=$STATE/usb-gate-closed # when the gate last resumed it, for the flap guard
101 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
102 136 FLAP_WINDOW=60
103 137
104 138 UNIT=usbguard.service
139 + POLICY=/etc/usbguard/rules.conf
105 140
106 141 # Every write here is best-effort and nothing is fatal. This is on the recovery
107 142 # path, and a gate that aborts halfway leaves the machine in the state it was
@@ -148,6 +183,82 @@
148 183 systemctl is-active --quiet "$UNIT" 2>/dev/null
149 184 }
150 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 +
151 262 open_gate() {
152 263 # Order matters: stopping first is what keeps the daemon from re-denying
153 264 # each device as the loop below authorizes it.
@@ -174,7 +285,9 @@
174 285 # it — so the next run would see a state it thinks it never made and leave
175 286 # it there.
176 287 if systemctl start "$UNIT" 2>/dev/null; then
177 - rm -f "$STAMP" 2>/dev/null || true
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
178 291 say "USB device authorization is back on: a keyboard is present."
179 292 else
180 293 say "A keyboard is present, but USB device authorization could not be"
@@ -205,7 +318,11 @@
205 318 say "turning enforcement back on denies it again. Rather than do that in"
206 319 say "a loop, it is left off."
207 320 say ""
208 - say "To keep this keyboard and turn enforcement back on:"
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:"
209 326 say ""
210 327 say " alloy usb"
211 328 say ""
@@ -225,7 +342,24 @@
225 342 # the latch survived forever and the gate never protected that machine
226 343 # again: enforcement on, valve permanently disabled, and nothing said so.
227 344 if armed; then
228 - rm -f "$LATCH" "$STAMP" 2>/dev/null || true
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
229 363 else
230 364 exit 0
231 365 fi
@@ -190,6 +190,111 @@
190 190 assert!(code.contains("FLAP_WINDOW"), "the flap window is gone");
191 191 }
192 192
193 + // The latch is not the end state: the gate keeps the keyboard that rescued the
194 + // machine and turns enforcement back on, so the user is not left with a
195 + // permanently unprotected machine and a command they can only learn from a
196 + // console message. Ruled 2026-08-27.
197 + #[test]
198 + fn a_latched_gate_keeps_the_rescue_keyboard_and_resumes() {
199 + let code = code(&script());
200 + let latched = code
201 + .split_once("if [ -e \"$LATCH\" ]; then")
202 + .expect("the latch branch is still there")
203 + .1
204 + .split_once("\nfi")
205 + .expect("the latch branch ends")
206 + .0;
207 + assert!(
208 + latched.contains("allow_keyboards"),
209 + "the latched gate no longer writes a rule for the rescue keyboard, so a \
210 + locked-out machine stays unprotected until somebody finds `alloy usb`",
211 + );
212 + assert!(
213 + latched.contains("close_gate"),
214 + "the rule is written and enforcement is never resumed, which is the \
215 + half that makes the machine protected again",
216 + );
217 + assert!(
218 + code.contains("usbguard generate-policy"),
219 + "the rule text is no longer generated by usbguard; a rule assembled out \
220 + of sysfs by hand is a guess at the fields usbguard matches on",
221 + );
222 + }
223 +
224 + // The whole safety property. Outside the latch, a machine that is working fine
225 + // would be handing a permanent allow to anything keyboard-shaped that arrives.
226 + // Inside it, the machine has already demonstrated it has no other keyboard,
227 + // which is the state an attacker would have had to create anyway.
228 + #[test]
229 + fn no_rule_is_written_when_the_gate_closes_without_latching() {
230 + let code = code(&script());
231 + // The function's own body mentions itself and the policy path; what this is
232 + // about is where it is called from.
233 + let calls: Vec<&str> = code
234 + .split("allow_keyboards() {")
235 + .next()
236 + .into_iter()
237 + .chain(
238 + code.split_once("allow_keyboards() {")
239 + .and_then(|(_, rest)| rest.split_once("\n}"))
240 + .map(|(_, after)| after),
241 + )
242 + .collect();
243 + let outside_the_function = calls.join("\n");
244 +
245 + let (before, latched) = outside_the_function
246 + .split_once("if [ -e \"$LATCH\" ]; then")
247 + .expect("the latch branch is still there");
248 + let after = latched.split_once("\nfi").expect("the latch branch ends").1;
249 +
250 + for (place, body) in [("before the latch branch", before), ("after it", after)] {
251 + assert!(
252 + !body.contains("allow_keyboards"),
253 + "a permanent allow rule is written {place}, so a machine that never \
254 + lost its keyboard would keep whatever was plugged into it",
255 + );
256 + }
257 +
258 + let close = code
259 + .split_once("close_gate() {")
260 + .expect("close_gate is still there")
261 + .1
262 + .split_once("\n}")
263 + .expect("close_gate ends")
264 + .0;
265 + assert!(
266 + !close.contains("allow_keyboards") && !close.contains("$POLICY"),
267 + "close_gate writes policy; resuming enforcement must not be a decision \
268 + about what to trust",
269 + );
270 + }
271 +
272 + // One attempt per latch. A rule that is written and does not match leaves the
273 + // daemon denying the keyboard, which reopens the gate; without this the gate
274 + // would append another rule on every cycle, forever, to a policy file that
275 + // grows while the machine stays unusable.
276 + #[test]
277 + fn the_rescue_writes_a_rule_once_per_latch() {
278 + let code = code(&script());
279 + assert!(code.contains("RESCUED="), "the one-attempt stamp is gone");
280 + let stamped = code
281 + .find(": > \"$RESCUED\"")
282 + .expect("the one-attempt stamp is written somewhere");
283 + let attempted = code
284 + .find("if allow_keyboards;")
285 + .expect("the rescue is attempted somewhere");
286 + assert!(
287 + stamped < attempted,
288 + "the stamp is written after the attempt rather than before it, so an \
289 + attempt that appears to work and does not costs one round per minute",
290 + );
291 + assert!(
292 + code.contains("[ ! -e \"$RESCUED\" ]"),
293 + "nothing reads the stamp, so the gate would write another rule on every \
294 + cycle of a keyboard the policy still denies",
295 + );
296 + }
297 +
193 298 // A latch that nothing can clear is worse than no latch: enforcement back on,
194 299 // the release valve permanently disabled, and nothing saying so. It has to
195 300 // clear on the daemon being armed rather than on the stamp being gone, because