Skip to main content

max / alloy

Ship the USB keyboard gate, so deny-unknown has a release valve Step 4 of the `alloy usb` work, and the clause that makes step 3's deny-unknown policy safe to arm. Enforcement comes off whenever the machine has zero usable keyboards, continuously rather than at boot, and goes back on when one appears. Client profile only: a server has no keyboard by design, so a gate counting keyboards would hold enforcement off forever on the machines that are hardest to visit. Two measurements contradicted the obvious implementation. The property is ID_INPUT_KEYBOARD and not ID_INPUT_KEY. On fw13 the power button, video bus, wireless radio control and consumer control all set ID_INPUT_KEY and none is a keyboard, so counting those keeps the gate shut on a machine whose only keyboard has just been denied, while reporting itself working. Reading the input subsystem rather than the USB bus also answers the i8042 clause by construction: the Framework's internal keyboard is there whatever the USB policy says. And suspending enforcement is not stopping the daemon. usbguard ships RestoreControllerDeviceState=false with no ExecStop, so nothing restores authorization when it exits; a gate that only stopped it would leave the rescued keyboard denied. Opening is stop, then authorize the bus, then set authorized_default. It cannot arm anything: it only starts usbguard to undo a stop it recorded itself, so on a machine where nobody armed enforcement it reads the count and exits. A keyboard the policy has never heard of would otherwise loop -- denied on resume, count back to zero, gate reopens -- so a close followed by another open inside a minute latches the gate open and says so on VT1. Whether that keyboard should instead be allowed permanently is filed as GoingsOn alloy 94ae2ea0 rather than decided here. Verified by building both profiles: the client prints "usb gate: installed on the client profile" and the built image has the unit enabled with usbguard still disabled; the server removes all three files and proves them gone.
Author: Max Johnson <me@maxj.phd> · 2026-08-23 00:04 UTC
Signed with PGP, not checked
Commit: f346518ee74f0ec9a05ee24665aac29d53da1e22
Parent: efeacae
9 files changed, +772 insertions, -2 deletions
@@ -3364,6 +3364,66 @@
3364 3364 || { echo "/etc/usbguard/rules.conf is no longer empty; Fedora shipped a policy and this image would enforce someone else's" >&2; exit 1; }; \
3365 3365 echo "usbguard: installed, stock deny-all policy (the disarmed check runs after preset-all)"
3366 3366
3367 + # =====================================================================
3368 + # The USB keyboard gate: on the client, and deliberately absent on the server.
3369 + #
3370 + # Step 4 of the `alloy usb` work, and the clause that makes deny-unknown safe
3371 + # to arm. It drops USB enforcement whenever the machine has zero usable
3372 + # keyboards and restores it when one appears. The reasoning, and the two
3373 + # measurements it rests on, are in usr/bin/alloy-usb-gate; the short version is
3374 + # that suspending enforcement means stopping the daemon AND re-authorizing the
3375 + # bus, because usbguard restores nothing on its way out.
3376 + #
3377 + # THE SPLIT IS THE RULING'S, and it is the opposite of what "the client is the
3378 + # careful profile" would suggest. A server-profile machine has no keyboard by
3379 + # design, so a gate counting keyboards would find zero on a healthy box and hold
3380 + # enforcement off forever, on exactly the machines that are physically exposed
3381 + # and hardest to visit. That profile enforces unconditionally and its recovery
3382 + # path is the provider console or a KVM.
3383 + #
3384 + # So the three files ride in from the COPY layers on both profiles, and the
3385 + # server branch takes them out again. Both branches assert, per the rule this
3386 + # file follows throughout and tests/profile_split.rs enforces: the client proves
3387 + # the gate is really there, and the server proves it is really gone rather than
3388 + # trusting that a branch it did not take was the right one.
3389 + #
3390 + # The client branch also checks the udev rule's number, which is load-bearing
3391 + # rather than cosmetic. The property the script reads is ID_INPUT_KEYBOARD, and
3392 + # udev's input_id builtin is what sets it; a rule sorting ahead of that reads
3393 + # the property before anything wrote it, counts zero keyboards on a machine full
3394 + # of them, and opens the gate on every boot.
3395 + # =====================================================================
3396 + RUN set -eu; \
3397 + gate=/usr/bin/alloy-usb-gate; \
3398 + unit=/etc/systemd/system/alloy-usb-gate.service; \
3399 + rule=/etc/udev/rules.d/70-alloy-usb-gate.rules; \
3400 + if [ "$PROFILE" = client ]; then \
3401 + test -x "$gate" \
3402 + || { echo "$gate is missing or not executable; deny-unknown has no release valve on the profile that has a keyboard to lose" >&2; exit 1; }; \
3403 + sh -n "$gate" \
3404 + || { echo "$gate does not parse; the release valve is a syntax error and nobody finds out until a machine has no keyboard" >&2; exit 1; }; \
3405 + test -f "$unit" \
3406 + || { echo "$unit is missing; 50-alloy.preset enables a unit that does not exist and the gate never runs" >&2; exit 1; }; \
3407 + test -f "$rule" \
3408 + || { echo "$rule is missing; the gate would be boot-only, and a keyboard that dies mid-session is the same lockout" >&2; exit 1; }; \
3409 + grep -q 'ID_INPUT_KEYBOARD' "$gate" \
3410 + || { echo "$gate no longer reads ID_INPUT_KEYBOARD; ID_INPUT_KEY is set by power buttons and consumer controls, and counting those keeps the gate shut on a machine with no keyboard" >&2; exit 1; }; \
3411 + case "$(basename "$rule")" in \
3412 + [7-9][0-9]-*) : ;; \
3413 + *) echo "$rule sorts before udev's input_id builtin, so ID_INPUT_KEYBOARD is unset when the script reads it and every boot counts zero keyboards" >&2; exit 1 ;; \
3414 + esac; \
3415 + echo "usb gate: installed on the client profile"; \
3416 + else \
3417 + rm -f "$gate" "$unit" "$rule"; \
3418 + test ! -e "$unit" \
3419 + || { echo "profile=server still carries the USB keyboard gate; a machine with no keyboard by design would hold enforcement off forever" >&2; exit 1; }; \
3420 + test ! -e "$gate" \
3421 + || { echo "profile=server still carries $gate" >&2; exit 1; }; \
3422 + test ! -e "$rule" \
3423 + || { echo "profile=server still carries the gate's udev rule, which would start a unit that is no longer there on every input event" >&2; exit 1; }; \
3424 + echo "usb gate: correctly absent on a profile with no keyboard to lock out"; \
3425 + fi
3426 +
3367 3427 # =====================================================================
3368 3428 # Machine identity, from the builder: hostname and ssh pubkey.
3369 3429 # =====================================================================
M docs/CONSOLE.md +1 -1
@@ -385,7 +385,7 @@
385 385 - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback, and `schemas/sway.schema` is the header that declares it). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only.
386 386 - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
387 387 - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page, added after the summary as a sixth step. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install now starts from this screen rather than from the summary, which is the ordering the page argues for: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system.
388 - - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles as of 2026-08-22 and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. What is left is that policy: deny-unknown with an activation the user performs from this screen, and the keyboard gate that suspends enforcement whenever the machine has no usable keyboard. That gate is the part that makes deny-unknown safe, and it has to count i8042 keyboards as usable or it opens permanently on every Framework laptop, whose internal keyboard is not a USB device at all.
388 + - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles as of 2026-08-22 and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. The keyboard gate landed the same day and is the part that makes deny-unknown safe: `usr/bin/alloy-usb-gate` drops enforcement whenever the machine has zero usable keyboards, continuously rather than at boot, on the client profile only. It counts `ID_INPUT_KEYBOARD` and not `ID_INPUT_KEY`, which is what keeps a power button from reading as a keyboard, and it reads the input subsystem rather than the USB bus, which is what makes the Framework's i8042 keyboard count without a special case. What is left is the policy itself: deny-unknown, with an activation the user performs from this screen.
389 389 - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in.
390 390 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
391 391 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
M docs/STACK.md +11 -1
@@ -395,7 +395,7 @@
395 395
396 396 Not settled here, and filed as its own work: whether `alloy net` should show the zone alongside the interface. firewalld is a NetworkManager-integrated daemon and `alloy net` is an nmcli front, so the state is one `nmcli` field away, but showing it is a console design question rather than part of the pick.
397 397
398 - ## USB device authorization: **usbguard, installed and not yet armed**
398 + ## USB device authorization: **usbguard, installed, gated, and not yet armed**
399 399
400 400 **usbguard on both profiles, with the enforcement half deliberately switched off until the policy and the keyboard gate ship.** The view came first and is already here: `alloy usb` reads sysfs, needs no package, and works on an image that has never heard of usbguard. What the package adds is the ability to act on what that screen shows, which is deauthorizing an attachment and remembering the decision.
401 401
@@ -405,6 +405,16 @@
405 405
406 406 **What has to land before the enable line does** is the ruling in GoingsOn alloy `63de3d4c`: deny unknown and prompt, activation from `alloy usb`, permanent allow rules keyed on vendor and product plus serial, and enforcement suspended whenever the machine has zero usable keyboards. That last clause is the one that makes the rest safe, and it has a measured subtlety. The internal keyboard on a Framework laptop is `AT Translated Set 2 keyboard` at `isa0060/serio0`, an i8042 device that usbguard has no jurisdiction over, so a client with every USB device denied still has a working keyboard and the gate must stay shut. A gate that counted only USB keyboards would open permanently on every Framework laptop, which is the inverted failure and is worth a test.
407 407
408 + **The keyboard gate is built, 2026-08-22**, as `usr/bin/alloy-usb-gate` with `alloy-usb-gate.service` and a udev rule on the input subsystem, client profile only. Two measurements shaped it and both contradicted the obvious implementation.
409 +
410 + The first is what to count. The property is `ID_INPUT_KEYBOARD`, which udev's own `input_id` builtin sets, and not `ID_INPUT_KEY`. On fw13 four things carry `ID_INPUT_KEY` and are not keyboards: the power button, the video bus, the wireless radio control and the consumer control. A gate counting those stays shut on a machine whose only real keyboard has just been denied, and reports itself working while doing it. Reading the input subsystem rather than the USB bus is also what answers the i8042 case by construction rather than by a special case, since the Framework's internal keyboard is present there no matter what the USB policy says. A denied USB keyboard is absent from the same list for the same structural reason, because deauthorizing a device unbinds its interfaces, so "how many keyboards can this person type on" and "how many does the kernel show" turn out to be one question.
411 +
412 + The second is what suspending enforcement actually means. usbguard ships `RestoreControllerDeviceState=false` and its unit has no `ExecStop` at all, so nothing restores device authorization when the daemon goes away. A gate that stopped the daemon and stopped there would leave every denied device denied, the rescued keyboard included, and would do nothing whatever for the person it exists to rescue. Opening the gate is therefore stop, then authorize every device on the bus, then set `authorized_default` so a keyboard attached afterwards also comes up usable.
413 +
414 + 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.
415 +
416 + 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.
417 +
408 418 **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.
409 419
410 420 Rejected: **shipping the package on `client` only.** A server-profile machine has a USB bus, and the ruling gives that profile the stricter half of the policy precisely because it has no keyboard to lock out and its recovery path is a provider console. It needs this more than a laptop does, not less.
@@ -68,6 +68,15 @@
68 68 //! it arrives, fronts the CLI: `allow-device`, `block-device`, and
69 69 //! `allow-device --permanent` for a decision that survives a reboot.
70 70 //!
71 + //! What did arrive on 2026-08-22 is the keyboard gate, `usr/bin/alloy-usb-gate`,
72 + //! which is the clause that makes deny-unknown safe to arm at all: it drops
73 + //! enforcement whenever the machine has zero usable keyboards. It is not this
74 + //! module's code and deliberately does not go through it — the gate has to work
75 + //! on a machine with no keyboard, which is a machine nobody is driving a TUI on.
76 + //! Worth knowing here for one reason: it decides what a keyboard is by reading
77 + //! `ID_INPUT_KEYBOARD` off the input subsystem, so a device this screen shows as
78 + //! denied is a device that has already stopped counting toward the gate.
79 + //!
71 80 //! <!-- wiki: alloy-console -->
72 81
73 82 use std::path::Path;
@@ -190,3 +190,24 @@
190 190 # not a network listener.
191 191 enable cups.socket
192 192 enable cups.path
193 +
194 + # The USB keyboard gate, which is what makes deny-unknown safe to arm at all.
195 + # It drops USB enforcement whenever the machine has zero usable keyboards and
196 + # puts it back when one appears (usr/bin/alloy-usb-gate, and the udev rule that
197 + # makes it continuous).
198 + #
199 + # Enabled on every client install, including the overwhelming majority where
200 + # usbguard is not armed and this does nothing. That is safe by construction
201 + # rather than by luck: the script only ever *starts* usbguard to undo a stop it
202 + # performed itself, recorded in a stamp file under /run, so on a machine where
203 + # nobody turned enforcement on it reads the keyboard count and exits. It cannot
204 + # be the thing that arms deny-everything.
205 + #
206 + # Client only. This line matches nothing on the server profile, which is the
207 + # silent no-op this file warns about above and is deliberate here: that profile
208 + # has no keyboard by design, so a gate reading "zero keyboards" would hold
209 + # enforcement off permanently on exactly the machines that need it most. Its
210 + # recovery path is the provider console. The Containerfile removes the unit
211 + # there and asserts it is gone, which is what keeps this no-op intentional
212 + # rather than a rename nobody noticed.
213 + enable alloy-usb-gate.service
@@ -1,0 +1,344 @@
1 + //! The USB keyboard gate, checked on the axes that regress without a word.
2 + //!
3 + //! `usr/bin/alloy-usb-gate` is what makes USBGuard's deny-unknown posture safe
4 + //! to arm: it drops enforcement whenever the machine has zero usable keyboards.
5 + //! Everything about it fails silently. A gate that counts the wrong thing
6 + //! reports itself working on a machine that is already lost; a gate that stops
7 + //! the daemon without re-authorizing the bus leaves the keyboard it opened for
8 + //! still denied; a udev rule numbered one digit lower reads a property nothing
9 + //! has set yet and opens the gate on every boot of every machine.
10 + //!
11 + //! None of those show up in a build, and two of them do not show up on the
12 + //! bench either, because the bench machine has a PS/2 keyboard. So they are
13 + //! asserted here, in the same spirit as `layer_notice.rs` and `polkit_rules.rs`:
14 + //! a text check that runs on every `cargo test`, which is where these files are
15 + //! actually edited.
16 + //!
17 + //! The three end-to-end checks the ruling asks for — a machine booted with no
18 + //! USB keyboard, a Framework laptop on its i8042 keyboard alone, and unplugging
19 + //! the only USB keyboard mid-session — need real hardware and are not these.
20 +
21 + use std::path::PathBuf;
22 +
23 + fn repo(relative: &str) -> String {
24 + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
25 + .join("../..")
26 + .join(relative);
27 + std::fs::read_to_string(&path)
28 + .unwrap_or_else(|error| panic!("reading {}: {error}", path.display()))
29 + }
30 +
31 + fn script() -> String {
32 + repo("usr/bin/alloy-usb-gate")
33 + }
34 +
35 + fn unit() -> String {
36 + repo("etc/systemd/system/alloy-usb-gate.service")
37 + }
38 +
39 + fn rule() -> String {
40 + repo("etc/udev/rules.d/70-alloy-usb-gate.rules")
41 + }
42 +
43 + /// The script with comments dropped, because every string this file looks for
44 + /// also appears in the reasoning above it.
45 + fn code(text: &str) -> String {
46 + text.lines()
47 + .filter(|line| !line.trim_start().starts_with('#'))
48 + .collect::<Vec<_>>()
49 + .join("\n")
50 + }
51 +
52 + // ---- what counts as a keyboard ----
53 +
54 + // The inverted failure, and the expensive one. Measured on fw13 2026-08-22:
55 + // Power Button, Video Bus, Wireless Radio Control and Consumer Control all
56 + // carry ID_INPUT_KEY=1, and none of them is a keyboard. A gate counting those
57 + // stays shut on a machine whose only real keyboard has just been denied, and
58 + // says nothing, because from its own point of view it is working.
59 + #[test]
60 + fn the_gate_counts_keyboards_and_not_everything_that_emits_a_key() {
61 + let code = code(&script());
62 + assert!(
63 + code.contains("ID_INPUT_KEYBOARD=1"),
64 + "the gate no longer reads ID_INPUT_KEYBOARD, which is the only property \
65 + that distinguishes a keyboard from a power button",
66 + );
67 + assert!(
68 + !code.contains("ID_INPUT_KEY=1"),
69 + "the gate matches ID_INPUT_KEY, which a power button, a video bus and a \
70 + consumer control all set: it would hold the gate shut on a machine \
71 + with no keyboard at all",
72 + );
73 + }
74 +
75 + // The i8042 half of the ruling, asserted as an absence. The internal Framework
76 + // keyboard is not a USB device, so it is counted by reading the input subsystem
77 + // and would be missed entirely by reading the USB bus. A gate rewritten over
78 + // /sys/bus/usb would open permanently on every Framework laptop.
79 + #[test]
80 + fn the_gate_reads_the_input_subsystem_rather_than_the_usb_bus() {
81 + let code = code(&script());
82 + let counting = code
83 + .split_once("keyboard_count()")
84 + .expect("the gate still has a keyboard_count function")
85 + .1
86 + .split_once("\n}")
87 + .expect("keyboard_count is a shell function")
88 + .0;
89 + assert!(
90 + counting.contains("/sys/class/input/"),
91 + "keyboard_count no longer reads /sys/class/input",
92 + );
93 + assert!(
94 + !counting.contains("/sys/bus/usb"),
95 + "keyboard_count reads the USB bus, which cannot see the i8042 keyboard \
96 + every Framework laptop has: the gate would open permanently on them",
97 + );
98 + }
99 +
100 + // ---- suspending enforcement is not stopping the daemon ----
101 +
102 + // usbguard-1.1.4-1.fc43 ships RestoreControllerDeviceState=false and its unit
103 + // has no ExecStop, so nothing restores device authorization when the daemon
104 + // goes away. Stopping it and stopping there would leave the denied keyboard
105 + // denied, which is the entire failure this gate exists to undo.
106 + #[test]
107 + fn opening_the_gate_re_authorizes_the_bus_and_not_only_the_daemon() {
108 + let code = code(&script());
109 + let opening = code
110 + .split_once("open_gate()")
111 + .expect("the gate still has an open_gate function")
112 + .1
113 + .split_once("\n}")
114 + .expect("open_gate is a shell function")
115 + .0;
116 +
117 + let stop = opening
118 + .find("systemctl stop")
119 + .expect("open_gate no longer stops the daemon");
120 + let authorize = opening.find("authorize_everything").expect(
121 + "open_gate no longer re-authorizes the bus; usbguard restores nothing \
122 + on its way out, so the keyboard it opened for is still denied",
123 + );
124 + assert!(
125 + stop < authorize,
126 + "open_gate authorizes before it stops the daemon, so the daemon denies \
127 + each device again as the loop walks the bus",
128 + );
129 +
130 + let authorizing = code
131 + .split_once("authorize_everything()")
132 + .expect("the gate still has an authorize_everything function")
133 + .1
134 + .split_once("\n}")
135 + .expect("authorize_everything is a shell function")
136 + .0;
137 + assert!(
138 + authorizing.contains("authorized_default"),
139 + "authorize_everything does not set authorized_default, so a keyboard \
140 + plugged in after the gate opened still arrives denied",
141 + );
142 + }
143 +
144 + // The gate must never be the thing that turns deny-everything on. It is enabled
145 + // on every client install, including the ones where nobody has armed usbguard,
146 + // and on those it has to be inert forever.
147 + #[test]
148 + fn the_gate_only_ever_starts_usbguard_to_undo_its_own_stop() {
149 + let code = code(&script());
150 + let starts: Vec<&str> = code
151 + .lines()
152 + .filter(|line| line.contains("systemctl start"))
153 + .collect();
154 + assert_eq!(
155 + starts.len(),
156 + 1,
157 + "more than one place starts a unit; the gate may only start usbguard, \
158 + and only to undo a stop it recorded: {starts:?}",
159 + );
160 +
161 + let closing = code
162 + .split_once("close_gate()")
163 + .expect("the gate still has a close_gate function")
164 + .1
165 + .split_once("\n}")
166 + .expect("close_gate is a shell function")
167 + .0;
168 + assert!(
169 + closing.contains("systemctl start"),
170 + "the one start is not in close_gate",
171 + );
172 + assert!(
173 + code.contains("[ -e \"$STAMP\" ]"),
174 + "nothing guards close_gate on the stamp, so the gate could arm \
175 + enforcement on a machine where nobody turned it on",
176 + );
177 + }
178 +
179 + // A keyboard the policy has never heard of is denied the moment enforcement
180 + // resumes, which returns the count to zero and reopens the gate. Without a
181 + // latch that is a loop several times a second.
182 + #[test]
183 + fn a_flapping_gate_latches_open_rather_than_looping() {
184 + let code = code(&script());
185 + assert!(
186 + code.contains("flapping"),
187 + "the flap guard is gone; a keyboard outside the policy makes the gate \
188 + open and close forever",
189 + );
190 + assert!(code.contains("FLAP_WINDOW"), "the flap window is gone",);
191 + }
192 +
193 + // A latch that nothing can clear is worse than no latch: enforcement back on,
194 + // the release valve permanently disabled, and nothing saying so. It has to
195 + // clear on the daemon being armed rather than on the stamp being gone, because
196 + // a person running `systemctl start usbguard` by hand removes no stamp.
197 + #[test]
198 + fn the_latch_clears_when_a_person_re_arms_enforcement() {
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("if armed;"),
209 + "the latch branch no longer clears on the daemon being armed, so a \
210 + machine whose owner turned enforcement back on keeps a disabled gate \
211 + forever",
212 + );
213 + assert!(
214 + latched.contains("rm -f \"$LATCH\""),
215 + "nothing in the latch branch removes the latch",
216 + );
217 + }
218 +
219 + // Removing the stamp before knowing the daemon came back loses the one record
220 + // that the gate is what suspended enforcement, on exactly the machine where
221 + // resuming it failed.
222 + #[test]
223 + fn closing_the_gate_drops_the_stamp_only_if_the_daemon_really_started() {
224 + let code = code(&script());
225 + let closing = code
226 + .split_once("close_gate()")
227 + .expect("the gate still has a close_gate function")
228 + .1
229 + .split_once("\n}")
230 + .expect("close_gate is a shell function")
231 + .0;
232 + let start = closing
233 + .find("systemctl start")
234 + .expect("close_gate no longer starts the daemon");
235 + let unstamp = closing
236 + .find("rm -f \"$STAMP\"")
237 + .expect("close_gate no longer removes the stamp");
238 + assert!(
239 + start < unstamp,
240 + "close_gate removes the stamp before starting the daemon, so a failed \
241 + start leaves enforcement off with nothing recording that the gate did it",
242 + );
243 + }
244 +
245 + // ---- the unit and the rule ----
246 +
247 + // The gate has to be continuous. A boot-only check has already passed by the
248 + // time the keyboard dies.
249 + #[test]
250 + fn the_udev_rule_covers_both_edges_and_starts_the_unit() {
251 + let rule = rule();
252 + let line = rule
253 + .lines()
254 + .find(|line| line.starts_with("SUBSYSTEM=="))
255 + .expect("the rule still has a match line");
256 + assert!(
257 + line.contains("\"input\""),
258 + "the rule no longer watches input"
259 + );
260 + assert!(
261 + line.contains("add") && line.contains("remove"),
262 + "the rule does not cover both edges, and remove is the one where the \
263 + machine loses its last keyboard: {line}",
264 + );
265 + assert!(
266 + line.contains("systemctl start") && line.contains("alloy-usb-gate.service"),
267 + "the rule no longer starts the unit: {line}",
268 + );
269 + assert!(
270 + !line.contains("RUN+=\"/usr/bin/alloy-usb-gate"),
271 + "the rule runs the script directly from udev, which kills a RUN program \
272 + that outlives its event and deadlocks on the udevadm call inside it",
273 + );
274 + }
275 +
276 + // udev's input_id builtin is what sets ID_INPUT_KEYBOARD. A rule sorting ahead
277 + // of it reads the property before anything wrote it, counts zero keyboards, and
278 + // opens the gate on a healthy machine.
279 + #[test]
280 + fn the_udev_rule_sorts_after_the_builtin_that_sets_the_property_it_reads() {
281 + let name = "70-alloy-usb-gate.rules";
282 + let prefix: u32 = name
283 + .split('-')
284 + .next()
285 + .and_then(|number| number.parse().ok())
286 + .expect("the rule is numbered");
287 + assert!(
288 + prefix >= 70,
289 + "the rule sorts before udev's input_id builtin at 60-input-id.rules, so \
290 + ID_INPUT_KEYBOARD is unset when the gate reads it",
291 + );
292 + }
293 +
294 + #[test]
295 + fn the_unit_runs_the_gate_and_is_enabled_on_the_client() {
296 + // Comments dropped, because the unit explains in prose why it carries no
297 + // `Restart=`, and that sentence contains the string this is looking for.
298 + let unit = code(&unit());
299 + assert!(
300 + unit.contains("ExecStart=/usr/bin/alloy-usb-gate"),
301 + "the unit no longer runs the gate",
302 + );
303 + assert!(
304 + unit.contains("Type=oneshot"),
305 + "the unit is no longer a oneshot; it is edge-triggered and must not linger",
306 + );
307 + assert!(
308 + !unit.contains("Restart="),
309 + "the unit restarts, which for an edge-triggered oneshot is a loop",
310 + );
311 +
312 + let preset = repo("etc/systemd/system-preset/50-alloy.preset");
313 + assert!(
314 + preset.contains("enable alloy-usb-gate.service"),
315 + "50-alloy.preset no longer enables the gate, so it runs on no machine",
316 + );
317 + }
318 +
319 + // The ruling's split, and it is the counter-intuitive direction: the profile
320 + // with no keyboard is the one that must not have the gate.
321 + #[test]
322 + fn the_containerfile_asserts_the_gate_on_both_profiles() {
323 + let containerfile = repo("Containerfile");
324 + let block = containerfile
325 + .split_once("gate=/usr/bin/alloy-usb-gate")
326 + .expect("the Containerfile still checks the gate")
327 + .1
328 + .split_once("\n\n")
329 + .expect("the block ends")
330 + .0;
331 + assert!(
332 + block.contains("test -x \"$gate\""),
333 + "the client branch no longer proves the gate is present and executable",
334 + );
335 + assert!(
336 + block.contains("rm -f \"$gate\""),
337 + "the server branch no longer removes the gate, so a keyboardless machine \
338 + would hold enforcement off forever",
339 + );
340 + assert!(
341 + block.contains("test ! -e \"$unit\""),
342 + "the server branch removes the gate without proving it is gone",
343 + );
344 + }
@@ -1,0 +1,47 @@
1 + # Drop USB enforcement while this machine has no keyboard.
2 + #
3 + # Step 4 of the `alloy usb` work. The reasoning, the measurements and the
4 + # ordering all live in usr/bin/alloy-usb-gate; this file is only what runs it.
5 + #
6 + # Two triggers, and both are needed.
7 + #
8 + # The boot-time one is this unit's [Install]. It answers the machine that boots
9 + # with its keyboard already denied — the state a person actually arrives in,
10 + # since the policy was written on a day when a different keyboard was attached.
11 + #
12 + # The continuous one is etc/udev/rules.d/70-alloy-usb-gate.rules, which starts
13 + # this unit on every input add and remove. A keyboard that dies mid-session is
14 + # the same lockout with a running session behind it, and a boot-only check would
15 + # already have passed. The rule starts the unit rather than running the script
16 + # from `RUN+=`: udev kills a RUN program that outlives its event, and the script
17 + # calls systemctl and udevadm, both of which are the wrong things to do from
18 + # inside udev's own context.
19 + #
20 + # Ordering is deliberately loose. This has to be able to run at any moment,
21 + # because the event it responds to happens at any moment, so it orders after
22 + # nothing except the units that make its two tools work.
23 + #
24 + # It cannot arm anything: the script only ever starts usbguard to undo a stop it
25 + # performed itself. So enabling this on a machine where enforcement was never
26 + # turned on is a no-op forever, which is what makes it safe to enable on every
27 + # client install.
28 +
29 + [Unit]
30 + Description=Suspend USB device authorization while no keyboard is present
31 + Documentation=https://makenot.work/git/max/alloy
32 + After=systemd-udevd.service
33 + Wants=systemd-udevd.service
34 + # On a machine with no usbguard at all the script reads the keyboard count and
35 + # exits, which is harmless but pointless. The condition says so out loud rather
36 + # than leaving a unit that runs on every input event to do nothing.
37 + ConditionPathExists=/usr/bin/usbguard
38 +
39 + [Service]
40 + Type=oneshot
41 + ExecStart=/usr/bin/alloy-usb-gate
42 + # The script is best-effort throughout and returns 0 on every path it means to
43 + # take, so a non-zero here is a real fault worth seeing in the journal. No
44 + # Restart=: this is edge-triggered, and the next input event runs it again.
45 +
46 + [Install]
47 + WantedBy=multi-user.target