#!/bin/sh
# alloy-usb-gate — drop USB enforcement while this machine has no keyboard.
#
# Step 4 of the `alloy usb` work, and the part that makes deny-unknown safe to
# turn on at all. USBGuard's default posture (deny anything matching no rule)
# is correct right up to the moment the thing it denies is how the person types,
# and then it is a machine nobody can log into. This is the release valve:
# whenever the machine has zero usable keyboards, enforcement comes off and the
# console says so.
#
# Continuous rather than boot-only, which is why a udev rule drives it as well
# as a boot-time run. A keyboard that dies mid-session is the same lockout with
# a running session behind it, and a check that only ran at boot would sit there
# having already passed.
#
# ## What counts as a keyboard, and it is the one thing worth measuring twice
#
# `ID_INPUT_KEYBOARD`, which udev's own `input_id` builtin sets, and NOT
# `ID_INPUT_KEY`. Measured on fw13 2026-08-22, and the difference is the whole
# check:
#
#     ID_INPUT_KEY=1                        Power Button
#     ID_INPUT_KEY=1                        Video Bus
#     ID_INPUT_KEY=1                        Wireless Radio Control
#     ID_INPUT_KEY=1                        Consumer Control
#     ID_INPUT_KEY=1 ID_INPUT_KEYBOARD=1    AT Translated Set 2 keyboard
#     ID_INPUT_KEY=1 ID_INPUT_KEYBOARD=1    8BitDo Retro Keyboard Receiver
#
# Four things on this laptop emit key events and are not keyboards. Counting
# them would keep the gate shut on a machine whose only real keyboard had just
# been denied, which is the inverted failure and the expensive one: the gate
# would report itself working while the machine was already lost.
#
# The same reading answers the i8042 half the ruling asks for, and answers it
# by construction rather than by a special case. The internal Framework
# keyboard is `AT Translated Set 2 keyboard` at `isa0060/serio0`, BUS_I8042 and
# not BUS_USB, so USBGuard has no jurisdiction over it; it is present in the
# input subsystem no matter what the USB policy says, so it is counted, so the
# gate stays shut. A check written over `/sys/bus/usb` instead would see no USB
# keyboard on a Framework and open the gate permanently on every one of them.
#
# A denied USB keyboard is not in this list either, and that is also by
# construction: deauthorizing a device unbinds its interfaces, so it stops
# being an input device at all. So "how many keyboards can this person type on"
# and "how many keyboards does the kernel show" are the same question.
#
# ## Suspending enforcement is not stopping the daemon
#
# Measured on usbguard-1.1.4-1.fc43 rather than assumed, and it is the finding
# that shaped this script: `RestoreControllerDeviceState=false` in the shipped
# config, and the shipped unit has no `ExecStop` at all. Nothing restores device
# authorization when the daemon goes away. So a gate that stopped the daemon and
# stopped there would leave every denied device denied, including the keyboard
# it opened for, and would do nothing whatsoever for the person it exists to
# rescue.
#
# Opening the gate is therefore three acts in this order: stop the daemon, so it
# does not immediately undo the next step; authorize everything on the bus; and
# set `authorized_default` so a keyboard plugged in *after* the gate opened comes
# up usable too. Everything, not just the keyboards — a denied device exposes no
# interfaces, so there is no way to tell which of them was going to be the
# keyboard, and the machine is already in the state this exists to get out of.
#
# ## It never arms anything
#
# The gate closes by starting `usbguard.service`, and it will only ever do that
# to undo an open it performed itself, which is what the stamp file records. On
# a machine where enforcement was never turned on there is no stamp, so this
# script reads the keyboard count and does nothing at all, forever. That is
# deliberate: arming is the person's decision from `alloy usb` (or the preset
# line, once the bench tests have run), and a boot-path script that could turn
# deny-everything on by itself would be a far worse hazard than the one this
# closes.
#
# ## The flap guard
#
# The ruling says enforcement resumes when a keyboard is present. Ordinarily
# that is right and uneventful: the policy was seeded from the devices that were
# attached, so unplugging a known keyboard and plugging it back in resumes
# cleanly. The case it does not cover is a keyboard the policy has never heard
# of. There the sequence is: gate closes, daemon starts, daemon denies the new
# keyboard, keyboard count returns to zero, gate opens. Forever, several times a
# second.
#
# So a close that is followed by another open inside FLAP_WINDOW seconds latches
# the gate open and says why on the console. Failing open is the correct
# direction for a lockout valve, and a machine that is honestly unprotected and
# usable beats one thrashing its own USB bus.
#
# ## Keeping the rescue keyboard, ruled 2026-08-27
#
# The latch is not the end state any more. Once the gate has latched and the
# keyboard has come back onto the bus, the gate writes a permanent allow rule
# for it and resumes enforcement, so the machine ends up protected and usable
# with no command for the user to find. GoingsOn alloy `94ae2ea0`.
#
# **Only while latched, and that is the whole safety property.** The latch means
# this machine has already demonstrated it has no other keyboard, which is the
# state where an attacker's prerequisite — make every existing keyboard
# disappear — has already been met, so refusing buys nothing; and it is the only
# state where somebody is certainly standing at the machine plugging something
# in. A rule written outside it would hand any keyboard-shaped device a
# permanent allow on a machine that was working fine.
#
# The threat model, recorded so it is not re-litigated: an attacker with
# physical access who can make every existing keyboard disappear gets a
# permanent allow for a keyboard-shaped device. On a laptop the internal i8042
# keyboard is not removable without opening the machine, so this mostly bites
# desktops and docked machines. Taken deliberately, against leaving a locked-out
# user with enforcement off and a command they can only learn from a console
# message.
#
# It happens on the run after the latch rather than in the same one, because at
# the moment of latching the keyboard is denied and therefore invisible: a
# denied device exposes no interfaces, so there is nothing to write a rule
# about. Opening the gate authorizes it, the kernel re-enumerates it, and that
# is a udev event, which is another run of this script — with the keyboard
# present and identifiable.
#
# One attempt per latch, recorded in RESCUED. If the rule is written and the
# daemon still denies the keyboard, the gate reopens and stays open rather than
# writing another rule every minute at a device it is not actually matching.
#
# Dependency-light on purpose: sh, udevadm, systemctl, and usbguard for the
# one policy write. It runs on a machine
# whose keyboard has just gone away, which is not the moment to discover that
# something it reaches for is missing.

