#!/bin/bash # omarchy:summary=Manage USB authorization from the start of kernel boot # omarchy:args= # omarchy:requires-sudo=true # omarchy:hidden=true set -euo pipefail drop_in=/etc/limine-entry-tool.d/usb-authorization.conf setting='KERNEL_CMDLINE[default]+=" usbcore.authorized_default=0"' limine_conf=/boot/limine.conf limine_defaults=/etc/default/limine boot_path=/boot kernel_parameter=usbcore.authorized_default=0 snapshot_begin='# Omarchy USB authorization begin' snapshot_setting='SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' snapshot_end='# Omarchy USB authorization end' usb_authorization_cmdline_has_parameter() { local cmdline="$1" parameter="$2" word for word in $cmdline; do [[ $word == "$parameter" ]] && return 0 done return 1 } usb_authorization_secure_boot_enabled() { local value variable for variable in /sys/firmware/efi/efivars/SecureBoot-*; do [[ -r $variable ]] || continue value=$(od -An -t u1 -j 4 -N 1 "$variable") value=${value//[[:space:]]/} [[ $value == "1" ]] && return 0 done return 1 } usb_authorization_verify_boot_entries() { local expected="$1" config="$2" esp="$3" local machine_id="${4:-$(&2 return 1 fi paths=$(mktemp /tmp/omarchy-usb-boot-paths.XXXXXXXXXX) section=$(mktemp /tmp/omarchy-usb-boot-cmdline.XXXXXXXXXX) if ! awk -v expected="$expected" -v machine_id="$machine_id" -v parameter="$kernel_parameter" ' function contains_parameter(value, count, fields, field_index) { count = split(value, fields, /[[:space:]]+/) for (field_index = 1; field_index <= count; field_index++) { if (fields[field_index] == parameter) return 1 } return 0 } function finish_entry(found) { if (!target_os || !is_kernel) return entries++ if (has_cmdline) { found = contains_parameter(cmdline) if ((expected == "enabled" && !found) || (expected == "disabled" && found)) bad = 1 } if (path == "") { bad = 1 } else { print (has_cmdline ? "external" : "embedded") "\t" path } } /^\/[^/]/ { finish_entry() target_os = 0 is_kernel = 0 has_cmdline = 0 cmdline = "" path = "" next } /^[[:space:]]*comment:[[:space:]]*machine-id=/ { target_os = index($0, "machine-id=" machine_id) > 0 next } /^[[:space:]]*\/\// { finish_entry() is_kernel = 0 has_cmdline = 0 cmdline = "" path = "" next } /^[[:space:]]*comment:[[:space:]]*kernel-id=/ { is_kernel = target_os next } is_kernel && /^[[:space:]]*path:[[:space:]]*/ { path = $0 sub(/^[[:space:]]*path:[[:space:]]*/, "", path) next } is_kernel && /^[[:space:]]*cmdline:[[:space:]]*/ { has_cmdline = 1 cmdline = $0 sub(/^[[:space:]]*cmdline:[[:space:]]*/, "", cmdline) next } END { finish_entry() if (entries == 0) bad = 1 exit bad } ' "$config" >"$paths"; then rm -f "$paths" "$section" echo "The generated Limine kernel entries do not all have USB authorization $expected." >&2 return 1 fi while IFS=$'\t' read -r mode path; do if [[ $path != 'boot():/'* ]]; then rm -f "$paths" "$section" echo "Cannot verify the kernel image referenced by Limine: $path" >&2 return 1 fi image="$esp/${path#'boot():/'}" image=${image%%#*} if [[ ! -r $image ]]; then rm -f "$paths" "$section" echo "Cannot verify the kernel image referenced by Limine: $image" >&2 return 1 fi if [[ $path == *'#'* ]]; then expected_hash=${path##*#} actual_hash=$(b2sum "$image") actual_hash=${actual_hash%% *} if [[ $actual_hash != "$expected_hash" ]]; then rm -f "$paths" "$section" echo "The verification hash for $image does not match the generated Limine entry." >&2 return 1 fi fi : >"$section" if ! objcopy -O binary --only-section=.cmdline "$image" "$section" 2>/dev/null; then [[ $mode == "external" ]] && continue rm -f "$paths" "$section" echo "Cannot inspect the embedded kernel command line in $image." >&2 return 1 fi embedded_cmdline=$(tr '\0' '\n' <"$section") # Without Secure Boot, Limine's external command line takes precedence. # Under Secure Boot, systemd-stub uses a nonempty embedded .cmdline. if [[ $mode == "external" && $secure_boot == "disabled" ]]; then continue fi # A signed UKI can ignore Limine's external cmdline and use its embedded # .cmdline instead. If one exists, both representations must agree. if [[ -z $embedded_cmdline ]]; then if [[ $mode == "external" || $expected == "disabled" ]]; then continue fi rm -f "$paths" "$section" echo "The generated kernel image $image has no verifiable kernel command line." >&2 return 1 fi if [[ $expected == "enabled" ]]; then if ! usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then rm -f "$paths" "$section" echo "The generated kernel image $image does not enable boot-time USB authorization." >&2 return 1 fi elif usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then rm -f "$paths" "$section" echo "The generated kernel image $image still enables boot-time USB authorization." >&2 return 1 fi done <"$paths" rm -f "$paths" "$section" } usb_authorization_rebuild_and_verify() { local expected="$1" config="$2" esp="$3" local machine_id="${4:-$(&2 return 1 fi temporary=$(mktemp "${config}.XXXXXXXXXX") cp --preserve=mode,ownership "$config" "$temporary" { cat "$config" printf '\n%s\n%s\n%s\n' "$snapshot_begin" "$snapshot_setting" "$snapshot_end" } >"$temporary" mv -f "$temporary" "$config" } usb_authorization_disable_snapshot_setting() { local config="$1" status temporary if usb_authorization_snapshot_block_status "$config"; then status=0 else status=$? fi (( status == 1 )) && return 0 if (( status != 0 )); then echo "Refusing to modify the USB authorization block in $config." >&2 return 1 fi temporary=$(mktemp "${config}.XXXXXXXXXX") cp --preserve=mode,ownership "$config" "$temporary" awk -v begin="$snapshot_begin" ' $0 == begin { skip = 2; next } skip > 0 { skip--; next } { print } ' "$config" >"$temporary" mv -f "$temporary" "$config" } usb_authorization_rewrite_boot_cmdlines() { local expected="$1" config="$2" machine_id="${3:-$("$temporary"; then rm -f "$backup" "$temporary" return 1 fi mv -f "$temporary" "$config" if ! limine-enroll-config; then mv -f "$backup" "$config" limine-enroll-config >/dev/null 2>&1 || true echo "Restored $config because its bootloader verification data could not be updated." >&2 return 1 fi rm -f "$backup" } usb_authorization_rewrite_snapshot_manifest() { local expected="$1" manifest="$2" temporary [[ -e $manifest ]] || return 0 temporary=$(mktemp "${manifest}.omarchy-usb.XXXXXXXXXX") if ! cp --preserve=mode,ownership "$manifest" "$temporary"; then rm -f "$temporary" return 1 fi # Limine uses the cached lines when present, and structured command lines # otherwise. Update both, including nested kernels and restore entries. if ! jq --arg expected "$expected" --arg parameter "$kernel_parameter" ' def cmdline: if type != "string" then error("Invalid snapshot command line") else [splits("[[:space:]]+") | select(length > 0 and . != $parameter)] | if $expected == "enabled" then . + [$parameter] else . end | join(" ") end; def lines: map(if test("^\\s*(cmdline|kernel_cmdline):"; "i") then capture("^(?\\s*(?:cmdline|kernel_cmdline):\\s*)(?.*)$"; "i") | .prefix + (.value | cmdline) else . end); def kernel: .cmdlineDetails |= map(.cmdline |= cmdline | .snapshotCmdline |= cmdline) | .allInConfig |= lines | .allInSnapshotConfig |= lines | .subKernels |= map(kernel); if .jsonFormatVersion != "1.3.0" or (.snapshotEntries | type) != "array" then error("Unsupported Limine snapshot manifest format") else .snapshotEntries |= map(.kernelEntries |= map(kernel)) end ' "$manifest" >"$temporary"; then rm -f "$temporary" echo "Cannot update stored USB boot policy in $manifest." >&2 return 1 fi mv -f "$temporary" "$manifest" } usb_authorization_verify_signature() { # sbctl verify can return zero for unsigned or unreadable files. Check its # structured result, including the exact path, instead of just the exit code. if ! sbctl verify --json "$1" | jq -e --arg file "$1" \ 'length == 1 and .[0].file_name == $file and .[0].is_signed == 1' >/dev/null; then echo "Cannot verify the Secure Boot signature of $1 using this machine's signing keys." >&2 return 1 fi } # Called with Limine's boot-partition lock held. Prepare signed copies of # conflicting historical UKIs before changing the selected boot policy. usb_authorization_reconcile_archived_images() ( local expected="$1" config="$2" esp="$3" machine_id="$4" local cache="${5:-/var/cache/boot/$machine_id/lss/snapshots.json}" local history="$esp/$machine_id/limine_history" staging manifest names name image local cmdline hash_command old_digest new_digest old_hash new_hash replacement index=0 local published=0 committed=0 section layout image_base image_size alignment address local -a manifests=() originals=() usb_authorization_secure_boot_enabled || return 0 [[ -d $history ]] || return 0 staging=$(mktemp -d "$esp/.omarchy-usb-uki.XXXXXXXXXX") || return 1 trap ' if (( published && ! committed )); then for ((index=0; index<${#originals[@]}; index++)); do cp --preserve=mode,ownership "$staging/original.$index" "${originals[index]}" || true done limine-enroll-config >/dev/null 2>&1 || true fi rm -rf "$staging" ' EXIT for manifest in "$history/snapshots.json" "$history/snapshots.json.old" "$cache"; do [[ -f $manifest ]] || continue jq -e '.jsonFormatVersion == "1.3.0" and (.snapshotEntries | type == "array")' "$manifest" >/dev/null || return 1 manifests+=("$manifest") done (( ${#manifests[@]} )) || return 0 names=$(jq -sr '[.[] | .snapshotEntries[] | .. | objects | .imageDetails? // empty | .[] | .fileHashName] | unique[]' "${manifests[@]}") || return 1 printf '[]\n' >"$staging/replacements.json" while IFS= read -r name; do [[ -n $name ]] || continue # Only image names, never arbitrary paths supplied by a manifest. if [[ $name == */* || $name == "." || $name == ".." ]]; then echo "Invalid archived kernel image name: $name" >&2 return 1 fi image="$history/$name" [[ -f $image && ! -L $image ]] || continue : >"$staging/cmdline" objcopy -O binary --only-section=.cmdline "$image" "$staging/cmdline" 2>/dev/null || continue cmdline=$(tr '\0' '\n' <"$staging/cmdline") [[ -n $cmdline ]] || continue if usb_authorization_cmdline_has_parameter "$cmdline" "$kernel_parameter"; then [[ $expected == "disabled" ]] || continue else [[ $expected == "enabled" ]] || continue fi # Multi-profile images and signed PCR policies need their original builder. # Do not silently invalidate their additional boot/unlock guarantees. for section in .profile .pcrsig; do objcopy -O binary --only-section="$section" "$image" "$staging/section" || return 1 if [[ -s $staging/section ]]; then echo "Rebuild archived image $image with its original signing policy before changing USB boot protection ($section)." >&2 return 1 fi done if [[ ! $name =~ ^(.+)_(sha1|sha256|b3|xxh)_([0-9a-f]+)$ ]]; then echo "Cannot verify the content hash of archived image $image." >&2 return 1 fi case "${BASH_REMATCH[2]}" in sha1) hash_command=sha1sum ;; sha256) hash_command=sha256sum ;; b3) hash_command=b3sum ;; xxh) hash_command=xxhsum ;; esac old_digest=${BASH_REMATCH[3]} new_digest=$("$hash_command" "$image") || return 1 if [[ ${new_digest%% *} != "$old_digest" ]]; then echo "Archived image hash mismatch: $image" >&2 return 1 fi # Verify the old signature before signing a modified copy with this machine's key. usb_authorization_verify_signature "$image" || return 1 cp "$image" "$staging/unsigned.efi" || return 1 sbattach --remove "$staging/unsigned.efi" || return 1 # Preserve the embedded boot arguments and change only our exact USB # parameter. Do not turn a signed command line into an external override. printf '%s' "$cmdline" | jq -Rrs --arg parameter "$kernel_parameter" --arg expected "$expected" ' [scan("[^[:space:]]+|[[:space:]]+") | select(. != $parameter)] | join("") | sub("[[:space:]]+$"; "") | if $expected == "enabled" then . + " " + $parameter else . end ' >"$staging/new-cmdline" || return 1 printf '\0' >>"$staging/new-cmdline" # objcopy --update-section retains PE VirtualSize and can truncate a longer # command line. Add a fresh section after the existing virtual image instead. layout=$(objdump -p "$staging/unsigned.efi" | awk ' $1 == "ImageBase" { base = $2 } $1 == "SizeOfImage" { size = $2 } $1 == "SectionAlignment" { alignment = $2 } END { if (base !~ /^[0-9a-fA-F]+$/ || size !~ /^[0-9a-fA-F]+$/ || alignment !~ /^[0-9a-fA-F]+$/) exit 1 print "0x" base, "0x" size, "0x" alignment } ') || return 1 read -r image_base image_size alignment <<<"$layout" (( alignment > 0 )) || return 1 address=$((image_base + (image_size + alignment - 1) / alignment * alignment)) objcopy --remove-section=.cmdline "$staging/unsigned.efi" "$staging/without-cmdline.efi" || return 1 objcopy --add-section .cmdline="$staging/new-cmdline" \ --change-section-vma .cmdline="$address" \ --set-section-flags .cmdline=alloc,load,readonly,data,contents \ "$staging/without-cmdline.efi" "$staging/image.efi" || return 1 sbctl sign "$staging/image.efi" || return 1 usb_authorization_verify_signature "$staging/image.efi" || return 1 objcopy -O binary --only-section=.cmdline "$staging/image.efi" "$staging/cmdline" || return 1 cmp -s "$staging/cmdline" "$staging/new-cmdline" || return 1 new_digest=$("$hash_command" "$staging/image.efi") || return 1 replacement="${name%"$old_digest"}${new_digest%% *}" old_hash=$(b2sum "$image") || return 1 new_hash=$(b2sum "$staging/image.efi") || return 1 # Publish copies first: every old reference remains usable until its manifest changes. if [[ -e $history/$replacement || -L $history/$replacement ]]; then [[ ! -L $history/$replacement ]] && cmp -s "$staging/image.efi" "$history/$replacement" || return 1 else mv "$staging/image.efi" "$history/$replacement" || return 1 fi jq --arg old "$name" --arg new "$replacement" \ --arg old_hash "${old_hash%% *}" --arg new_hash "${new_hash%% *}" \ '. + [{old: $old, new: $new, old_hash: $old_hash, new_hash: $new_hash}]' \ "$staging/replacements.json" >"$staging/next.json" || return 1 mv "$staging/next.json" "$staging/replacements.json" || return 1 done <<<"$names" jq -e 'length > 0' "$staging/replacements.json" >/dev/null || return 0 originals=("$config" "${manifests[@]}") for ((index=0; index<${#originals[@]}; index++)); do cp --preserve=mode,ownership "${originals[index]}" "$staging/original.$index" || return 1 cp --preserve=mode,ownership "${originals[index]}" "$staging/updated.$index" || return 1 # Update cached menu lines, structured image metadata, restore paths and # both hashes. The new file name remains a valid Limine content address. if (( index == 0 )); then jq -Rrs --slurpfile replacements "$staging/replacements.json" ' split("\n") | map(reduce $replacements[0][] as $r (. ; if contains($r.old) then split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash) else . end)) | join("\n") ' "$staging/original.$index" >"$staging/updated.$index" || return 1 else jq --slurpfile replacements "$staging/replacements.json" ' walk(if type == "string" then reduce $replacements[0][] as $r (. ; split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash)) else . end) ' "$staging/original.$index" >"$staging/updated.$index" || return 1 fi done published=1 for ((index=0; index<${#originals[@]}; index++)); do mv -f "$staging/updated.$index" "${originals[index]}" || return 1 done limine-enroll-config || return 1 committed=1 ) usb_authorization_prepare_archived_images() ( local expected="$1" config="$2" esp="$3" machine_id="${4:-$(/run/lock/boot-partition.lock flock --timeout 30 9 || return 1 limine-snapper-sync --no-mutex --no-hooks || return 1 usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine_id" ) usb_authorization_sync_and_verify() ( local expected="$1" config="$2" esp="$3" local machine_id="${4:-$(/run/lock/boot-partition.lock flock --timeout 30 9 || return 1 # Recover/upgrade the manifest first, including backups if necessary. limine-snapper-sync --no-mutex --no-hooks || return 1 usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine_id" || return 1 for manifest in \ "$esp/$machine_id/limine_history/snapshots.json" \ "$esp/$machine_id/limine_history/snapshots.json.old" \ "/var/cache/boot/$machine_id/lss/snapshots.json"; do usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" || return 1 done usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine_id" || return 1 # Verify the regenerated menu, so synchronization cannot mask stale stored # command lines with a one-time edit to limine.conf. limine-snapper-sync --no-mutex --no-hooks || return 1 limine-enroll-config || return 1 usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" ) main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary if (( EUID != 0 )); then echo "Error: omarchy-usb-authorization-boot must run as root" >&2 exit 1 fi case "${1:-}" in enable) if usb_authorization_snapshot_block_status "$limine_defaults"; then snapshot_status=0 else snapshot_status=$? fi if (( snapshot_status == 2 )); then echo "Refusing to modify the USB authorization block in $limine_defaults." >&2 exit 1 fi # Quote the expected value because the Limine key contains glob brackets. if [[ -e $drop_in ]] && [[ $(<"$drop_in") != "$setting" ]]; then echo "Refusing to replace the existing $drop_in." >&2 exit 1 fi usb_authorization_prepare_archived_images enabled "$limine_conf" "$boot_path" || exit 1 usb_authorization_enable_snapshot_setting "$limine_defaults" if [[ ! -e $drop_in ]]; then install -d -m755 "${drop_in%/*}" temporary=$(mktemp "${drop_in}.XXXXXXXXXX") trap 'rm -f "$temporary"' EXIT printf '%s\n' "$setting" >"$temporary" chmod 644 "$temporary" mv -f "$temporary" "$drop_in" trap - EXIT fi if ! limine-mkinitcpio || ! usb_authorization_sync_and_verify enabled "$limine_conf" "$boot_path"; then echo "Boot-time USB authorization remains configured in $drop_in; rerun this command after fixing the boot-image error." >&2 exit 1 fi ;; disable) if usb_authorization_snapshot_block_status "$limine_defaults"; then had_snapshot_setting=1 else snapshot_status=$? if (( snapshot_status == 2 )); then echo "Refusing to modify the USB authorization block in $limine_defaults." >&2 exit 1 fi fi if [[ -e $drop_in ]]; then if [[ $(<"$drop_in") != "$setting" ]]; then echo "Refusing to remove the modified $drop_in." >&2 exit 1 fi had_drop_in=1 fi usb_authorization_prepare_archived_images disabled "$limine_conf" "$boot_path" || exit 1 if (( had_drop_in )); then rm -f "$drop_in" fi usb_authorization_disable_snapshot_setting "$limine_defaults" if ! limine-mkinitcpio || ! usb_authorization_sync_and_verify disabled "$limine_conf" "$boot_path"; then if (( had_drop_in )); then printf '%s\n' "$setting" >"$drop_in" chmod 644 "$drop_in" fi if (( had_snapshot_setting )); then usb_authorization_enable_snapshot_setting "$limine_defaults" fi echo "USBGuard remains required because the boot images could not be verified as default-allow." >&2 exit 1 fi ;; *) echo "Usage: omarchy-usb-authorization-boot " >&2 exit 2 ;; esac } if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@" fi