set -u

STATE=/run/alloy
STAMP=$STATE/usb-gate-open      # exists => this script suspended enforcement
CLOSED=$STATE/usb-gate-closed   # when the gate last resumed it, for the flap guard
LATCH=$STATE/usb-gate-latched   # exists => flapping, stay open and stop deciding
RESCUED=$STATE/usb-gate-rescued # a permanent rule was already written this latch
FLAP_WINDOW=60

UNIT=usbguard.service
POLICY=/etc/usbguard/rules.conf

# Every write here is best-effort and nothing is fatal. This is on the recovery
# path, and a gate that aborts halfway leaves the machine in the state it was
# called to fix.
say() {
    # VT1 is where the greeter is, so that is where a person with no keyboard is
    # looking. /dev/console is the fallback for a machine that is not using VT1
    # for the greeter, and for an install that has not reached one yet.
    printf '%s\n' "$1" > /dev/tty1 2>/dev/null \
        || printf '%s\n' "$1" > /dev/console 2>/dev/null \
        || true
    # The journal gets it too, for the case where the screen scrolled or nobody
    # was in the room.
    printf 'alloy-usb-gate: %s\n' "$1" >&2
}

# How many devices the kernel considers a keyboard. See the header: the property
# is ID_INPUT_KEYBOARD, and ID_INPUT_KEY is the trap.
keyboard_count() {
    count=0
    for device in /sys/class/input/input*; do
        [ -e "$device" ] || continue
        if udevadm info --query=property --path="${device#/sys}" 2>/dev/null \
            | grep -qx 'ID_INPUT_KEYBOARD=1'; then
            count=$((count + 1))
        fi
    done
    printf '%s\n' "$count"
}

# Undo every deauthorization on the bus, and stop new attachments arriving
# denied. Only ever called with the daemon already stopped.
authorize_everything() {
    for flag in /sys/bus/usb/devices/*/authorized_default; do
        [ -w "$flag" ] && printf '1\n' > "$flag" 2>/dev/null
    done
    for flag in /sys/bus/usb/devices/*/authorized; do
        [ -w "$flag" ] && printf '1\n' > "$flag" 2>/dev/null
    done
    return 0
}

armed() {
    systemctl is-active --quiet "$UNIT" 2>/dev/null
}

# The USB ids of every keyboard the kernel can see, one `vvvv:pppp` per line.
#
# Read off the same input-subsystem query the count uses, so the two agree about
# what a keyboard is. A keyboard with no USB ids is an internal one — the i8042
# on a laptop — and it needs no rule, because USBGuard has no jurisdiction over
# it. It drops out here by having no properties to print rather than by a case.
keyboard_usb_ids() {
    for device in /sys/class/input/input*; do
        [ -e "$device" ] || continue
        udevadm info --query=property --path="${device#/sys}" 2>/dev/null \
            | awk -F= '
                /^ID_INPUT_KEYBOARD=1$/ { keyboard = 1 }
                /^ID_VENDOR_ID=/        { vendor = $2 }
                /^ID_MODEL_ID=/         { model = $2 }
                END { if (keyboard && vendor != "" && model != "") print vendor ":" model }
            '
    done | sort -u
}

# Write a permanent allow rule for every keyboard now on the bus.
#
# The rule text comes from `usbguard generate-policy` rather than being composed
# here. It is the one verb that does not go through the daemon's IPC socket
# (measured on usbguard-1.1.4), which is what makes it the only one available at
# this moment: the gate is open, so the daemon is stopped. It also emits exactly
# the fields usbguard matches on, which a rule assembled by hand out of sysfs
# would be guessing at.
#
# Selected by USB id, so what is kept is "this model of keyboard" rather than
# every device attached while the machine was in trouble. `DeviceRulesWithPort`
# is false by default, so the rule is not tied to the socket it was plugged
# into, which is what a keyboard that moves ports needs.
#
# Returns success only if something was actually appended. A failure here leaves
# the gate open, which is the correct direction: unprotected and usable.
allow_keyboards() {
    ids=$(keyboard_usb_ids)
    [ -n "$ids" ] || return 1
    generated=$(usbguard generate-policy 2>/dev/null) || return 1
    [ -n "$generated" ] || return 1

    # A rule is one line and lines are what is being iterated, so IFS is set to
    # a newline for the loop rather than left at whitespace: every generated
    # rule contains spaces and a device name that may contain several.
    written=0
    saved_ifs=$IFS
    IFS='
'
    for id in $ids; do
        for line in $(printf '%s\n' "$generated" | grep -F "id $id "); do
            [ -n "$line" ] || continue
            # Idempotent: a second run over the same keyboard must not grow the
            # policy file a line at a time.
            if [ -e "$POLICY" ] && grep -qxF "$line" "$POLICY" 2>/dev/null; then
                continue
            fi
            printf '%s\n' "$line" >> "$POLICY" 2>/dev/null || { IFS=$saved_ifs; return 1; }
            written=$((written + 1))
        done
    done
    IFS=$saved_ifs

    [ "$written" -gt 0 ] || return 1
    say ""
    say "The keyboard you attached has been added to this machine's USB policy"
    say "permanently, and enforcement is coming back on with it allowed:"
    say ""
    for id in $ids; do
        say "    $id"
    done
    say ""
    say "To undo that, remove its line from $POLICY."
    say ""
    return 0
}

open_gate() {
    # Order matters: stopping first is what keeps the daemon from re-denying
    # each device as the loop below authorizes it.
    systemctl stop "$UNIT" 2>/dev/null || true
    authorize_everything
    mkdir -p "$STATE" 2>/dev/null || true
    : > "$STAMP" 2>/dev/null || true
    say ""
    say "USB device authorization is OFF: this machine has no keyboard."
    say ""
    say "Every USB device has been re-authorized so you can attach one and"
    say "type. Enforcement comes back by itself once a keyboard is present."
    say ""
}

close_gate() {
    # The timestamp goes down before the start, because the flap it feeds is
    # measured from the attempt rather than from a success.
    mkdir -p "$STATE" 2>/dev/null || true
    date +%s > "$CLOSED" 2>/dev/null || true
    # The stamp comes off only if the daemon really came back. Removing it
    # first and then failing to start would leave a machine with enforcement
    # off, the bus authorized, and nothing recording that the gate is what did
    # it — so the next run would see a state it thinks it never made and leave
    # it there.
    if systemctl start "$UNIT" 2>/dev/null; then
        # RESCUED goes with it: enforcement is back on, so the episode this
        # gate was in is over and a future latch gets its own one attempt.
        rm -f "$STAMP" "$RESCUED" 2>/dev/null || true
        say "USB device authorization is back on: a keyboard is present."
    else
        say "A keyboard is present, but USB device authorization could not be"
        say "turned back on. It stays off. See: systemctl status $UNIT"
    fi
}

# A close followed by another open inside the window means the keyboard that
# closed the gate is not in the policy, so resuming denies it and we are back
# here. See the header.
flapping() {
    [ -r "$CLOSED" ] || return 1
    closed_at=$(cat "$CLOSED" 2>/dev/null) || return 1
    now=$(date +%s 2>/dev/null) || return 1
    case "$closed_at" in
        ''|*[!0-9]*) return 1 ;;
    esac
    [ $((now - closed_at)) -lt "$FLAP_WINDOW" ]
}

latch() {
    mkdir -p "$STATE" 2>/dev/null || true
    : > "$LATCH" 2>/dev/null || true
    say ""
    say "USB device authorization is OFF and staying off."
    say ""
    say "The keyboard you attached is not in this machine's USB policy, so"
    say "turning enforcement back on denies it again. Rather than do that in"
    say "a loop, it is left off."
    say ""
    say "The keyboard is being re-authorized now. As soon as the kernel sees"
    say "it again, it is added to the policy permanently and enforcement comes"
    say "back on by itself, with the keyboard working."
    say ""
    say "If that does not happen, enforcement stays off and this is the way in:"
    say ""
    say "    alloy usb"
    say ""
}

keyboards=$(keyboard_count)

if [ -e "$LATCH" ]; then
    # Latched open after a flap. The only thing that supersedes it is a person
    # turning enforcement back on themselves, from `alloy usb` or by hand —
    # which means they have dealt with the keyboard the policy did not know,
    # and the gate goes back to being an ordinary gate.
    #
    # Reading that off the daemon rather than off a second stamp matters. An
    # earlier version cleared the latch only when the stamp was gone, and the
    # stamp is not removed by a person running `systemctl start usbguard`, so
    # the latch survived forever and the gate never protected that machine
    # again: enforcement on, valve permanently disabled, and nothing said so.
    if armed; then
        rm -f "$LATCH" "$STAMP" "$RESCUED" 2>/dev/null || true
    elif [ "$keyboards" -gt 0 ] && [ ! -e "$RESCUED" ]; then
        # The rescue keyboard is back on the bus, because opening the gate
        # authorized it. This is the one moment a permanent rule may be
        # written: see the header. The stamp goes down before the attempt, so a
        # write that appears to work and does not still costs one round rather
        # than one per minute forever.
        : > "$RESCUED" 2>/dev/null || true
        if allow_keyboards; then
            rm -f "$LATCH" 2>/dev/null || true
            close_gate
        else
            say ""
            say "The keyboard could not be added to the USB policy, so device"
            say "authorization stays off. See: alloy usb"
            say ""
        fi
        exit 0
    else
        exit 0
    fi
fi

if [ "$keyboards" -eq 0 ]; then
    if armed; then
        if flapping; then
            open_gate
            latch
        else
            open_gate
        fi
    fi
    # Not armed and no keyboard: nothing to suspend, and nothing to say. The
    # machine is in whatever state its owner left it in.
    exit 0
fi

if [ -e "$STAMP" ]; then
    close_gate
fi

exit 0
