Fully revert USB and Thunderbolt device approval (#15091)
* Revert "Merge pull request #11874 from acrogenesis/feature/usb-device-authorization" This reverts commit5cb31317b4, reversing changes made to9d48fc402e. * Undo USB and Thunderbolt device approval on machines that ran it * Verify boot rebuilds, recover Thunderbolt journals and prove USBGuard ownership in the removal migration * Read the boot partition from ESP_PATH and stop on failed Bolt reads in the removal migration * Recover Bolt's original mode from the setup journal in the removal migration
This commit is contained in:
1 parent
9480d1b6a9
commit
defbf90e89
60 files changed
+557
-5986
No files matched your search
@@ -1174,15 +1174,6 @@ run_provisioning() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if omarchy-pkg-present usbguard; then
|
||||
log_step "enrolling the owner's USB devices"
|
||||
source "$OMARCHY_PATH/install/helpers/usb-authorization.sh"
|
||||
usb_authorization_provision_owner "$username"
|
||||
fi
|
||||
|
||||
log_step "enrolling the owner's Thunderbolt accessories"
|
||||
/usr/bin/omarchy-thunderbolt-authorization-admin owner
|
||||
|
||||
log_step "cleaning up provisioning state"
|
||||
cleanup_oem_state
|
||||
log_step "first-boot setup complete"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Restore Bolt's default authorization behavior
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
gum confirm "Restore Bolt's previous policy? New accessories may be authorized automatically when IOMMU protection is active." || exit 0
|
||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin disable
|
||||
systemctl --user disable --now omarchy-thunderbolt-authorization.service
|
||||
echo "Previous Bolt policy restored. Saved Omarchy trust is retained for re-enabling."
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Restore saved Thunderbolt firmware boot permissions
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
gum confirm "Restore the previous Thunderbolt firmware boot allowlists? Device approval will remain enabled." || exit 0
|
||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-disable
|
||||
echo "Previous firmware boot permissions restored and verified."
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Stop asking before new USB devices can access the system
|
||||
# omarchy:args=[--boot-only] [--yes]
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
assume_yes=0
|
||||
boot_only=0
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--yes) assume_yes=1 ;;
|
||||
--boot-only) boot_only=1 ;;
|
||||
-h | --help)
|
||||
echo "Usage: omarchy-remove-security-usb-authorization [--boot-only] [--yes]"
|
||||
echo
|
||||
echo "Disables Omarchy's USB approval prompts and restores the kernel's"
|
||||
echo "normal default-allow behavior."
|
||||
echo "Use --boot-only to keep USBGuard enabled after the system starts."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "omarchy-remove-security-usb-authorization: unknown option '$1'. Try --help." >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if (( boot_only )); then
|
||||
if (( ! assume_yes )); then
|
||||
gum style --foreground 212 --bold "Remove USB authorization from boot"
|
||||
echo
|
||||
echo "USBGuard will continue blocking unknown devices after its service starts."
|
||||
echo "Devices can reach kernel drivers earlier during boot."
|
||||
echo
|
||||
gum confirm "Disable USB authorization from kernel boot?" || exit 130
|
||||
fi
|
||||
|
||||
sudo omarchy-usb-authorization-boot disable
|
||||
echo
|
||||
echo "Boot-time USB authorization is disabled and takes effect after reboot."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
state_dir="$HOME/.local/state/omarchy/usb-authorization"
|
||||
unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service"
|
||||
|
||||
if (( ! assume_yes )); then
|
||||
gum style --foreground 212 --bold "Remove USB device authorization"
|
||||
echo
|
||||
echo "New USB devices will be allowed to bind to kernel drivers without approval."
|
||||
echo
|
||||
gum confirm "Disable USB device authorization?" || exit 130
|
||||
fi
|
||||
|
||||
# Rebuild a permissive boot image before stopping USBGuard. If rebuilding
|
||||
# fails, the command stops here and leaves the complete policy enabled.
|
||||
sudo omarchy-usb-authorization-boot disable
|
||||
|
||||
if omarchy-pkg-present usbguard; then
|
||||
# Keep the watcher and its IPC access until blocking has actually stopped.
|
||||
# In particular, a failed stop must not strand a running daemon without UI.
|
||||
sudo systemctl disable --now usbguard.service
|
||||
if systemctl is-active --quiet usbguard.service || systemctl is-enabled --quiet usbguard.service; then
|
||||
echo "USBGuard is still active or enabled; approval prompts have been preserved." >&2
|
||||
exit 1
|
||||
fi
|
||||
sudo omarchy-usb-authorization-restore-default
|
||||
sudo usbguard remove-user "$USER"
|
||||
fi
|
||||
|
||||
systemctl --user disable --now omarchy-usb-authorization.service 2>/dev/null || true
|
||||
rm -f "$unit"
|
||||
systemctl --user daemon-reload
|
||||
|
||||
if [[ -d $state_dir/requests ]]; then
|
||||
find "$state_dir/requests" -mindepth 1 -maxdepth 1 -type f -delete
|
||||
rmdir "$state_dir/requests" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -f "$state_dir/package-installed-by-omarchy" \
|
||||
"$state_dir/policy-generated-by-omarchy" \
|
||||
"$state_dir/service-enabled-by-omarchy"
|
||||
rmdir "$state_dir" 2>/dev/null || true
|
||||
|
||||
echo "USB device authorization is disabled."
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Require approval for new Thunderbolt PCIe accessories
|
||||
# omarchy:args=[--quiet|status|recover]
|
||||
|
||||
set -euo pipefail
|
||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh"
|
||||
|
||||
if [[ ${1:-} == "status" ]]; then
|
||||
tb_status
|
||||
elif [[ ${1:-} == "recover" && $# == 1 ]]; then
|
||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin recover
|
||||
elif [[ $# == 0 || ( $# == 1 && $1 == "--quiet" ) ]]; then
|
||||
if [[ ${1:-} != "--quiet" ]]; then
|
||||
if [[ -f $TB_PENDING ]]; then tb_status; fi
|
||||
echo "First setup, including a deferred setup, trusts connected accessories. Re-enabling a completed setup preserves saved trust."
|
||||
gum confirm "Require approval for new Thunderbolt accessories?" || exit 0
|
||||
fi
|
||||
# System units and rules are shipped by omarchy-settings.
|
||||
sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy
|
||||
sudo systemctl daemon-reload
|
||||
action=enable
|
||||
if [[ ${1:-} == "--quiet" ]]; then
|
||||
action=migrate
|
||||
fi
|
||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin "$action"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now omarchy-thunderbolt-authorization.service
|
||||
if [[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]; then
|
||||
for ((attempt=0; attempt<10; attempt++)); do
|
||||
if tb_public_snapshot >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
tb_status
|
||||
elif [[ -f $TB_PENDING ]]; then
|
||||
tb_status
|
||||
fi
|
||||
else
|
||||
echo "Usage: omarchy-setup-security-thunderbolt-authorization [--quiet|status|recover]" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Clear supported Thunderbolt firmware boot allowlists
|
||||
# omarchy:args=[recover]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${1:-} == "recover" && $# == 1 ]]; then
|
||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-recover
|
||||
elif [[ $# == 0 ]]; then
|
||||
gum style --bold "Thunderbolt at Boot"
|
||||
echo "This trusts connected accessories for use after Linux starts, then clears the firmware boot allowlist."
|
||||
echo "Trusted Thunderbolt keyboards, storage, and network adapters may be unavailable at disk unlock."
|
||||
echo "You must be able to unlock and recover without Thunderbolt accessories."
|
||||
echo "All controllers must be connected and support a boot allowlist in user or secure mode."
|
||||
echo "Older snapshots or another operating system may restore firmware boot permissions."
|
||||
gum confirm "Clear firmware boot access with these recovery limits?" || exit 0
|
||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-enable
|
||||
echo "Firmware boot allowlists cleared and verified. Current connections were not disconnected."
|
||||
else
|
||||
echo "Usage: omarchy-setup-security-thunderbolt-boot [recover]" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,191 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Ask before new USB devices can access the system
|
||||
# omarchy:args=[--boot] [--yes] [--review-existing]
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
assume_yes=0
|
||||
from_boot=0
|
||||
review_existing=0
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--yes) assume_yes=1 ;;
|
||||
--boot) from_boot=1 ;;
|
||||
--review-existing) review_existing=1 ;;
|
||||
-h | --help)
|
||||
echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes] [--review-existing]"
|
||||
echo
|
||||
echo "Trusts USB devices that are connected now, then blocks new devices"
|
||||
echo "until you approve them from an Omarchy notification."
|
||||
echo
|
||||
echo "--boot also blocks USB devices from the start of kernel boot. Connected"
|
||||
echo "devices are added to USBGuard's policy, but remain unavailable before"
|
||||
echo "USBGuard starts, including at an encrypted-disk password prompt."
|
||||
echo "Older snapshots without USBGuard keep all USB disabled after startup,"
|
||||
echo "including keyboards, network adapters, and storage. Use --boot only if"
|
||||
echo "disk unlock and recovery work without any USB devices."
|
||||
echo
|
||||
echo "--review-existing reviews connected devices with older port-bound approvals."
|
||||
echo "Each portable approval requires confirmation; existing rules are preserved."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "omarchy-setup-security-usb-authorization: unknown option '$1'. Try --help." >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Boot-time blocking goes into Limine's own configuration, which a Mac's boot
|
||||
# package owns, so it is not offered there.
|
||||
if (( from_boot )) && omarchy-hw-aarch64-apple; then
|
||||
echo "USB authorization from boot is not available on Apple Silicon yet; USB device authorization after login still works." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
state_dir="$HOME/.local/state/omarchy/usb-authorization"
|
||||
unit_source="$OMARCHY_PATH/default/systemd/user/omarchy-usb-authorization.service"
|
||||
unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service"
|
||||
rules_file=/etc/usbguard/rules.conf
|
||||
daemon_config=/etc/usbguard/usbguard-daemon.conf
|
||||
|
||||
source "$OMARCHY_PATH/install/helpers/usb-authorization.sh"
|
||||
|
||||
if (( review_existing )); then
|
||||
# Older Omarchy rules have no per-rule ownership marker. They cannot safely
|
||||
# be distinguished from intentionally restricted administrator rules.
|
||||
(( ! assume_yes && ! from_boot )) || { echo "Review requires individual confirmation; do not combine it with --yes or --boot." >&2; exit 2; }
|
||||
policy=$(usbguard list-rules)
|
||||
declare -A reviewed=()
|
||||
# Keep stdin attached to the terminal for gum (and any sudo prompt).
|
||||
mapfile -t policy_lines <<<"$policy"
|
||||
for line in "${policy_lines[@]}"; do
|
||||
if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then
|
||||
rule=${line#*: }
|
||||
[[ $rule == allow\ * ]] || continue
|
||||
portable=$(usb_authorization_portable_rule "$rule") || continue
|
||||
[[ ${portable% label *} != "$rule" ]] || continue
|
||||
devices=$(usbguard list-devices "$rule")
|
||||
mapfile -t candidates <<<"$devices"
|
||||
for device in "${candidates[@]}"; do
|
||||
[[ $device =~ ^([0-9]+):[[:space:]](allow[[:space:]].*)$ ]] || continue
|
||||
id=${BASH_REMATCH[1]} current=${BASH_REMATCH[2]}
|
||||
[[ ! -v reviewed[$id] ]] || continue
|
||||
reviewed[$id]=1
|
||||
echo
|
||||
printf '%s\n' "$current" | LC_ALL=C tr -c '\11\12\15\40-\176' '?'
|
||||
echo "This device has a location-bound approval. Details are supplied by the device."
|
||||
if gum confirm "Trust this device on any USB port or hub?"; then
|
||||
sudo /usr/bin/omarchy-usb-authorization-approve "$id" "$current"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "Review complete. Disconnected devices can be approved when reconnected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The watcher runs in the session's environment, which may differ from a
|
||||
# command invoked in a checkout. Fail before enabling blocking on mismatched
|
||||
# or incomplete installations, including dangling dev-link unit symlinks.
|
||||
session_environment=$(systemctl --user show-environment)
|
||||
grep -Fqx -- "OMARCHY_PATH=$OMARCHY_PATH" <<<"$session_environment" || {
|
||||
echo "The session and this command use different Omarchy installations. Update the session before enabling USB protection." >&2
|
||||
exit 1
|
||||
}
|
||||
for required in "$unit_source" /usr/share/omarchy/install/helpers/usb-authorization-policy.sh; do
|
||||
[[ -r $required ]] || { echo "USB authorization installation is incomplete: $required" >&2; exit 1; }
|
||||
done
|
||||
for required in "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch" \
|
||||
"$OMARCHY_PATH/bin/omarchy-usb-authorization-event" \
|
||||
"$OMARCHY_PATH/bin/omarchy-usb-authorization-review" \
|
||||
/usr/bin/omarchy-usb-authorization-approve; do
|
||||
[[ -r $required && -x $required ]] || { echo "USB authorization command is unavailable: $required" >&2; exit 1; }
|
||||
done
|
||||
|
||||
if (( ! assume_yes )); then
|
||||
if (( from_boot )); then
|
||||
gum style --foreground 212 --bold "USB authorization from boot"
|
||||
else
|
||||
gum style --foreground 212 --bold "USB device authorization"
|
||||
fi
|
||||
echo
|
||||
echo "Devices connected now will be trusted. Disconnect anything you do not trust."
|
||||
echo "New USB devices will remain blocked until you approve them."
|
||||
if (( from_boot )); then
|
||||
echo
|
||||
echo "Boot-time protection blocks all USB devices until USBGuard starts."
|
||||
echo "A USB keyboard will not work at an encrypted-disk password prompt, even"
|
||||
echo "when it is connected now."
|
||||
echo
|
||||
echo "Older snapshots without USBGuard keep ALL USB disabled after startup,"
|
||||
echo "including keyboards, network adapters, and storage."
|
||||
echo "Continue only if disk unlock and recovery work without any USB devices."
|
||||
fi
|
||||
echo
|
||||
if (( from_boot )); then
|
||||
gum confirm "Enable USB authorization from kernel boot?" || exit 130
|
||||
else
|
||||
gum confirm "Enable USB device authorization?" || exit 130
|
||||
fi
|
||||
fi
|
||||
|
||||
omarchy-pkg-add usbguard
|
||||
|
||||
usb_authorization_require_secure_settings "$daemon_config"
|
||||
|
||||
mkdir -p "$state_dir" "${unit%/*}"
|
||||
chmod 700 "$state_dir"
|
||||
|
||||
if ! usb_authorization_rules_present "$rules_file"; then
|
||||
policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX")
|
||||
trap 'rm -f "$policy"' EXIT
|
||||
chmod 600 "$policy"
|
||||
usb_authorization_generate_policy "$policy"
|
||||
sudo install -Dm600 -o root -g root "$policy" "$rules_file"
|
||||
touch "$state_dir/policy-generated-by-omarchy"
|
||||
fi
|
||||
|
||||
# This user can inspect USBGuard events and approve the device named by an
|
||||
# event. Policy and daemon parameters remain unavailable through the IPC ACL.
|
||||
sudo usbguard add-user "$USER" \
|
||||
--devices=list,listen,modify \
|
||||
--policy=list \
|
||||
--exceptions=listen
|
||||
|
||||
sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy
|
||||
|
||||
# Install a real file: a link into a checkout can become dangling on a branch
|
||||
# switch. Start the waiting watcher before USBGuard can block new devices.
|
||||
unit_temporary=$(mktemp "${unit}.XXXXXXXXXX")
|
||||
install -m644 "$unit_source" "$unit_temporary"
|
||||
mv -fT "$unit_temporary" "$unit"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable omarchy-usb-authorization.service
|
||||
systemctl --user restart omarchy-usb-authorization.service
|
||||
systemctl --user is-active --quiet omarchy-usb-authorization.service
|
||||
|
||||
sudo systemctl enable usbguard.service
|
||||
# add-user ACL changes are loaded only when the daemon starts, so restart an
|
||||
# existing service as well as starting a fresh one.
|
||||
sudo systemctl restart usbguard.service
|
||||
systemctl is-active --quiet usbguard.service
|
||||
systemctl --user is-active --quiet omarchy-usb-authorization.service
|
||||
|
||||
if (( from_boot )); then
|
||||
# The fixed helper independently verifies each full live device snapshot.
|
||||
usb_authorization_trust_present_devices
|
||||
sudo omarchy-usb-authorization-boot enable
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "USB device authorization is enabled."
|
||||
echo "Devices connected during setup remain trusted; new devices require approval."
|
||||
echo "To review older port-bound approvals, run: omarchy-setup-security-usb-authorization --review-existing"
|
||||
if (( from_boot )); then
|
||||
echo "Boot-time authorization is enabled and takes effect after reboot."
|
||||
fi
|
||||
@@ -462,12 +462,6 @@ scrub_factory_accounts() {
|
||||
sanitize_factory_baseline() {
|
||||
local factory="$1"
|
||||
btrfs property set -ts "$factory" ro false
|
||||
if [[ -d $factory/var/lib/omarchy/thunderbolt-authorization || -f $factory/etc/omarchy/thunderbolt-authorization.enabled || -f $factory/etc/omarchy/thunderbolt-authorization.pending ]]; then
|
||||
if ! /usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$factory"; then
|
||||
btrfs property set -ts "$factory" ro true
|
||||
fail "could not reset Thunderbolt authorization in the factory baseline"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! scrub_factory_accounts "$factory"; then
|
||||
btrfs property set -ts "$factory" ro true
|
||||
@@ -493,14 +487,6 @@ stage_full_reset() {
|
||||
[[ -f $unit_src/omarchy-provision-owner.service && -x $next/usr/bin/omarchy-provision-owner ]] ||
|
||||
fail "the factory snapshot predates provisioning support; cannot reset from it"
|
||||
|
||||
# A normal install's factory snapshot includes the original owner's USB
|
||||
# policy and enabled daemon. Keep input usable during the new owner's setup;
|
||||
# provisioning enrolls their devices before enabling USBGuard again.
|
||||
if [[ -f $next/usr/lib/systemd/system/usbguard.service ]]; then
|
||||
systemctl --root="$next" disable usbguard.service >>"$LOG_FILE" 2>&1 ||
|
||||
fail "could not disable USBGuard in the factory system (see $LOG_FILE)"
|
||||
fi
|
||||
|
||||
log "Scrubbing machine identity from the factory system"
|
||||
systemd-id128 new >"$next/etc/machine-id"
|
||||
rm -f "$next"/etc/ssh/ssh_host_*
|
||||
@@ -516,13 +502,6 @@ stage_full_reset() {
|
||||
scrub_factory_accounts "$next" ||
|
||||
fail "could not remove account credentials from the factory system (see $LOG_FILE)"
|
||||
|
||||
# Leave the new owner's input available before interactive enrollment. Clear
|
||||
# inherited accessory identities in both roots; never change the live daemon.
|
||||
if [[ -d $next/var/lib/omarchy/thunderbolt-authorization || -f $next/etc/omarchy/thunderbolt-authorization.enabled || -f $next/etc/omarchy/thunderbolt-authorization.pending ]]; then
|
||||
/usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$next" ||
|
||||
fail "could not reset Thunderbolt authorization in the staged root"
|
||||
fi
|
||||
|
||||
# @factory itself survives the wipe as the baseline for future resets. If it
|
||||
# came from a normal install it still holds the seller's account and
|
||||
# /etc/shadow, which the new wheel user could mount and read — and a second
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Manage system Thunderbolt authorization settings
|
||||
# omarchy:hidden=true
|
||||
|
||||
# This root entrypoint uses installed code and rejects unsafe Bash startup.
|
||||
if [[ $- != *p* ]]; then exit 126; fi
|
||||
source /usr/bin/omarchy-security-functions || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
set -euo pipefail
|
||||
(( EUID == 0 )) || exit 126
|
||||
export PATH=/usr/bin:/bin LC_ALL=C
|
||||
unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS
|
||||
source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh
|
||||
source /usr/share/omarchy/install/helpers/thunderbolt-setup.sh
|
||||
tb_admin "$@"
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Approve one verified Thunderbolt connection
|
||||
# omarchy:hidden=true
|
||||
|
||||
# pkexec permits this exact program for an active local wheel user. Suppress
|
||||
# startup injection before reading input; it cannot dispatch general root work.
|
||||
if [[ $- != *p* ]]; then exit 126; fi
|
||||
source /usr/bin/omarchy-security-functions || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
set -euo pipefail
|
||||
(( EUID == 0 )) || exit 126
|
||||
export PATH=/usr/bin:/bin LC_ALL=C
|
||||
unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS
|
||||
source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh
|
||||
|
||||
[[ $# == 2 && ( $1 == "true" || $1 == "false" ) ]] || exit 64
|
||||
(( ${#2} <= 16384 )) || exit 64
|
||||
jq -e 'type == "object"' <<< "$2" >/dev/null
|
||||
exec 9> "$TB_LOCK"
|
||||
flock -x 9
|
||||
result=$(tb_approve "$2" "$1" true)
|
||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$result"
|
||||
printf '%s\n' "$result"
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Check whether Thunderbolt firmware boot access is restricted
|
||||
|
||||
set -euo pipefail
|
||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
||||
tb_public_snapshot | jq -e .boot_protection >/dev/null
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:summary=Restore trusted Thunderbolt devices and publish their status
|
||||
# omarchy:hidden=true
|
||||
|
||||
# Keep root startup independent of a caller's environment or checkout.
|
||||
if [[ $- != *p* ]]; then exit 126; fi
|
||||
source /usr/bin/omarchy-security-functions || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
set -euo pipefail
|
||||
(( EUID == 0 )) || exit 126
|
||||
export PATH=/usr/bin:/bin LC_ALL=C
|
||||
unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS
|
||||
source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh
|
||||
tb_daemon
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Handle Thunderbolt device approval review
|
||||
|
||||
set -euo pipefail
|
||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh"
|
||||
[[ $# == 1 ]] || exit 64
|
||||
tb_review "$1"
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Handle Thunderbolt device approval watch
|
||||
|
||||
set -euo pipefail
|
||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh"
|
||||
tb_watch
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash -p
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Save portable trust for one verified USB device
|
||||
|
||||
# This narrow Polkit boundary accepts a live device snapshot, never a policy or
|
||||
# file path. Privileged Bash suppresses startup code from the caller's environment.
|
||||
if [[ $- != *p* ]]; then exit 126; fi
|
||||
source /usr/bin/omarchy-security-functions || exit 126
|
||||
omarchy_security_require_privileged_bash_startup || exit 126
|
||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
||||
set -euo pipefail
|
||||
(( EUID == 0 )) || exit 126
|
||||
export PATH=/usr/bin:/bin LC_ALL=C
|
||||
unset USBGUARD_DAEMON_CONF USBGUARD_DEBUG
|
||||
source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh
|
||||
|
||||
(( $# == 2 )) || exit 64
|
||||
# Omarchy persists to this file. Refuse custom daemon storage rather than
|
||||
# mistaking a stale copy of a rule for a successful persistent decision.
|
||||
rules_file=$(awk -F= '/^[[:space:]]*RuleFile[[:space:]]*=/ { value=$2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); print value; exit }' /etc/usbguard/usbguard-daemon.conf)
|
||||
[[ $rules_file == "/etc/usbguard/rules.conf" ]] || exit 1
|
||||
exec 8>/run/omarchy-usb-authorization.lock
|
||||
flock -x 8
|
||||
usb_authorization_permanent "$1" "$2"
|
||||
@@ -1,827 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Manage USB authorization from the start of kernel boot
|
||||
# omarchy:args=<enable|disable>
|
||||
# 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:-$(</etc/machine-id)}"
|
||||
local secure_boot="${5:-auto}"
|
||||
local actual_hash embedded_cmdline expected_hash image mode path paths section
|
||||
|
||||
if [[ $secure_boot == "auto" ]]; then
|
||||
if usb_authorization_secure_boot_enabled; then
|
||||
secure_boot=enabled
|
||||
else
|
||||
secure_boot=disabled
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -r $config ]]; then
|
||||
echo "Cannot verify the generated Limine boot entries in $config." >&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:-$(</etc/machine-id)}" secure_boot="${5:-auto}"
|
||||
|
||||
if ! limine-mkinitcpio; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# limine-mkinitcpio-hook can swallow an individual kernel build failure and
|
||||
# still return success, so its exit status alone is not a safety boundary.
|
||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" "$secure_boot"
|
||||
}
|
||||
|
||||
usb_authorization_snapshot_block_status() {
|
||||
local config="$1"
|
||||
|
||||
[[ -r $config ]] || return 2
|
||||
awk -v begin="$snapshot_begin" -v setting="$snapshot_setting" -v end="$snapshot_end" '
|
||||
$0 == begin { begin_count++; state = 1; next }
|
||||
$0 == setting { setting_count++; if (state == 1) state = 2; next }
|
||||
$0 == end { end_count++; if (state == 2) valid_count++; state = 0; next }
|
||||
{ state = 0 }
|
||||
END {
|
||||
if (begin_count == 0 && setting_count == 0 && end_count == 0) exit 1
|
||||
if (begin_count == 1 && setting_count == 1 && end_count == 1 && valid_count == 1) exit 0
|
||||
exit 2
|
||||
}
|
||||
' "$config"
|
||||
}
|
||||
|
||||
usb_authorization_enable_snapshot_setting() {
|
||||
local config="$1" status temporary
|
||||
|
||||
if usb_authorization_snapshot_block_status "$config"; then
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
if (( status != 1 )); 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"
|
||||
{
|
||||
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:-$(</etc/machine-id)}"
|
||||
local backup temporary
|
||||
|
||||
backup=$(mktemp "${config}.omarchy-usb-backup.XXXXXXXXXX")
|
||||
temporary=$(mktemp "${config}.omarchy-usb-new.XXXXXXXXXX")
|
||||
cp --preserve=mode,ownership "$config" "$backup"
|
||||
cp --preserve=mode,ownership "$config" "$temporary"
|
||||
|
||||
if ! awk -v expected="$expected" -v machine_id="$machine_id" -v parameter="$kernel_parameter" '
|
||||
function rewrite_cmdline(line, prefix, value, count, fields, field_index, result, found) {
|
||||
prefix = line
|
||||
sub(/cmdline:.*/, "cmdline:", prefix)
|
||||
value = line
|
||||
sub(/^[[:space:]]*cmdline:[[:space:]]*/, "", value)
|
||||
count = split(value, fields, /[[:space:]]+/)
|
||||
result = ""
|
||||
found = 0
|
||||
for (field_index = 1; field_index <= count; field_index++) {
|
||||
if (fields[field_index] == parameter) {
|
||||
found = 1
|
||||
continue
|
||||
}
|
||||
if (fields[field_index] != "") result = result (result == "" ? "" : " ") fields[field_index]
|
||||
}
|
||||
if (expected == "enabled") result = result (result == "" ? "" : " ") parameter
|
||||
return prefix (result == "" ? "" : " ") result
|
||||
}
|
||||
|
||||
/^\/[^/]/ { target_os = 0 }
|
||||
/^[[:space:]]*comment:[[:space:]]*machine-id=/ {
|
||||
target_os = index($0, "machine-id=" machine_id) > 0
|
||||
}
|
||||
target_os && /^[[:space:]]*cmdline:[[:space:]]*/ {
|
||||
print rewrite_cmdline($0)
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$config" >"$temporary"; then
|
||||
rm -f "$backup" "$temporary"
|
||||
return 1
|
||||
fi
|
||||
|
||||
mv -f "$temporary" "$config"
|
||||
if ! usb_authorization_enroll_config "$config" "${config%/*}"; then
|
||||
mv -f "$backup" "$config"
|
||||
if ! usb_authorization_secure_boot_enabled; then
|
||||
limine-enroll-config >/dev/null 2>&1 || true
|
||||
fi
|
||||
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("^(?<prefix>\\s*(?:cmdline|kernel_cmdline):\\s*)(?<value>.*)$"; "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
|
||||
}
|
||||
|
||||
# Read the same architecture and enrollment setting as the installed Limine tool.
|
||||
usb_authorization_limine_settings() (
|
||||
source /usr/lib/limine/limine-common-functions
|
||||
load_config || return 1
|
||||
printf '%s %s\n' "$(limine_efi_arch | tr '[:upper:]' '[:lower:]')" "${ENABLE_ENROLL_LIMINE_CONFIG:-no}"
|
||||
)
|
||||
|
||||
usb_authorization_verify_bootloader() (
|
||||
local config="$1" esp="$2" architecture enrollment image check digest
|
||||
read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1
|
||||
[[ -n $architecture ]] || return 1
|
||||
image="$esp/EFI/limine/limine_$architecture.efi"
|
||||
usb_authorization_verify_signature "$image" || return 1
|
||||
check=$(mktemp) || return 1
|
||||
trap 'rm -f "$check"' EXIT
|
||||
cp "$image" "$check" || return 1
|
||||
if [[ $enrollment == "yes" ]]; then
|
||||
digest=$(b2sum "$config") || return 1
|
||||
limine enroll-config "$check" "${digest%% *}" --quiet || return 1
|
||||
else
|
||||
limine enroll-config "$check" --reset --quiet || return 1
|
||||
fi
|
||||
# Enrollment edits fixed bytes in the executable. Reapplying the expected
|
||||
# value must be a no-op, including when enrollment is explicitly disabled.
|
||||
if ! cmp -s "$image" "$check"; then
|
||||
echo "Limine's signed executable does not match the configured menu enrollment." >&2
|
||||
return 1
|
||||
fi
|
||||
)
|
||||
|
||||
usb_authorization_enroll_config() {
|
||||
limine-enroll-config || return 1
|
||||
if usb_authorization_secure_boot_enabled; then
|
||||
usb_authorization_verify_bootloader "$1" "$2" || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Kernel rebuild hooks also reset and re-sign Limine. Checkpoint the complete
|
||||
# boot files, not just the menu: rebuilding can overwrite images it references.
|
||||
# The outer process holds the real boot lock; the private mount namespace gives
|
||||
# nested Limine tools their own lock inode so they cannot deadlock against it.
|
||||
usb_authorization_boot_transaction() (
|
||||
local action="$1" state architecture enrollment index manifest changed=0 committed=0 restored=1
|
||||
local machine_id="$(</etc/machine-id)"
|
||||
local cache="${2:-/var/cache/boot/$machine_id}"
|
||||
local -a paths=("$boot_path" "$cache" "$limine_defaults" "$drop_in")
|
||||
exec 8>/run/lock/boot-partition.lock
|
||||
flock --timeout 30 8 || return 1
|
||||
read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1
|
||||
[[ -n $architecture ]] || return 1
|
||||
usb_authorization_verify_signature "$boot_path/EFI/limine/limine_$architecture.efi" || return 1
|
||||
state=$(mktemp -d /var/tmp/omarchy-usb-boot.XXXXXXXXXX) || return 1
|
||||
trap '
|
||||
if (( changed && ! committed )); then
|
||||
echo "USB boot policy failed; restoring the previous boot files and settings." >&2
|
||||
for ((index=0; index<${#paths[@]}; index++)); do
|
||||
if [[ -d $state/$index && ! -L $state/$index ]]; then
|
||||
mkdir -p "${paths[index]}" && cp -a "$state/$index/." "${paths[index]}/" || restored=0
|
||||
elif [[ -e $state/$index || -L $state/$index ]]; then
|
||||
cp -a --remove-destination "$state/$index" "${paths[index]}" || restored=0
|
||||
else
|
||||
rm -rf -- "${paths[index]}" || restored=0
|
||||
fi
|
||||
done
|
||||
# New unreferenced images are harmless, but a newly created cached backup
|
||||
# manifest must not reintroduce the failed policy on a later recovery.
|
||||
for manifest in snapshots.json snapshots.json.old; do
|
||||
[[ -e $state/0/$machine_id/limine_history/$manifest ]] ||
|
||||
rm -f "$boot_path/$machine_id/limine_history/$manifest" || restored=0
|
||||
[[ -e $state/1/lss/$manifest ]] || rm -f "$cache/lss/$manifest" || restored=0
|
||||
done
|
||||
sync -f "$boot_path" || restored=0
|
||||
fi
|
||||
if (( restored )); then
|
||||
rm -rf "$state"
|
||||
else
|
||||
echo "Boot recovery was incomplete. Preserve and restore the checkpoint at $state before rebooting." >&2
|
||||
fi
|
||||
' EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
for ((index=0; index<${#paths[@]}; index++)); do
|
||||
if [[ -d ${paths[index]} ]]; then
|
||||
cp -a "${paths[index]}/." "$state/$index" || return 1
|
||||
elif [[ -e ${paths[index]} || -L ${paths[index]} ]]; then
|
||||
cp -a "${paths[index]}" "$state/$index" || return 1
|
||||
fi
|
||||
done
|
||||
: >"$state/lock"
|
||||
changed=1
|
||||
usb_authorization_run_transaction "$state" "$action" || return 1
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path" || return 1
|
||||
sync -f "$boot_path" || return 1
|
||||
committed=1
|
||||
)
|
||||
|
||||
usb_authorization_run_transaction() {
|
||||
unshare --mount --propagation private /bin/bash -c '
|
||||
mount --bind "$1/lock" /run/lock/boot-partition.lock || exit 1
|
||||
source "$2"
|
||||
usb_authorization_main "$3"
|
||||
' bash "$1" "${BASH_SOURCE[0]}" "$2"
|
||||
}
|
||||
|
||||
# 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
|
||||
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
|
||||
usb_authorization_enroll_config "$config" "$esp" || return 1
|
||||
committed=1
|
||||
)
|
||||
|
||||
usb_authorization_prepare_archived_images() (
|
||||
local expected="$1" config="$2" esp="$3" machine_id="${4:-$(</etc/machine-id)}"
|
||||
|
||||
usb_authorization_secure_boot_enabled || return 0
|
||||
# Package hooks acquire the boot lock too; install this optional tool before
|
||||
# taking it, never while rewriting archived images.
|
||||
if omarchy-cmd-missing sbattach; then
|
||||
omarchy-pkg-add sbsigntools || return 1
|
||||
fi
|
||||
exec 9>/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:-$(</etc/machine-id)}" manifest
|
||||
|
||||
# Share Limine's lock so a concurrent watcher cannot restore an old manifest
|
||||
# while we update it. The sync wrapper must not reacquire our lock.
|
||||
exec 9>/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
|
||||
usb_authorization_enroll_config "$config" "$esp" || return 1
|
||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id"
|
||||
)
|
||||
|
||||
# A Mac's boot chain belongs to its boot package, so boot-time denial is not
|
||||
# managed here. Disabling what is not configured succeeds, so turning USB
|
||||
# authorization off still works; anything configured by another route is left
|
||||
# to the owner, since this cannot rebuild a Mac's boot files.
|
||||
usb_authorization_apple_boot() {
|
||||
if [[ ${1:-} == "enable" ]]; then
|
||||
echo "USB authorization from boot is not available on Apple Silicon yet." >&2
|
||||
return 2
|
||||
fi
|
||||
if [[ -e $drop_in ]] || grep -Fqx "$snapshot_begin" "$limine_defaults" 2>/dev/null; then
|
||||
echo "Boot-time USB authorization is configured in $drop_in or $limine_defaults, which this command cannot remove on Apple Silicon." >&2
|
||||
echo "Remove it by hand: delete $drop_in and the lines from '$snapshot_begin' to '$snapshot_end' in $limine_defaults, then rebuild the boot files." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
usb_authorization_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 <enable|disable>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main() {
|
||||
if (( EUID != 0 )); then
|
||||
echo "Error: omarchy-usb-authorization-boot must run as root" >&2
|
||||
return 1
|
||||
fi
|
||||
case "${1:-}" in
|
||||
enable|disable)
|
||||
if /usr/bin/omarchy-hw-aarch64-apple; then
|
||||
usb_authorization_apple_boot "$1"
|
||||
elif usb_authorization_secure_boot_enabled; then
|
||||
usb_authorization_boot_transaction "$1"
|
||||
else
|
||||
usb_authorization_main "$1"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Usage: omarchy-usb-authorization-boot <enable|disable>" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Create an approval request for a blocked USB device
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
case "${USBGUARD_IPC_SIGNAL:-}" in
|
||||
IPC.Connected) ;;
|
||||
Device.PolicyApplied)
|
||||
# PresenceChanged precedes policy evaluation, even for trusted devices.
|
||||
[[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0
|
||||
;;
|
||||
Device.PresenceChanged)
|
||||
[[ ${USBGUARD_DEVICE_EVENT:-} == "Remove" ]] || exit 0
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
requests="$HOME/.local/state/omarchy/usb-authorization/requests"
|
||||
mkdir -p "$requests"
|
||||
chmod 700 "${requests%/*}" "$requests"
|
||||
exec 9>"$requests/.lock"
|
||||
|
||||
request_generation() {
|
||||
local daemon_id
|
||||
|
||||
# USBGuard reuses device IDs after restarting. Include both the daemon
|
||||
# invocation and watcher lifetime so old prompts cannot suppress new ones.
|
||||
daemon_id=$(systemctl show usbguard.service --property=InvocationID --value) || return 1
|
||||
[[ $daemon_id =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
printf '%s:%s' "${OMARCHY_USB_AUTHORIZATION_WATCH_ID:-standalone}" "$daemon_id"
|
||||
}
|
||||
|
||||
if [[ $USBGUARD_IPC_SIGNAL == "IPC.Connected" ]]; then
|
||||
flock 9
|
||||
generation=$(request_generation) || exit 1
|
||||
for pending in "$requests"/request-*.json; do
|
||||
[[ -f $pending ]] || continue
|
||||
if ! jq -e --arg generation "$generation" '.generation == $generation' "$pending" >/dev/null 2>&1; then
|
||||
rm -f "$pending"
|
||||
fi
|
||||
done
|
||||
flock -u 9
|
||||
|
||||
# USBGuard scans present devices before accepting IPC connections. Repeat
|
||||
# this on every reconnect: temporary approvals disappear on daemon restart.
|
||||
blocked=$(usbguard list-devices --blocked) || exit 1
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \
|
||||
"$OMARCHY_PATH/bin/omarchy-usb-authorization-event" || true
|
||||
fi
|
||||
done <<<"$blocked"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0
|
||||
[[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0
|
||||
|
||||
# A removed device's final target may be allow even though a blocked request
|
||||
# remains. Its identity attributes are unchanged; normalize the target.
|
||||
USBGUARD_DEVICE_RULE="block ${USBGUARD_DEVICE_RULE#* }"
|
||||
|
||||
# The token is safe to carry through the presentation launcher's command
|
||||
# string. The device-controlled rule itself stays in a mode-0600 JSON file and
|
||||
# is never interpolated into a shell command.
|
||||
token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | sha256sum | cut -d' ' -f1)"
|
||||
request="$requests/$token.json"
|
||||
# watch executes handlers concurrently. A reconnect scan and a policy event
|
||||
# can refer to the same device; publish only one pending request.
|
||||
if [[ $USBGUARD_IPC_SIGNAL == "Device.PresenceChanged" ]]; then
|
||||
flock 9
|
||||
blocked=$(usbguard list-devices --blocked) || exit 1
|
||||
# A delayed removal event must not erase a request for a reappeared device.
|
||||
if ! grep -Fqx -- "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" <<<"$blocked"; then
|
||||
rm -f "$request"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Keep retrying without needing another USB event. Handlers belong to the
|
||||
# watcher service and are stopped with the graphical session.
|
||||
while true; do
|
||||
flock 9
|
||||
generation=$(request_generation) || exit 1
|
||||
if [[ -f $request ]] && jq -e --arg generation "$generation" '.generation == $generation' "$request" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Discard events overtaken by another policy decision or device replacement.
|
||||
blocked=$(usbguard list-devices --blocked) || exit 1
|
||||
current=0
|
||||
while IFS= read -r line; do
|
||||
if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then
|
||||
current=1
|
||||
break
|
||||
fi
|
||||
done <<<"$blocked"
|
||||
(( current )) || exit 0
|
||||
|
||||
# Login and shell restarts can leave D-Bus alive before notifications are ready.
|
||||
# Bound each wait so disappeared devices are rechecked between attempts.
|
||||
if ! omarchy-notification-wait 1; then
|
||||
flock -u 9
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
temporary=$(mktemp "$requests/.request.XXXXXXXXXX")
|
||||
trap 'rm -f "$temporary"' EXIT
|
||||
|
||||
jq -n \
|
||||
--arg id "$USBGUARD_DEVICE_ID" \
|
||||
--arg rule "$USBGUARD_DEVICE_RULE" \
|
||||
--arg generation "$generation" \
|
||||
'{id: $id, rule: $rule, generation: $generation}' >"$temporary"
|
||||
chmod 600 "$temporary"
|
||||
mv -f "$temporary" "$request"
|
||||
|
||||
if omarchy-notification-send \
|
||||
--app-name omarchy-action \
|
||||
--urgency critical \
|
||||
--glyph \
|
||||
"USB accessory blocked" \
|
||||
"Click to review it. Keep it blocked unless you recognize the device." \
|
||||
--exec omarchy-launch-floating-terminal-with-presentation \
|
||||
omarchy-usb-authorization-review "$token"; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "$request"
|
||||
flock -u 9
|
||||
sleep 1
|
||||
done
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Restore the kernel's default USB authorization state
|
||||
# omarchy:requires-sudo=true
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if (( EUID != 0 )); then
|
||||
echo "Error: omarchy-usb-authorization-restore-default must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# USBGuard intentionally leaves root hubs in default-deny mode when it exits.
|
||||
# Restore both the hub defaults and devices that are still connected.
|
||||
shopt -s nullglob
|
||||
for attribute in /sys/bus/usb/devices/usb*/authorized_default; do
|
||||
echo 1 >"$attribute"
|
||||
done
|
||||
for attribute in /sys/bus/usb/devices/*/authorized; do
|
||||
echo 1 >"$attribute"
|
||||
done
|
||||
shopt -u nullglob
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Review a blocked USB device authorization request
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
token="${1:-}"
|
||||
if [[ ! $token =~ ^request-[0-9a-f]{64}$ ]]; then
|
||||
echo "This USB authorization request is invalid or expired." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
request="$HOME/.local/state/omarchy/usb-authorization/requests/$token.json"
|
||||
# Serialize request updates with watcher events, but release the lock while the
|
||||
# user chooses. A changed request must never inherit an old dialog's approval.
|
||||
if [[ ! -d ${request%/*} ]]; then
|
||||
echo "This USB authorization request is no longer available." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec 9>"${request%/*}/.lock"
|
||||
flock 9
|
||||
if [[ ! -f $request || -L $request ]]; then
|
||||
echo "This USB authorization request is no longer available." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
snapshot=$(cat "$request")
|
||||
id=$(jq -er '.id | select(test("^[0-9]+$"))' <<<"$snapshot")
|
||||
expected_rule=$(jq -er '.rule | select(type == "string" and startswith("block "))' <<<"$snapshot")
|
||||
choice=$(jq -er '.approval // "" | select(. == "" or . == "Allow once" or . == "Always allow this device")' <<<"$snapshot")
|
||||
allowed_rule="allow ${expected_rule#block }"
|
||||
|
||||
approval_failed() {
|
||||
echo "Could not confirm USB approval. The request has been kept; try again." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
read_current_rule() {
|
||||
local devices line
|
||||
|
||||
current_rule=""
|
||||
devices=$(usbguard list-devices) || approval_failed
|
||||
while IFS= read -r line; do
|
||||
if [[ $line == "$id: "* ]]; then
|
||||
current_rule="${line#"$id: "}"
|
||||
return
|
||||
fi
|
||||
done <<<"$devices"
|
||||
}
|
||||
|
||||
validate_device() {
|
||||
read_current_rule
|
||||
if [[ $current_rule == "$expected_rule" ]] ||
|
||||
[[ -n $choice && $current_rule == "$allowed_rule" ]]; then
|
||||
return
|
||||
fi
|
||||
rm -f "$request"
|
||||
echo "That USB device was removed or changed. Connect it again to review it." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
validate_device
|
||||
|
||||
if [[ -z $choice ]]; then
|
||||
flock -u 9
|
||||
gum style --foreground 212 --bold "Blocked USB accessory"
|
||||
echo
|
||||
echo "The following details come from the device and can be forged:"
|
||||
printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?'
|
||||
echo
|
||||
|
||||
choice=$(gum choose \
|
||||
"Allow once" \
|
||||
"Always allow this device" \
|
||||
"Keep blocked") || exit 130
|
||||
|
||||
flock 9
|
||||
if [[ ! -f $request || -L $request || $(cat "$request") != "$snapshot" ]]; then
|
||||
echo "This USB authorization request changed. Open the latest notification to review it." >&2
|
||||
exit 1
|
||||
fi
|
||||
# A fresh choice can authorize only the blocked identity shown in the dialog.
|
||||
read_current_rule
|
||||
if [[ $current_rule != "$expected_rule" ]]; then
|
||||
rm -f "$request"
|
||||
echo "That USB device was removed or changed. Connect it again to review it." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$choice" in
|
||||
"Allow once" | "Always allow this device")
|
||||
# Save intent before applying policy: a later query can fail after the device
|
||||
# was authorized. Retry must verify that approval, including persistent trust.
|
||||
temporary=$(mktemp "${request%/*}/.request.XXXXXXXXXX")
|
||||
trap 'rm -f "$temporary"' EXIT
|
||||
jq --arg approval "$choice" '.approval = $approval' "$request" >"$temporary"
|
||||
mv -f "$temporary" "$request"
|
||||
|
||||
if [[ $choice == "Always allow this device" ]]; then
|
||||
pkexec /usr/bin/omarchy-usb-authorization-approve "$id" "$current_rule" || approval_failed
|
||||
elif [[ $current_rule == "$expected_rule" ]]; then
|
||||
# Match the complete identity, since numeric IDs can be reassigned.
|
||||
usbguard allow-device "$expected_rule" || approval_failed
|
||||
fi
|
||||
|
||||
# Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can
|
||||
# return success without authorizing anything. Verify the exact device.
|
||||
allowed=$(usbguard list-devices --allowed) || approval_failed
|
||||
grep -Fqx -- "$id: $allowed_rule" <<<"$allowed" || approval_failed
|
||||
|
||||
if [[ $choice == "Always allow this device" ]]; then
|
||||
# Let USBGuard match policy rules: saved rules can omit the device's port.
|
||||
# --show-devices indents the devices matching each policy rule.
|
||||
policy=$(usbguard list-rules --label omarchy-usb-authorization-v1 --show-devices) || approval_failed
|
||||
policy_allows=0
|
||||
matched=0
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then
|
||||
policy_allows=0
|
||||
if [[ $line =~ ^[0-9]+:[[:space:]]allow[[:space:]] ]]; then
|
||||
policy_allows=1
|
||||
fi
|
||||
elif (( policy_allows )) && [[ $line == $'\t'"$id: $allowed_rule" ]]; then
|
||||
matched=1
|
||||
break
|
||||
fi
|
||||
done <<<"$policy"
|
||||
(( matched )) || approval_failed
|
||||
echo "USB accessory added to the trusted policy."
|
||||
else
|
||||
echo "USB accessory allowed until it is disconnected."
|
||||
fi
|
||||
;;
|
||||
"Keep blocked")
|
||||
echo "USB accessory remains blocked."
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
|
||||
rm -f "$request"
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Watch USBGuard for devices awaiting user approval
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Starting a new graphical-session watcher must offer fresh prompts even if
|
||||
# their old notification actions were dismissed and aged out of history.
|
||||
OMARCHY_USB_AUTHORIZATION_WATCH_ID=$(cat /proc/sys/kernel/random/uuid)
|
||||
export OMARCHY_USB_AUTHORIZATION_WATCH_ID
|
||||
|
||||
event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event"
|
||||
|
||||
# The handler scans blocked devices on IPC.Connected, including reconnects
|
||||
# after a daemon restart, once the event subscription is established.
|
||||
exec usbguard watch --wait --exec "$event_handler"
|
||||
@@ -220,12 +220,8 @@
|
||||
"setup.plugin.remove": {"icon":"","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"},
|
||||
"setup.security": {"icon":"","label":"Security"},
|
||||
"setup.config": {"icon":"","label":"Config"},
|
||||
"setup.security.thunderbolt": {"icon":"","label":"Thunderbolt","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization"},
|
||||
"setup.security.thunderbolt-boot": {"icon":"","label":"Thunderbolt Boot","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-boot"},
|
||||
"setup.security.fingerprint": {"icon":"","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"},
|
||||
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
|
||||
"setup.security.usb-authorization": {"icon":"","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"},
|
||||
"setup.security.usb-authorization-boot": {"icon":"","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"},
|
||||
"setup.security.sshd": {"icon":"","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
|
||||
"setup.security.ssh-agent": {"icon":"","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"},
|
||||
"setup.security.sudoless-docker": {"icon":"","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
|
||||
@@ -344,12 +340,8 @@
|
||||
"remove.windows": {"icon":"","label":"Windows","when":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm remove'"},
|
||||
"remove.preinstalls": {"icon":"","label":"Preinstalls","when":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-preinstalls"},
|
||||
"remove.security": {"icon":"","label":"Security","title":"Remove"},
|
||||
"remove.security.thunderbolt": {"icon":"","label":"Thunderbolt","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled || -f /etc/omarchy/thunderbolt-authorization.pending ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-authorization"},
|
||||
"remove.security.thunderbolt-boot": {"icon":"","label":"Thunderbolt Boot","when":"omarchy-thunderbolt-authorization-boot-enabled","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-boot"},
|
||||
"remove.security.fingerprint": {"icon":"","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"},
|
||||
"remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"},
|
||||
"remove.security.usb-authorization": {"icon":"","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"},
|
||||
"remove.security.usb-authorization-boot": {"icon":"","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple && grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"},
|
||||
"remove.security.sshd": {"icon":"","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"},
|
||||
"remove.security.sudoless-docker": {"icon":"","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"},
|
||||
"remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<action id="org.omarchy.thunderbolt.approve">
|
||||
<description>Approve a connected Thunderbolt accessory</description>
|
||||
<message>Authentication is required to approve this Thunderbolt accessory</message>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/omarchy-thunderbolt-authorization-approve</annotate>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<action id="org.omarchy.usb.approve">
|
||||
<description>Trust a connected USB accessory on any port</description>
|
||||
<message>Authentication is required to trust this USB accessory</message>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/omarchy-usb-authorization-approve</annotate>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=Offer approval for blocked Thunderbolt accessories
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
ConditionEnvironment=WAYLAND_DISPLAY
|
||||
ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.enabled
|
||||
ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.pending
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/omarchy-thunderbolt-authorization-watch
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=Ask before allowing new USB devices
|
||||
After=graphical-session.target dbus.socket
|
||||
Requires=dbus.socket
|
||||
PartOf=graphical-session.target
|
||||
ConditionEnvironment=OMARCHY_PATH
|
||||
ConditionEnvironment=WAYLAND_DISPLAY
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/bash -lc 'exec "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch"'
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -1,43 +0,0 @@
|
||||
# Thunderbolt authorization
|
||||
|
||||
Omarchy uses Bolt for Thunderbolt/USB4 PCIe authorization. USB functions remain separate and need USB authorization. The end-user controls and firmware limitations are documented in [the Security manual](../manual/48-security.md#thunderbolt-device-authorization).
|
||||
|
||||
## Policy and services
|
||||
|
||||
Bolt's default IOMMU policy can enroll new devices without asking. Omarchy keeps `AuthMode=disabled` in `/var/lib/boltd/boltd.conf` and authorizes individual devices through Bolt's D-Bus API. This does not disable Bolt or prevent explicit authorization. Setting only `DefaultPolicy=manual` would still leave IOMMU auto-enrollment enabled.
|
||||
|
||||
The root `omarchy-thunderbolt-authorization.service` holds explicit consent in `/var/lib/omarchy/thunderbolt-authorization/policy.json`. Bolt can import devices authorized by firmware, so its database is not treated as consent. Initial setup captures currently connected accessories; subsequent enables preserve that inventory, including an initially empty inventory. Always-allow approvals authorize, read back the real status, enroll with manual policy, verify storage, and save consent. Trusted devices are restored even without an unlocked graphical session. No operation globally enables Bolt to authorize one device.
|
||||
|
||||
Omarchy records which Bolt entries it enrolled, including their store timestamps, separately from consent. Removing protection converts only matching, successfully approved entries from manual to automatic policy before restoring Bolt's global mode; this includes disconnected accessories. Existing manual records and externally recreated records retain their policies. Secure keys are kept. The handoff verifies the stored policies after restarting Bolt and checkpoints policies, firmware allowlists, configuration and services for rollback. Automatic policies resume Bolt's normal firmware allowlist behavior, so every controller must be online for a recoverable handoff. A completed handoff clears enrollment ownership while retaining the trust list for re-enabling.
|
||||
|
||||
The Bolt startup drop-in sets the persisted mode before hardware enumeration whenever `/etc/omarchy/thunderbolt-authorization.enabled` exists. The Omarchy service checks the live mode again. Firmware security `none` remains a bypass: software cannot prevent driver loading after firmware has already opened a PCIe tunnel. This produces a warning instead of disconnecting active storage.
|
||||
|
||||
Secure-capable devices require both a stored Bolt record and a saved key for automatic trust. An explicit permanent approval pins the current sysfs directory and key descriptor, seeds an empty kernel key before authorization, and verifies Bolt imported it before saving consent. Automatic reconciliation cannot initialize a missing key and retains its read-only sysfs sandbox. First activation is deferred when a captured secure-capable device lacks a saved Bolt key: the `.pending` marker starts a setup notification, while existing Bolt configuration and services remain untouched. Installation can finish; only explicit setup after safe disconnection clears the pending state. Initial identity capture uses Bolt's numeric device/vendor fallback when descriptive sysfs attributes are absent.
|
||||
|
||||
The runtime is Bash, with `busctl --json=short` for Bolt's structured D-Bus replies and `jq` for JSON. The root daemon publishes a root-owned snapshot in `/run/omarchy-thunderbolt-authorization`. The user service polls that snapshot and refuses unavailable, stale, or mismatched generations. Both loops tick every 2 s without forking. While Linux sees no Thunderbolt controller and nothing is connected or boot-protected, the daemon reconciles Bolt every 10 s instead of every tick, still well inside a snapshot's 20 s lifetime; a controller appearing, a policy change or a boot recovery checkpoint restores the 2 s cadence at once. The user service skips an unchanged, fresh snapshot with no devices, warnings or requests in flight. It does not infer final policy from presence signals. Requests use opaque tokens and bind the policy-service generation, Bolt bus owner, device path, connection timestamp, sysfs inode, and device identity. The inode distinguishes same-second reconnects. Device text is displayed as untrusted data. Failed delivery retries; lost approval responses retain intent and offer another notification. Watcher restarts preserve unfinished intent only while the exact connection and policy-service incarnation remain unchanged. A changed device or daemon requires a new request.
|
||||
|
||||
The terminal dialog saves an approval intent; the background watcher submits it through `pkexec` to the fixed installed `/usr/bin/omarchy-thunderbolt-authorization-approve` helper. Polkit grants active local wheel users this individual approval action. The helper validates its arguments and the live connection, then independently verifies authorization and any saved trust before returning success. Root entrypoints use protected Bash startup and fixed installed libraries; they discard caller-supplied D-Bus addresses. A shared lock serializes setup, reconciliation, and approvals. General setup uses a separate administrator command invoked with `sudo` from the terminal. Blanket Bolt management requires administrator authentication while protection is enabled. These controls protect against unsolicited accessories, not malicious software already running as an administrator. Identifiers can be forged; Bolt's secure connection keys provide additional verification on supported hardware.
|
||||
|
||||
## Firmware boot access
|
||||
|
||||
The optional boot control applies only to online controllers with a firmware BootACL in `user` or `secure` mode. It captures connected accessories, changes stored policies to manual, clears each allowlist, and independently reads back the results. Reconciliation keeps firmware imports and returning controllers from silently restoring automatic boot permissions. Unsupported or offline controllers produce explicit warnings/errors.
|
||||
|
||||
Changes retain the previous policy and firmware lists. A failed transition restores matching state, or leaves a recovery checkpoint and reports failure. Device-authorization setup has a separate checkpoint that includes service state and firmware settings when boot protection was active. Restore commands are described in the manual. Firmware modified by another OS or an older snapshot is outside the current system's control; this is not an early-boot kernel authorization switch.
|
||||
|
||||
Deferred owner setup leaves ordinary Bolt behavior in place until the owner finishes provisioning. Factory reset disables the copied service and removes previous-owner trust, recovery data, Bolt identities, and keys from both staged and retained roots. It does not change the live daemon.
|
||||
|
||||
## Validation
|
||||
|
||||
Run policy, notification, rollback, deferred-owner, and real offline-systemctl tests with:
|
||||
|
||||
```sh
|
||||
bash test/shell.d/thunderbolt-authorization-test.sh
|
||||
```
|
||||
|
||||
The optional integration suite runs the installed `boltd` against upstream's UMockdev Thunderbolt fixture and a private D-Bus. Its hardware simulator uses Python/GObject; all Omarchy policy code and test assertions execute Bash. It needs `python-gobject`, `python-dbusmock`, `umockdev`, Bolt, and a Bolt source checkout (validated with 0.9.11):
|
||||
|
||||
```sh
|
||||
umockdev-wrapper env BOLT_TEST_SOURCE=/path/to/bolt bash test/shell.d/thunderbolt-authorization-test.sh
|
||||
```
|
||||
|
||||
It covers user/secure modes, IOMMU default denial, verified permanent and one-time approval, daemon restart, stale requests, firmware ACL restoration, and a separate Bash daemon publishing status after reconnecting to Bolt. Removal cases exercise the real setup and approval functions, automatic reconnection with retained secure keys, preservation of existing manual policies, disconnected accessories, and rollback of policies, firmware ACLs and the real private approval daemon after a setup failure. A service-control bridge manages only the fixture's private processes. These tests simulate kernel devices and firmware. They do not demonstrate physical DMA isolation or real firmware enforcement during boot. Systemd packaging, the installed approval helper, and the actual desktop Polkit rules require installed-guest validation; source-only dev linking does not install system units or rules.
|
||||
@@ -1,17 +0,0 @@
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id === "org.omarchy.thunderbolt.approve" &&
|
||||
subject.active && subject.local && subject.isInGroup("wheel")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
// Bolt's vendor rule grants wheel passwordless management, including enabling
|
||||
// automatic approval of every IOMMU device. Keep blanket policy changes behind
|
||||
// administrator authentication; the narrow Omarchy action approves one device.
|
||||
if (action.id === "org.freedesktop.bolt.manage" && subject.user !== "root") {
|
||||
try {
|
||||
polkit.spawn(["/usr/bin/test", "-f", "/etc/omarchy/thunderbolt-authorization.enabled"]);
|
||||
return polkit.Result.AUTH_ADMIN;
|
||||
} catch (error) {
|
||||
// Protection is disabled: retain the packaged Bolt permission rules.
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id === "org.omarchy.usb.approve" &&
|
||||
subject.active && subject.local && subject.isInGroup("wheel")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
[Service]
|
||||
# Runs before Bolt enumerates hardware, including D-Bus/udev activation.
|
||||
ExecStartPre=/usr/bin/omarchy-thunderbolt-authorization-admin guard
|
||||
@@ -1,26 +0,0 @@
|
||||
[Unit]
|
||||
Description=Restore approved Thunderbolt devices and handle new approvals
|
||||
Wants=bolt.service
|
||||
After=bolt.service polkit.service
|
||||
ConditionPathExists=/etc/omarchy/thunderbolt-authorization.enabled
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/omarchy-thunderbolt-authorization-daemon
|
||||
RuntimeDirectory=omarchy-thunderbolt-authorization
|
||||
RuntimeDirectoryMode=0755
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/omarchy/thunderbolt-authorization /run/lock
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -8,7 +8,5 @@ run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/docker.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/locale.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/snapper.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/enable-services.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/firewall.sh"
|
||||
run_logged "$OMARCHY_INSTALL/config/thunderbolt-authorization.sh"
|
||||
@@ -1,10 +0,0 @@
|
||||
echo "Configure Thunderbolt device authorization"
|
||||
|
||||
install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy
|
||||
|
||||
# The builder's accessories must not become the new owner's trust policy.
|
||||
# Keep ordinary Bolt behavior until interactive owner setup has finished.
|
||||
if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then
|
||||
/usr/bin/omarchy-thunderbolt-authorization-admin prepare
|
||||
systemctl enable omarchy-thunderbolt-authorization.service
|
||||
fi
|
||||
@@ -1,26 +0,0 @@
|
||||
echo "Configure USB device authorization"
|
||||
|
||||
install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy
|
||||
|
||||
# A deferred install has only the builder's peripherals attached. The owner
|
||||
# needs working input before there is an account or a session for approvals.
|
||||
if [[ -z ${OMARCHY_INSTALL_USER:-} ]]; then
|
||||
systemctl disable usbguard.service
|
||||
return 0
|
||||
fi
|
||||
|
||||
rules_file="${OMARCHY_USB_AUTHORIZATION_RULES_FILE:-/etc/usbguard/rules.conf}"
|
||||
daemon_config="${OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG:-/etc/usbguard/usbguard-daemon.conf}"
|
||||
|
||||
source "$OMARCHY_INSTALL/helpers/usb-authorization.sh"
|
||||
usb_authorization_require_secure_settings "$daemon_config"
|
||||
|
||||
if [[ ! -s $rules_file ]]; then
|
||||
usb_authorization_install_policy "$rules_file"
|
||||
fi
|
||||
|
||||
usb_authorization_add_user "$OMARCHY_INSTALL_USER"
|
||||
|
||||
# Installs are followed by a reboot, so enable the daemon without starting it
|
||||
# in the live ISO's chroot.
|
||||
systemctl enable usbguard.service
|
||||
@@ -1,356 +0,0 @@
|
||||
# Shared by the root controller and session commands. These paths are fixed at
|
||||
# privileged entrypoints; tests may source the functions in an isolated fixture.
|
||||
TB_STATE=/var/lib/omarchy/thunderbolt-authorization/policy.json
|
||||
TB_CONFIG=/var/lib/boltd/boltd.conf
|
||||
TB_MARKER=/etc/omarchy/thunderbolt-authorization.enabled
|
||||
TB_PENDING=/etc/omarchy/thunderbolt-authorization.pending
|
||||
TB_RUNTIME=/run/omarchy-thunderbolt-authorization
|
||||
TB_LOCK=/run/lock/omarchy-thunderbolt-authorization.lock
|
||||
TB_SERVICE=omarchy-thunderbolt-authorization.service
|
||||
TB_BOLT=org.freedesktop.bolt
|
||||
TB_MANAGER=org.freedesktop.bolt1.Manager
|
||||
TB_DEVICE=org.freedesktop.bolt1.Device
|
||||
TB_DOMAIN=org.freedesktop.bolt1.Domain
|
||||
TB_PATH=/org/freedesktop/bolt
|
||||
TB_SYSFS=/sys/bus/thunderbolt/devices
|
||||
# How often the daemon re-checks Bolt while there is no controller and no boot
|
||||
# protection: well inside the 20 s a published snapshot stays valid.
|
||||
TB_IDLE_RECHECK=10
|
||||
TB_TRUST_JQ='def trusted($state): $state.trusted[.Uid] == {Uid,Name,Vendor} and
|
||||
((.Secure | not) or (.Stored and (.Key | IN("have","new"))));'
|
||||
|
||||
tb_fail() { printf '%s\n' "$*" >&2; return 1; }
|
||||
|
||||
# Sleeps without forking on each tick: a pipe open for reading and writing
|
||||
# never reaches EOF, so a timed read on it returns only on timeout or a signal.
|
||||
tb_wait() {
|
||||
[[ -n ${TB_TICK:-} ]] || exec {TB_TICK}<> <(:)
|
||||
read -r -t "$1" -u "$TB_TICK" _ || true
|
||||
}
|
||||
|
||||
tb_controller_present() {
|
||||
local domain
|
||||
for domain in "$TB_SYSFS"/domain*; do
|
||||
[[ -e $domain ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
tb_json_write() {
|
||||
local target=$1 mode=${2:-600} temporary
|
||||
temporary=$(mktemp "${target%/*}/.thunderbolt.XXXXXX") || return 1
|
||||
if jq -cS . > "$temporary" && chmod "$mode" "$temporary" && sync "$temporary" &&
|
||||
mv -fT -- "$temporary" "$target" && sync "${target%/*}"; then
|
||||
return 0
|
||||
else
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_state() {
|
||||
jq -ce 'select(.version == 1 and (.trusted | type == "object") and (.enabled | type == "boolean"))' "$TB_STATE"
|
||||
}
|
||||
|
||||
tb_bus() {
|
||||
busctl --system --auto-start=no --timeout=10 --json=short "$@"
|
||||
}
|
||||
|
||||
tb_owner() {
|
||||
tb_bus call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$TB_BOLT" | jq -er '.data[0]'
|
||||
}
|
||||
|
||||
tb_properties() {
|
||||
tb_bus call "$1" "$2" org.freedesktop.DBus.Properties GetAll s "$3" | jq -ce '.data[0] | map_values(.data)'
|
||||
}
|
||||
|
||||
tb_set() {
|
||||
local owner=$1 path=$2 interface=$3 key=$4 value=$5 signature=${6:-s} current
|
||||
local -a values=()
|
||||
current=$(tb_properties "$owner" "$path" "$interface") || return 1
|
||||
if ! jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null; then
|
||||
if [[ $signature == "as" ]]; then
|
||||
mapfile -t values < <(jq -r '.[]' <<< "$value")
|
||||
tb_bus set-property "$owner" "$path" "$interface" "$key" as "${#values[@]}" "${values[@]}" >/dev/null || return 1
|
||||
else
|
||||
tb_bus set-property "$owner" "$path" "$interface" "$key" s "$(jq -r . <<< "$value")" >/dev/null || return 1
|
||||
fi
|
||||
fi
|
||||
# Bolt rejects no-op BootACL writes; even skipped writes are read back.
|
||||
current=$(tb_properties "$owner" "$path" "$interface") || return 1
|
||||
jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null ||
|
||||
tb_fail "Bolt did not retain the requested $key"
|
||||
}
|
||||
|
||||
tb_inventory() {
|
||||
local owner manager paths path item inode domains='[]' devices='[]' stored='[]'
|
||||
owner=$(tb_owner) || return 1
|
||||
manager=$(tb_properties "$owner" "$TB_PATH" "$TB_MANAGER") || return 1
|
||||
paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDomains | jq -cer '.data[0]') || return 1
|
||||
while IFS= read -r path; do
|
||||
item=$(tb_properties "$owner" "$path" "$TB_DOMAIN" | jq -c --arg path "$path" '. + {path:$path}') || return 1
|
||||
domains=$(jq -c --argjson item "$item" '. + [$item]' <<< "$domains") || return 1
|
||||
done < <(jq -r '.[]' <<< "$paths")
|
||||
paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDevices | jq -cer '.data[0]') || return 1
|
||||
while IFS= read -r path; do
|
||||
item=$(tb_properties "$owner" "$path" "$TB_DEVICE" | jq -c --arg path "$path" '. + {path:$path}') || return 1
|
||||
[[ $(jq -r .Type <<< "$item") == "peripheral" ]] || continue
|
||||
if jq -e .Stored <<< "$item" >/dev/null; then
|
||||
stored=$(jq -c --argjson item "$item" '. + [$item]' <<< "$stored") || return 1
|
||||
fi
|
||||
[[ $(jq -r .Status <<< "$item") != "disconnected" ]] || continue
|
||||
inode=$(stat -Lc '%i' -- "$(jq -r .SysfsPath <<< "$item")") || return 1
|
||||
item=$(jq -c --arg inode "$inode" '. + {inode:$inode}' <<< "$item") || return 1
|
||||
devices=$(jq -c --argjson item "$item" '. + [$item]' <<< "$devices") || return 1
|
||||
done < <(jq -r '.[]' <<< "$paths")
|
||||
[[ $(tb_owner) == "$owner" ]] || { tb_fail "Bolt restarted while reading its inventory"; return 1; }
|
||||
jq -cn --arg owner "$owner" --argjson manager "$manager" --argjson domains "$domains" \
|
||||
--argjson devices "$devices" --argjson stored "$stored" '
|
||||
{owner:$owner,manager:$manager,domains:$domains,stored:$stored,devices:[$devices[] |
|
||||
. + {Secure:((.Domain as $domain | any($domains[]; .Uid == $domain and .SecurityLevel == "secure"))
|
||||
and (.AuthFlags | contains("nokey") | not))}]}'
|
||||
}
|
||||
|
||||
tb_read_inventory() {
|
||||
local inventory
|
||||
inventory=$(tb_inventory) || return 1
|
||||
jq -e '.manager.AuthMode == "disabled"' <<< "$inventory" >/dev/null || {
|
||||
tb_fail "Bolt automatic authorization is enabled; protection is not active"
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$inventory"
|
||||
}
|
||||
|
||||
tb_identity() {
|
||||
jq -c --arg owner "$1" --arg generation "$2" \
|
||||
'{owner:$owner,generation:$generation,path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime}'
|
||||
}
|
||||
|
||||
tb_snapshot() {
|
||||
local state inventory generation recovery=false
|
||||
state=$(tb_state) || return 1
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
[[ -f ${TB_STATE%/*}/boot-recovery.json ]] && recovery=true
|
||||
jq -c --argjson state "$state" --arg generation "$generation" --argjson recovery "$recovery" \
|
||||
--arg error "${1:-}" --argjson time "$(date +%s)" "$TB_TRUST_JQ"'
|
||||
.owner as $owner |
|
||||
[.devices[] | . as $d | {
|
||||
identity: ({owner:$owner,generation:$generation} + ($d | {path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime})),
|
||||
trusted: ($d | trusted($state)),
|
||||
status:.Status,stored:.Stored,policy:.Policy,flags:.AuthFlags
|
||||
}] as $devices |
|
||||
{available:true,time:$time,owner:$owner,generation:$generation,devices:$devices,error:$error,
|
||||
boot_protection:($state.boot_protection // false),
|
||||
domains:[.domains[] | {Uid,SecurityLevel,IOMMU,BootACL}],
|
||||
warnings: ([
|
||||
(if $recovery then "A firmware boot-access change needs recovery. Boot protection is not confirmed." else empty end),
|
||||
(.domains[] | if .SecurityLevel == "none" then
|
||||
"This controller allows PCIe connections in firmware. Select user authorization in firmware settings; Omarchy cannot block its devices before their drivers load."
|
||||
else empty end),
|
||||
(.domains[] | .SecurityLevel as $level | select(["none","user","secure","dponly","usbonly","nopcie"] | index($level) | not) |
|
||||
"The controller authorization mode could not be verified."),
|
||||
(.domains[] | select($state.boot_protection and (.SysfsPath == "" or
|
||||
(.SecurityLevel != "user" and .SecurityLevel != "secure") or (.BootACL | length == 0) or any(.BootACL[]; . != ""))) |
|
||||
"The firmware boot allowlist is not confirmed empty on every controller. Check Thunderbolt Boot before relying on boot protection."),
|
||||
($devices[] | select((.status | startswith("authorized")) and (.trusted | not) and (.flags | contains("boot"))) |
|
||||
"An untrusted device was authorized by firmware before Linux started. Disconnect it safely to apply approval on its next connection.")
|
||||
] | unique)}' <<< "$inventory"
|
||||
}
|
||||
|
||||
tb_current() {
|
||||
local expected=$1 inventory owner generation device identity
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
while IFS= read -r device; do
|
||||
identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1
|
||||
if jq -e --argjson expected "$expected" '. == $expected' <<< "$identity" >/dev/null; then
|
||||
jq -cn --arg owner "$owner" --argjson device "$device" '{owner:$owner,device:$device}'
|
||||
return 0
|
||||
fi
|
||||
done < <(jq -c '.devices[]' <<< "$inventory")
|
||||
tb_fail "That Thunderbolt device was removed or changed. Open its latest notification."
|
||||
}
|
||||
|
||||
tb_prepare_key() (
|
||||
local identity=$1 current key key_fd
|
||||
current=$(tb_current "$identity") || return 1
|
||||
# Bolt creates keys for stored devices itself. For a new device, disabled
|
||||
# AuthMode prevents enrollment before authorization. Seed the kernel key so
|
||||
# Authorize provisions it and the subsequent enrollment imports it into Bolt.
|
||||
jq -e '.device.Secure and (.device.Stored | not)' <<< "$current" >/dev/null || return 0
|
||||
cd -- "$(jq -r .device.SysfsPath <<< "$current")" || return 1
|
||||
[[ $(stat -Lc '%i' .) == "$(jq -r .device.inode <<< "$current")" &&
|
||||
$(cat unique_id) == "$(jq -r .device.Uid <<< "$current")" ]] || {
|
||||
tb_fail 'The Thunderbolt connection changed before key setup'; return 1;
|
||||
}
|
||||
# Pin the attribute in the original directory; do not follow a replacement
|
||||
# connection at the same path, or truncate an existing key on a retry.
|
||||
exec {key_fd}<>key || return 1
|
||||
key=$(cat key) || return 1
|
||||
if [[ -z $key ]]; then
|
||||
[[ $(cat authorized) == "0" ]] || { tb_fail 'Reconnect the device before saving secure approval'; return 1; }
|
||||
key=$(openssl rand -hex 32) || return 1
|
||||
[[ $key =~ ^[0-9a-f]{64}$ ]] || return 1
|
||||
tb_current "$identity" >/dev/null || return 1
|
||||
printf '%s' "$key" >&"$key_fd" || return 1
|
||||
[[ $(cat key) == "$key" ]] || { tb_fail 'Could not verify the Thunderbolt key'; return 1; }
|
||||
else
|
||||
[[ $key =~ ^[0-9a-fA-F]{64}$ ]] || { tb_fail 'The Thunderbolt key is invalid'; return 1; }
|
||||
fi
|
||||
)
|
||||
|
||||
tb_approve() {
|
||||
local identity=$1 permanent=$2 initialize=${3:-false} current owner path status state
|
||||
state=$(tb_state) || return 1
|
||||
jq -e .enabled <<< "$state" >/dev/null || { tb_fail "Thunderbolt protection is disabled"; return 1; }
|
||||
current=$(tb_current "$identity") || return 1
|
||||
owner=$(jq -r .owner <<< "$current")
|
||||
path=$(jq -r .device.path <<< "$current")
|
||||
status=$(jq -r .device.Status <<< "$current")
|
||||
case "$status" in
|
||||
connected|auth-error)
|
||||
if [[ $permanent == "true" ]]; then
|
||||
if [[ $initialize == "true" ]]; then
|
||||
tb_prepare_key "$identity" || return 1
|
||||
else
|
||||
jq -e '(.device.Secure | not) or (.device.Stored and (.device.Key | IN("have","new")))' <<< "$current" >/dev/null || {
|
||||
tb_fail 'A secure key requires explicit device approval'; return 1;
|
||||
}
|
||||
fi
|
||||
fi
|
||||
current=$(tb_current "$identity") || return 1
|
||||
tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null || return 1 ;;
|
||||
authorized|authorized-newkey|authorized-secure) ;;
|
||||
*) tb_fail "Device authorization is still in progress; try again"; return 1 ;;
|
||||
esac
|
||||
current=$(tb_current "$identity") || return 1
|
||||
jq -e '.device.Status | IN("authorized","authorized-newkey","authorized-secure")' <<< "$current" >/dev/null || {
|
||||
tb_fail "Could not confirm device authorization; try again"; return 1;
|
||||
}
|
||||
if [[ $permanent == "true" ]]; then
|
||||
if ! jq -e .device.Stored <<< "$current" >/dev/null; then
|
||||
# Save enrollment intent before Bolt writes its record, so a lost reply
|
||||
# can be retried without confusing our record with a pre-existing one.
|
||||
state=$(jq --argjson device "$(jq -c .device <<< "$current")" \
|
||||
'.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state") || return 1
|
||||
tb_json_write "$TB_STATE" <<< "$state" || return 1
|
||||
tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$(jq -r .device.Uid <<< "$current")" manual '' >/dev/null || return 1
|
||||
fi
|
||||
current=$(tb_current "$identity") || return 1
|
||||
jq -e .device.Stored <<< "$current" >/dev/null || { tb_fail "Could not confirm saved device information; try again"; return 1; }
|
||||
jq -e '(.device.Secure | not) or (.device.Key | IN("have","new"))' <<< "$current" >/dev/null || {
|
||||
tb_fail 'Secure approval has no saved key. Safely disconnect, reconnect, and approve the device again.'; return 1;
|
||||
}
|
||||
jq --argjson device "$(jq -c .device <<< "$current")" '
|
||||
if .enrolled[$device.Uid] == ($device | {Uid,Name,Vendor}) then
|
||||
.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor,StoreTime})
|
||||
else . end |
|
||||
.trusted[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state" |
|
||||
tb_json_write "$TB_STATE" || return 1
|
||||
fi
|
||||
tb_snapshot
|
||||
}
|
||||
|
||||
tb_boot_apply() {
|
||||
local saved=$1 inventory owner item uid policies acls value path
|
||||
inventory=$(tb_inventory) || return 1
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
jq -e --argjson saved "$saved" '
|
||||
(($saved.policies | keys) - [.stored[].Uid] | length == 0) and
|
||||
(($saved.acls | keys) - [.domains[] | select(.SysfsPath != "") | .Uid] | length == 0)' <<< "$inventory" >/dev/null || {
|
||||
tb_fail "Reconnect the original controllers and restore missing Bolt records before recovery"; return 1;
|
||||
}
|
||||
while IFS= read -r item; do
|
||||
uid=$(jq -r .Uid <<< "$item")
|
||||
value=$(jq -c --arg uid "$uid" '.policies[$uid] // empty' <<< "$saved") || return 1
|
||||
[[ -n $value ]] || continue
|
||||
tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DEVICE" Policy "$value" || return 1
|
||||
done < <(jq -c '.stored[]' <<< "$inventory")
|
||||
# Changing a device policy can update firmware ACLs, so restore ACLs last.
|
||||
while IFS= read -r item; do
|
||||
uid=$(jq -r .Uid <<< "$item")
|
||||
value=$(jq -c --arg uid "$uid" '.acls[$uid] // empty' <<< "$saved") || return 1
|
||||
[[ -n $value ]] || continue
|
||||
[[ $(jq length <<< "$value") == "$(jq '.BootACL | length' <<< "$item")" ]] || {
|
||||
tb_fail "Firmware boot allowlist capacity changed"; return 1;
|
||||
}
|
||||
tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DOMAIN" BootACL "$value" as || return 1
|
||||
done < <(jq -c '.domains[]' <<< "$inventory")
|
||||
}
|
||||
|
||||
tb_reconcile() {
|
||||
local inventory state owner generation device identity error='' saved
|
||||
state=$(tb_state) || return 1
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
if jq -e .boot_protection <<< "$state" >/dev/null; then
|
||||
saved=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries),
|
||||
acls:(.domains | map(select(.SysfsPath != "" and (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length>0))) |
|
||||
map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1
|
||||
tb_boot_apply "$saved" || return 1
|
||||
fi
|
||||
while IFS= read -r device; do
|
||||
identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1
|
||||
if ! tb_approve "$identity" true >/dev/null; then
|
||||
error='A trusted Thunderbolt device could not be restored. Check the system service log.'
|
||||
fi
|
||||
done < <(jq -c --argjson state "$state" "$TB_TRUST_JQ"'.devices | sort_by(.SysfsPath | length)[] |
|
||||
select((.Status == "connected" or .Status == "auth-error") and (.AuthFlags | contains("nopcie") | not)) |
|
||||
# Never silently initialize a missing secure binding on a later connection.
|
||||
select(trusted($state))' <<< "$inventory")
|
||||
tb_snapshot "$error"
|
||||
}
|
||||
|
||||
tb_daemon() {
|
||||
local snapshot error policy idle_policy='' idle_since=0
|
||||
install -d -m 755 "$TB_RUNTIME"
|
||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
||||
chmod 644 "$TB_RUNTIME/generation"
|
||||
while [[ -f $TB_MARKER ]]; do
|
||||
policy=''
|
||||
[[ -r $TB_STATE ]] && IFS= read -r -d '' policy < "$TB_STATE" || true
|
||||
# With no controller, no device and no boot protection there is nothing to
|
||||
# restore or reapply, so Bolt is reconciled every TB_IDLE_RECHECK seconds
|
||||
# instead of every tick. A controller appearing, a policy change or a boot
|
||||
# recovery checkpoint brings back the full check at once.
|
||||
if [[ -n $idle_policy && $policy == "$idle_policy" && ! -e ${TB_STATE%/*}/boot-recovery.json ]] &&
|
||||
(( EPOCHSECONDS - idle_since >= 0 && EPOCHSECONDS - idle_since < TB_IDLE_RECHECK )) &&
|
||||
! tb_controller_present; then
|
||||
tb_wait 2
|
||||
continue
|
||||
fi
|
||||
idle_policy=''
|
||||
(
|
||||
flock -x 9
|
||||
if snapshot=$(tb_reconcile 2> "$TB_RUNTIME/error"); then
|
||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$snapshot"
|
||||
else
|
||||
cat "$TB_RUNTIME/error" >&2
|
||||
error=$(< "$TB_RUNTIME/error")
|
||||
jq -cn --arg error "$error" --argjson time "$(date +%s)" '{available:false,time:$time,error:$error}' |
|
||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644
|
||||
fi
|
||||
) 9> "$TB_LOCK"
|
||||
snapshot=''
|
||||
[[ -r $TB_RUNTIME/snapshot.json ]] && IFS= read -r -d '' snapshot < "$TB_RUNTIME/snapshot.json" || true
|
||||
if [[ -n $policy ]] && jq -e '.available == true and .domains == [] and .devices == [] and
|
||||
.warnings == [] and .error == "" and .boot_protection == false' <<< "$snapshot" >/dev/null 2>&1; then
|
||||
idle_policy=$policy idle_since=$EPOCHSECONDS
|
||||
fi
|
||||
tb_wait 2
|
||||
done
|
||||
}
|
||||
|
||||
tb_public_snapshot() {
|
||||
local snapshot generation
|
||||
snapshot=$(cat "$TB_RUNTIME/snapshot.json") || return 1
|
||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
||||
jq -e --argjson now "$(date +%s)" --arg generation "$generation" \
|
||||
'.available and .generation == $generation and .time <= $now and $now - .time < 20' <<< "$snapshot" >/dev/null || {
|
||||
tb_fail 'Thunderbolt policy is unavailable or could not be verified.'; return 1;
|
||||
}
|
||||
printf '%s\n' "$snapshot"
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
# Session-side notification and terminal dialog functions.
|
||||
|
||||
tb_requests() {
|
||||
TB_REQUESTS="$HOME/.local/state/omarchy/thunderbolt-authorization/requests"
|
||||
mkdir -p -m 700 "$TB_REQUESTS"
|
||||
chmod 700 "${TB_REQUESTS%/*}" "$TB_REQUESTS"
|
||||
}
|
||||
|
||||
tb_safe_text() {
|
||||
jq -r 'gsub("[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]"; "?")'
|
||||
}
|
||||
|
||||
tb_notify() {
|
||||
local token=$1 retry=${2:-false} title='Thunderbolt accessory blocked' description
|
||||
description='Click to review it. Keep it blocked unless you recognize the device.'
|
||||
if [[ $retry == "true" ]]; then
|
||||
title='Thunderbolt approval needs retry'
|
||||
description='The last approval could not be confirmed. Click to retry it.'
|
||||
fi
|
||||
omarchy-notification-wait 1 >/dev/null || return 1
|
||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical --glyph '' \
|
||||
"$title" "$description" --exec omarchy-launch-floating-terminal-with-presentation \
|
||||
omarchy-thunderbolt-authorization-review "$token"
|
||||
}
|
||||
|
||||
tb_attention() {
|
||||
omarchy-notification-wait 1 >/dev/null || return 1
|
||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \
|
||||
'Thunderbolt protection needs attention' 'The approval or firmware policy could not be confirmed. Click for details.' \
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization status
|
||||
}
|
||||
|
||||
tb_setup_notice() {
|
||||
omarchy-notification-wait 1 >/dev/null || return 1
|
||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \
|
||||
'Finish Thunderbolt protection setup' 'A connected accessory needs secure enrollment. Protection is not enabled. Click to finish safely.' \
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization
|
||||
}
|
||||
|
||||
tb_token() {
|
||||
local digest
|
||||
digest=$(jq -cS --arg watch "$2" '{watch:$watch,identity:.}' <<< "$1" | sha256sum) || return 1
|
||||
printf 'request-%s\n' "${digest%% *}"
|
||||
}
|
||||
|
||||
tb_request_read() {
|
||||
[[ -f $1 && ! -L $1 ]] || return 1
|
||||
jq -ce 'select(.identity | type == "object")' "$1"
|
||||
}
|
||||
|
||||
tb_deliver() {
|
||||
local path=$1 request token
|
||||
request=$(tb_request_read "$path") || return 1
|
||||
if ! jq -e '.notified // false' <<< "$request" >/dev/null; then
|
||||
token=${path##*/}
|
||||
if tb_notify "${token%.json}" "$(jq -r '.approval != null' <<< "$request")"; then
|
||||
jq '.notified=true' <<< "$request" | tb_json_write "$path"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
tb_request_apply() {
|
||||
# The watcher has no terminal. Polkit permits this one fixed helper for an
|
||||
# active local administrator; it cannot dispatch general setup operations.
|
||||
pkexec --disable-internal-agent /usr/bin/omarchy-thunderbolt-authorization-approve "$1" "$2"
|
||||
}
|
||||
|
||||
tb_request_process() {
|
||||
local path=$1 request identity permanent result error_file error
|
||||
request=$(tb_request_read "$path") || return 1
|
||||
if jq -e '.pending // false' <<< "$request" >/dev/null; then
|
||||
identity=$(jq -c .identity <<< "$request")
|
||||
permanent=$(jq -r '.approval == "Always allow this device"' <<< "$request")
|
||||
error_file=$(mktemp "$TB_REQUESTS/.error.XXXXXX") || return 1
|
||||
if result=$(tb_request_apply "$permanent" "$identity" 2> "$error_file") &&
|
||||
jq -e --argjson identity "$identity" --argjson permanent "$permanent" \
|
||||
'any(.devices[]; .identity==$identity and (.status | IN("authorized","authorized-newkey","authorized-secure")) and
|
||||
($permanent == false or .trusted))' <<< "$result" >/dev/null; then
|
||||
jq --argjson result "$result" '.pending=false | .result=$result | del(.error)' <<< "$request" |
|
||||
tb_json_write "$path" || return 1
|
||||
else
|
||||
error=$(cat "$error_file")
|
||||
[[ -n $error ]] || error='Could not confirm approval. Click the retry notification.'
|
||||
jq --arg error "$error" '.pending=false | .notified=false | .error=$error' <<< "$request" |
|
||||
tb_json_write "$path" || return 1
|
||||
fi
|
||||
rm -f -- "$error_file"
|
||||
fi
|
||||
tb_deliver "$path"
|
||||
}
|
||||
|
||||
tb_scan() {
|
||||
local watch=$1 snapshot path request device identity token existing
|
||||
snapshot=$(tb_public_snapshot) || return 1
|
||||
(
|
||||
flock -x 9
|
||||
for path in "$TB_REQUESTS"/request-*.json; do
|
||||
[[ -e $path || -L $path ]] || continue
|
||||
if ! request=$(tb_request_read "$path"); then
|
||||
rm -f -- "$path"
|
||||
continue
|
||||
fi
|
||||
identity=$(jq -c .identity <<< "$request")
|
||||
device=$(jq -c --argjson identity "$identity" '.devices[] | select(.identity==$identity)' <<< "$snapshot") || exit 1
|
||||
if [[ -z $device ]] || { ! jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null &&
|
||||
! jq -e '.approval != null' <<< "$request" >/dev/null; }; then
|
||||
rm -- "$path"
|
||||
continue
|
||||
fi
|
||||
if [[ $(jq -r .watch <<< "$request") != "$watch" ]]; then
|
||||
rm -- "$path"
|
||||
# Resume an interrupted attempt only for the same exact connection.
|
||||
jq -e '.approval != null and .result == null' <<< "$request" >/dev/null || continue
|
||||
token=$(tb_token "$identity" "$watch") || exit 1
|
||||
path="$TB_REQUESTS/$token.json"
|
||||
request=$(jq -c --arg watch "$watch" '.watch=$watch | .notified=false' <<< "$request") || exit 1
|
||||
tb_json_write "$path" <<< "$request" || exit 1
|
||||
fi
|
||||
tb_request_process "$path" || true
|
||||
done
|
||||
snapshot=$(tb_public_snapshot) || exit 1
|
||||
while IFS= read -r device; do
|
||||
identity=$(jq -c .identity <<< "$device")
|
||||
token=$(tb_token "$identity" "$watch") || exit 1
|
||||
path="$TB_REQUESTS/$token.json"
|
||||
if [[ ! -f $path ]]; then
|
||||
jq -cn --argjson identity "$identity" --arg watch "$watch" \
|
||||
'{identity:$identity,watch:$watch,notified:false}' | tb_json_write "$path" || exit 1
|
||||
if ! tb_deliver "$path"; then
|
||||
rm -f -- "$path"
|
||||
fi
|
||||
fi
|
||||
done < <(jq -c '.devices[] | select((.trusted | not) and (.status | IN("connected","auth-error")) and
|
||||
(.flags | contains("nopcie") | not))' <<< "$snapshot")
|
||||
) 9> "$TB_REQUESTS/.lock" || return 1
|
||||
printf '%s\n' "$snapshot"
|
||||
}
|
||||
|
||||
tb_watch() {
|
||||
local watch snapshot warning warned='' failures=0 text generation clean='' clean_generation='' clean_time=0
|
||||
tb_requests
|
||||
watch=$(cat /proc/sys/kernel/random/uuid)
|
||||
while true; do
|
||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
||||
if [[ $warned != "pending" ]] && tb_setup_notice; then warned=pending; fi
|
||||
clean=''
|
||||
tb_wait 2
|
||||
continue
|
||||
fi
|
||||
# The last scan's snapshot, unchanged, still fresh and from the same daemon,
|
||||
# with no device, warning or request in flight, has nothing to deliver.
|
||||
text='' generation=''
|
||||
[[ -r $TB_RUNTIME/snapshot.json ]] && IFS= read -r -d '' text < "$TB_RUNTIME/snapshot.json" || true
|
||||
[[ -r $TB_RUNTIME/generation ]] && IFS= read -r generation < "$TB_RUNTIME/generation" || true
|
||||
if [[ -n $clean && ${text%$'\n'} == "$clean" && $generation == "$clean_generation" ]] &&
|
||||
(( EPOCHSECONDS - clean_time >= 0 && EPOCHSECONDS - clean_time < 20 )) &&
|
||||
! compgen -G "$TB_REQUESTS/request-*.json" >/dev/null; then
|
||||
tb_wait 2
|
||||
continue
|
||||
fi
|
||||
clean=''
|
||||
if snapshot=$(tb_scan "$watch"); then
|
||||
failures=0
|
||||
warning=$(jq -c '[.generation,.warnings,.error]' <<< "$snapshot")
|
||||
if jq -e '(.warnings | length > 0) or .error != ""' <<< "$snapshot" >/dev/null; then
|
||||
if [[ $warning != "$warned" ]] && tb_attention; then
|
||||
warned=$warning
|
||||
fi
|
||||
else
|
||||
warned=''
|
||||
if jq -e '.devices == []' <<< "$snapshot" >/dev/null; then
|
||||
IFS=' ' read -r clean_generation clean_time <<< "$(jq -r '"\(.generation) \(.time)"' <<< "$snapshot")"
|
||||
[[ $clean_time =~ ^[0-9]+$ ]] && clean=$snapshot
|
||||
fi
|
||||
fi
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
if (( failures >= 3 )) && [[ $warned != "unavailable" ]] && tb_attention; then
|
||||
warned=unavailable
|
||||
fi
|
||||
fi
|
||||
tb_wait 2
|
||||
done
|
||||
}
|
||||
|
||||
tb_find_device() {
|
||||
local snapshot
|
||||
snapshot=$(tb_public_snapshot) || return 1
|
||||
jq -ce --argjson identity "$1" '.devices[] | select(.identity==$identity)' <<< "$snapshot" ||
|
||||
tb_fail 'That Thunderbolt device was removed or changed. Open its latest notification.'
|
||||
}
|
||||
|
||||
tb_review() {
|
||||
local token=$1 path request identity device choice latest index
|
||||
[[ $token =~ ^request-[0-9a-f]{64}$ ]] || { tb_fail 'Invalid or expired Thunderbolt request'; return 1; }
|
||||
tb_requests
|
||||
path="$TB_REQUESTS/$token.json"
|
||||
request=$(tb_request_read "$path") || return 1
|
||||
identity=$(jq -c .identity <<< "$request")
|
||||
device=$(tb_find_device "$identity") || return 1
|
||||
choice=$(jq -r '.approval // empty' <<< "$request")
|
||||
if [[ -z $choice ]]; then
|
||||
jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || { tb_fail 'This device is no longer blocked'; return 1; }
|
||||
gum style --foreground 212 --bold 'Blocked Thunderbolt accessory'
|
||||
printf '\nThese details come from the device and can be forged:\n'
|
||||
for index in Name Vendor Uid; do
|
||||
printf '%s: ' "$index"
|
||||
jq --arg key "$index" '.[$key]' <<< "$identity" | tb_safe_text
|
||||
done
|
||||
printf '\nApproval gives this accessory access to its PCIe drivers.\n\n'
|
||||
choice=$(gum choose 'Allow once' 'Always allow this device' 'Keep blocked') || return 0
|
||||
fi
|
||||
case "$choice" in
|
||||
'Allow once'|'Always allow this device'|'Keep blocked') ;;
|
||||
*) tb_fail 'Invalid approval choice'; return 1 ;;
|
||||
esac
|
||||
(
|
||||
flock -x 9
|
||||
latest=$(tb_request_read "$path") || exit 1
|
||||
[[ $latest == "$request" ]] || { tb_fail 'This request changed. Open the latest notification.'; exit 1; }
|
||||
device=$(tb_find_device "$identity") || exit 1
|
||||
if [[ $choice == "Keep blocked" ]]; then
|
||||
printf 'Thunderbolt accessory remains blocked.\n'
|
||||
else
|
||||
if ! jq -e '.approval != null' <<< "$request" >/dev/null; then
|
||||
jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || exit 1
|
||||
fi
|
||||
jq --arg choice "$choice" '.approval=$choice | .pending=true | del(.error,.result)' <<< "$request" |
|
||||
tb_json_write "$path"
|
||||
fi
|
||||
) 9> "$TB_REQUESTS/.lock" || return 1
|
||||
if [[ $choice != "Keep blocked" ]]; then
|
||||
for ((index=0; index<60; index++)); do
|
||||
latest=$(tb_request_read "$path") || return 1
|
||||
if jq -e '.result != null' <<< "$latest" >/dev/null; then
|
||||
rm -- "$path"
|
||||
if [[ $choice == "Always allow this device" ]]; then
|
||||
printf 'Thunderbolt accessory trusted.\n'
|
||||
else
|
||||
printf 'Thunderbolt accessory allowed for this connection.\n'
|
||||
fi
|
||||
return 0
|
||||
elif jq -e '.error != null' <<< "$latest" >/dev/null; then
|
||||
jq .error <<< "$latest" | tb_safe_text >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
tb_fail 'Approval is still pending. The watcher will retain and retry the request.'
|
||||
fi
|
||||
}
|
||||
|
||||
tb_status() {
|
||||
local snapshot
|
||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
||||
printf 'Thunderbolt protection is deferred. Previous Bolt behavior remains active.\nKeep a keyboard/input device available independently of Thunderbolt. Safely disconnect the Thunderbolt accessories, then run Setup > Security > Thunderbolt. Reconnect each accessory and choose Always allow to save its secure key.\n'
|
||||
return 0
|
||||
fi
|
||||
snapshot=$(tb_public_snapshot) || return 1
|
||||
printf 'Thunderbolt device approval is enabled.\nTrusted accessories reconnect automatically; new accessories require approval.\n'
|
||||
if jq -e '.domains | length == 0' <<< "$snapshot" >/dev/null; then
|
||||
printf 'No Thunderbolt controller is currently visible. Its firmware policy will be checked when it appears.\n'
|
||||
fi
|
||||
jq '.warnings[], (.error | select(. != ""))' <<< "$snapshot" | while IFS= read -r warning; do
|
||||
printf 'Attention: '
|
||||
tb_safe_text <<< "$warning"
|
||||
done
|
||||
printf 'Firmware boot access is separate; see the Security manual before relying on pre-boot protection.\n'
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
# Root setup functions. The caller sources thunderbolt-policy.sh first.
|
||||
|
||||
tb_config_authmode() {
|
||||
local path=$1
|
||||
if [[ -f $path ]]; then
|
||||
awk '
|
||||
/^[[:space:]]*\[/ { section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) }
|
||||
section && /^[[:space:]]*AuthMode[[:space:]]*=/ {
|
||||
sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); mode=$0
|
||||
}
|
||||
END { print mode == "" ? "enabled" : mode }
|
||||
' "$path"
|
||||
else
|
||||
printf 'enabled\n'
|
||||
fi
|
||||
}
|
||||
|
||||
tb_config_write() {
|
||||
local path=$1 mode=$2 temporary
|
||||
[[ $mode == "enabled" || $mode == "disabled" ]] || return 1
|
||||
install -d -m 700 "${path%/*}" || return 1
|
||||
temporary=$(mktemp "${path%/*}/.config.XXXXXX") || return 1
|
||||
if awk -v mode="$mode" '
|
||||
/^[[:space:]]*\[/ {
|
||||
section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/)
|
||||
if (section) { found=1; print; print "AuthMode = " mode; next }
|
||||
}
|
||||
section && /^[[:space:]]*AuthMode[[:space:]]*=/ { next }
|
||||
{ print }
|
||||
END { if (!found) print "\n[config]\nAuthMode = " mode }
|
||||
' "$(if [[ -e $path ]]; then printf '%s' "$path"; else printf /dev/null; fi)" > "$temporary" &&
|
||||
sync "$temporary" && mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then
|
||||
return 0
|
||||
else
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_guard() {
|
||||
if [[ -f $TB_MARKER ]]; then
|
||||
tb_state | jq -e .enabled >/dev/null || { tb_fail 'Thunderbolt policy and enable marker disagree'; return 1; }
|
||||
tb_config_write "$TB_CONFIG" disabled
|
||||
fi
|
||||
}
|
||||
|
||||
tb_capture() {
|
||||
local path uid name vendor trusted='{}'
|
||||
for path in "$TB_SYSFS"/*; do
|
||||
[[ -f $path/unique_id && $path != *-0 ]] || continue
|
||||
uid=$(cat "$path/unique_id") || return 1
|
||||
# Bolt uses the numeric attribute when a descriptive name is absent.
|
||||
if [[ -e $path/device_name ]]; then
|
||||
name=$(cat "$path/device_name") || return 1
|
||||
else
|
||||
name=$(cat "$path/device") || return 1
|
||||
fi
|
||||
if [[ -e $path/vendor_name ]]; then
|
||||
vendor=$(cat "$path/vendor_name") || return 1
|
||||
else
|
||||
vendor=$(cat "$path/vendor") || return 1
|
||||
fi
|
||||
[[ -n $uid ]] || { tb_fail 'A connected Thunderbolt device has no identity'; return 1; }
|
||||
trusted=$(jq -c --arg uid "$uid" --arg name "$name" --arg vendor "$vendor" \
|
||||
'.[$uid] = {Uid:$uid,Name:$name,Vendor:$vendor}' <<< "$trusted") || return 1
|
||||
done
|
||||
printf '%s\n' "$trusted"
|
||||
}
|
||||
|
||||
tb_capture_ready() {
|
||||
local fresh=${1:-false} offline=${2:-false} path uid inode inventory=''
|
||||
# Do not relax an active policy, or capture new devices when re-enabling an
|
||||
# existing policy. This preflight only guards the initial trust inventory.
|
||||
[[ ! -f $TB_MARKER ]] || return 0
|
||||
if [[ -f $TB_STATE && ! -f $TB_PENDING && $fresh != "true" ]]; then
|
||||
tb_state | jq -e '.enrollment_pending // false' >/dev/null || return 0
|
||||
fi
|
||||
for path in "$TB_SYSFS"/*; do
|
||||
[[ -f $path/unique_id && $path != *-0 && -e $path/key ]] || continue
|
||||
uid=$(cat "$path/unique_id") || return 1
|
||||
[[ $uid =~ ^[0-9a-fA-F-]{36}$ ]] || { tb_fail 'A connected Thunderbolt device has an invalid identity'; return 1; }
|
||||
path=$(realpath -e -- "$path") || return 1
|
||||
inode=$(stat -Lc '%i' -- "$path") || return 1
|
||||
# A key file alone can be orphaned after a failed Bolt record write. Ask
|
||||
# Bolt whether this exact connection has a stored record and key. Offline
|
||||
# prepare must not accept the live installer's daemon as the target store.
|
||||
if [[ $offline != "true" && -z $inventory ]]; then
|
||||
inventory=$(tb_inventory) || inventory='{}'
|
||||
fi
|
||||
if [[ $offline == "true" ]] || ! jq -e --arg uid "$uid" --arg path "$path" --arg inode "$inode" \
|
||||
'any(.devices[]?; .Uid==$uid and .SysfsPath==$path and .inode==$inode and .Stored and (.Key | IN("have","new")))' <<< "$inventory" >/dev/null; then
|
||||
# An active keyless dock cannot receive a key without disconnecting it.
|
||||
# Preserve input/storage and finish installation with setup deferred.
|
||||
install -d -m 755 "${TB_PENDING%/*}" || return 1
|
||||
install -m 644 /dev/null "$TB_PENDING" || return 1
|
||||
printf 'Thunderbolt protection is deferred: safely disconnect accessories without saved secure keys, keep independent input available, then run Setup > Security > Thunderbolt.\n' >&2
|
||||
return 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
tb_prepare() {
|
||||
local fresh=${1:-false} state='{}' trusted mode
|
||||
if [[ -f $TB_STATE ]]; then
|
||||
state=$(tb_state) || return 1
|
||||
fi
|
||||
if [[ ! -f $TB_STATE || -f $TB_PENDING || $fresh == "true" ]] || jq -e '.enrollment_pending // false' <<< "$state" >/dev/null; then
|
||||
trusted=$(tb_capture) || return 1
|
||||
mode=$(jq -r '.original_authmode // empty' <<< "$state") || return 1
|
||||
[[ -n $mode ]] || mode=$(tb_config_authmode "$TB_CONFIG") || return 1
|
||||
[[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; }
|
||||
state=$(jq -c --arg mode "$mode" --argjson trusted "$trusted" \
|
||||
'del(.enrollment_pending) | . + {version:1,enabled:true,trusted:$trusted,original_authmode:$mode}' <<< "$state") || return 1
|
||||
else
|
||||
mode=$(jq -r .original_authmode <<< "$state") || return 1
|
||||
[[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; }
|
||||
state=$(jq -c '.enabled=true' <<< "$state") || return 1
|
||||
fi
|
||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
||||
tb_json_write "$TB_STATE" <<< "$state" || return 1
|
||||
tb_config_write "$TB_CONFIG" disabled || return 1
|
||||
install -d -m 755 "${TB_MARKER%/*}" || return 1
|
||||
install -m 644 /dev/null "$TB_MARKER"
|
||||
}
|
||||
|
||||
tb_boot_saved() {
|
||||
jq -c '{policies:(.stored | map({key:.Uid,value:.Policy}) | from_entries),
|
||||
acls:(.domains | map({key:.Uid,value:.BootACL}) | from_entries)}'
|
||||
}
|
||||
|
||||
tb_boot_change() {
|
||||
local enabled=$1 state inventory saved after desired backup="${TB_STATE%/*}/boot-recovery.json"
|
||||
state=$(tb_state) || return 1
|
||||
jq -e .enabled <<< "$state" >/dev/null || { tb_fail 'Enable Thunderbolt device authorization first'; return 1; }
|
||||
if [[ $enabled == "false" ]] && ! jq -e .boot_protection <<< "$state" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
[[ ! -e $backup ]] || { tb_fail 'Recover the previous firmware boot-access change before retrying'; return 1; }
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
jq -e '.domains | length > 0 and all(.[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || {
|
||||
tb_fail 'Connect every Thunderbolt controller before changing firmware boot access'; return 1;
|
||||
}
|
||||
saved=$(tb_boot_saved <<< "$inventory" | jq -c --argjson state "$state" '. + {state:$state}') || return 1
|
||||
if [[ $enabled == "true" ]]; then
|
||||
jq -e 'all(.domains[]; (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length > 0))' <<< "$inventory" >/dev/null || {
|
||||
tb_fail 'This controller has no supported firmware boot allowlist. Disable Thunderbolt pre-boot/PCIe boot support in firmware settings; Omarchy cannot verify that setting.'; return 1;
|
||||
}
|
||||
desired=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries),
|
||||
acls:(.domains | map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1
|
||||
after=$(jq -c --argjson saved "$saved" --argjson devices "$(jq -c .devices <<< "$inventory")" '
|
||||
if .boot_protection then . else .boot_original=($saved | {policies,acls}) end |
|
||||
.boot_protection=true | .trusted += ($devices | map({key:.Uid,value:{Uid,Name,Vendor}}) | from_entries)' <<< "$state") || return 1
|
||||
else
|
||||
desired=$(jq -ce .boot_original <<< "$state") || return 1
|
||||
after=$(jq -c 'del(.boot_original) | .boot_protection=false' <<< "$state") || return 1
|
||||
fi
|
||||
tb_json_write "$backup" <<< "$saved" || return 1
|
||||
if tb_boot_apply "$desired" && tb_json_write "$TB_STATE" <<< "$after"; then
|
||||
rm -- "$backup"
|
||||
else
|
||||
if tb_boot_apply "$saved" && tb_json_write "$TB_STATE" <<< "$state"; then
|
||||
rm -- "$backup" || return 1
|
||||
tb_fail 'Firmware change failed; previous state restored'
|
||||
else
|
||||
tb_fail "Firmware change failed; recovery is incomplete. Recovery data retained at $backup"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_boot_recover() {
|
||||
local backup="${TB_STATE%/*}/boot-recovery.json" saved
|
||||
saved=$(jq -ce . "$backup") || return 1
|
||||
tb_boot_apply "$saved" || return 1
|
||||
jq .state <<< "$saved" | tb_json_write "$TB_STATE" || return 1
|
||||
rm -- "$backup"
|
||||
}
|
||||
|
||||
tb_unit_state() {
|
||||
local properties
|
||||
properties=$(systemctl show -p ActiveState -p UnitFileState "$1") || return 1
|
||||
jq -Rn '[inputs | split("=") | {key:.[0],value:.[1]}] | from_entries |
|
||||
{active:(.ActiveState == "active"),enabled:(.UnitFileState == "enabled")}' <<< "$properties"
|
||||
}
|
||||
|
||||
tb_handoff_policies() {
|
||||
local state=$1
|
||||
# StoreTime distinguishes a record recreated outside Omarchy. A completed
|
||||
# enrollment and matching consent are both required, including offline
|
||||
# accessories. Never promote somebody else's manual policy.
|
||||
jq -c --argjson state "$state" '{policies:([.stored[] |
|
||||
select(.Policy == "manual" and .StoreTime > 0 and
|
||||
$state.enrolled[.Uid] == {Uid,Name,Vendor,StoreTime} and
|
||||
$state.trusted[.Uid] == {Uid,Name,Vendor}) |
|
||||
{key:.Uid,value:"auto"}] | from_entries),acls:{}}'
|
||||
}
|
||||
|
||||
tb_config_checkpoint() {
|
||||
local backup="${TB_STATE%/*}/setup-recovery.json" path original files='{}' units='{}' unit value saved inventory data mode state
|
||||
[[ ! -e $backup && ! -e ${TB_STATE%/*}/boot-recovery.json ]] || {
|
||||
tb_fail 'A previous setup change needs recovery before continuing'; return 1;
|
||||
}
|
||||
for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do
|
||||
original=null
|
||||
if [[ -e $path ]]; then
|
||||
data=$(base64 -w0 -- "$path") || return 1
|
||||
mode=$(stat -c '%a' -- "$path") || return 1
|
||||
original=$(jq -cn --arg data "$data" --arg mode "$mode" '{data:$data,mode:$mode}') || return 1
|
||||
fi
|
||||
files=$(jq -c --arg path "$path" --argjson original "$original" '.[$path]=$original' <<< "$files") || return 1
|
||||
done
|
||||
for unit in bolt.service "$TB_SERVICE"; do
|
||||
value=$(tb_unit_state "$unit") || return 1
|
||||
units=$(jq -c --arg unit "$unit" --argjson value "$value" '.[$unit]=$value' <<< "$units") || return 1
|
||||
done
|
||||
saved=$(jq -cn --argjson files "$files" --argjson units "$units" '{files:$files,units:$units}') || return 1
|
||||
state='{}'
|
||||
if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi
|
||||
# A handoff to auto also changes Bolt's firmware allowlists. Checkpoint the
|
||||
# policies and ACLs even when the optional boot protection was never used.
|
||||
if jq -e '.enabled and (.boot_protection or ((.enrolled // {}) | length > 0))' <<< "$state" >/dev/null; then
|
||||
inventory=$(tb_read_inventory) || return 1
|
||||
jq -e 'all(.domains[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || {
|
||||
tb_fail 'Reconnect every Thunderbolt controller before changing device policies'; return 1;
|
||||
}
|
||||
saved=$(jq -c --argjson firmware "$(tb_boot_saved <<< "$inventory")" '.firmware=$firmware' <<< "$saved") || return 1
|
||||
fi
|
||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
||||
tb_json_write "$backup" <<< "$saved"
|
||||
}
|
||||
|
||||
tb_config_restore() {
|
||||
local backup="${TB_STATE%/*}/setup-recovery.json" saved path original temporary unit
|
||||
saved=$(jq -ce . "$backup") || return 1
|
||||
systemctl stop "$TB_SERVICE" bolt.service || return 1
|
||||
for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do
|
||||
original=$(jq -c --arg path "$path" '.files[$path]' <<< "$saved") || return 1
|
||||
if [[ $original == "null" ]]; then
|
||||
rm -f -- "$path" || return 1
|
||||
else
|
||||
mkdir -p -- "${path%/*}" || return 1
|
||||
temporary=$(mktemp "${path%/*}/.restore.XXXXXX") || return 1
|
||||
if jq -r .data <<< "$original" | base64 -d > "$temporary" &&
|
||||
chmod "$(jq -r .mode <<< "$original")" "$temporary" && sync "$temporary" &&
|
||||
mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then
|
||||
:
|
||||
else
|
||||
rm -f -- "$temporary"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if jq -e .firmware <<< "$saved" >/dev/null; then
|
||||
systemctl start bolt.service || return 1
|
||||
tb_boot_apply "$(jq -c .firmware <<< "$saved")" || return 1
|
||||
fi
|
||||
rm -f -- "${TB_STATE%/*}/boot-recovery.json" || return 1
|
||||
if jq -e --arg unit "$TB_SERVICE" '.units[$unit].enabled' <<< "$saved" >/dev/null; then
|
||||
systemctl enable "$TB_SERVICE" || return 1
|
||||
else
|
||||
systemctl disable "$TB_SERVICE" || return 1
|
||||
fi
|
||||
for unit in bolt.service "$TB_SERVICE"; do
|
||||
if jq -e --arg unit "$unit" '.units[$unit].active' <<< "$saved" >/dev/null; then
|
||||
systemctl start "$unit" || return 1
|
||||
else
|
||||
systemctl stop "$unit" || return 1
|
||||
fi
|
||||
done
|
||||
rm -- "$backup"
|
||||
}
|
||||
|
||||
tb_transaction() {
|
||||
tb_config_checkpoint || return 1
|
||||
if "$@"; then
|
||||
rm -- "${TB_STATE%/*}/setup-recovery.json"
|
||||
else
|
||||
if tb_config_restore; then
|
||||
tb_fail 'Thunderbolt setup failed; previous configuration restored'
|
||||
else
|
||||
tb_fail 'Thunderbolt setup failed; restoration is incomplete. Run the setup recovery command.'
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
tb_enable() {
|
||||
systemctl stop "$TB_SERVICE" bolt.service || return 1
|
||||
tb_prepare "${1:-false}" || return 1
|
||||
systemctl daemon-reload || return 1
|
||||
systemctl start bolt.service || return 1
|
||||
tb_read_inventory >/dev/null || return 1
|
||||
systemctl enable --now "$TB_SERVICE"
|
||||
}
|
||||
|
||||
tb_disable() {
|
||||
local state mode policies
|
||||
systemctl stop "$TB_SERVICE" || return 1
|
||||
tb_boot_change false || return 1
|
||||
state=$(tb_state) || return 1
|
||||
mode=$(jq -er .original_authmode <<< "$state") || return 1
|
||||
policies=$(tb_read_inventory | tb_handoff_policies "$state") || return 1
|
||||
tb_boot_apply "$policies" || return 1
|
||||
systemctl stop bolt.service || return 1
|
||||
tb_config_write "$TB_CONFIG" "$mode" || return 1
|
||||
rm -f -- "$TB_MARKER" || return 1
|
||||
jq '.enabled=false | del(.enrolled)' <<< "$state" | tb_json_write "$TB_STATE" || return 1
|
||||
systemctl disable "$TB_SERVICE" || return 1
|
||||
systemctl start bolt.service || return 1
|
||||
tb_inventory | jq -e --arg mode "$mode" --argjson policies "$policies" '
|
||||
.manager.AuthMode==$mode and
|
||||
(([.stored[] | {key:.Uid,value:.Policy}] | from_entries) as $actual |
|
||||
all($policies.policies | to_entries[]; $actual[.key]==.value))' >/dev/null
|
||||
}
|
||||
|
||||
tb_cancel_pending() {
|
||||
local state
|
||||
if [[ -f $TB_STATE ]]; then
|
||||
state=$(tb_state) || return 1
|
||||
else
|
||||
state=$(jq -cn --arg mode "$(tb_config_authmode "$TB_CONFIG")" \
|
||||
'{version:1,enabled:false,trusted:{},enrollment_pending:true,original_authmode:$mode}') || return 1
|
||||
fi
|
||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
||||
jq '.enabled=false | .enrollment_pending=true' <<< "$state" | tb_json_write "$TB_STATE" || return 1
|
||||
rm -- "$TB_PENDING"
|
||||
}
|
||||
|
||||
tb_reset_root() {
|
||||
local root directory
|
||||
root=$(realpath -e -- "$1") || return 1
|
||||
[[ $root != "/" && -d $root ]] || { tb_fail 'Refusing to reset the running system'; return 1; }
|
||||
[[ -e $root$TB_MARKER || -e $root$TB_PENDING || -d $root${TB_STATE%/*} ]] || return 0
|
||||
# Resolve fixed paths inside the cloned root, never through an outside link.
|
||||
for directory in etc etc/omarchy var var/lib var/lib/omarchy var/lib/boltd; do
|
||||
[[ ! -L $root/$directory ]] || { tb_fail 'Refusing a linked factory policy directory'; return 1; }
|
||||
done
|
||||
systemctl --root="$root" disable "$TB_SERVICE" || return 1
|
||||
rm -f -- "$root$TB_MARKER" || return 1
|
||||
rm -f -- "$root$TB_PENDING" || return 1
|
||||
rm -rf -- "$root${TB_STATE%/*}" || return 1
|
||||
for directory in devices keys domains; do
|
||||
rm -rf -- "$root/var/lib/boltd/$directory" || return 1
|
||||
done
|
||||
tb_config_write "$root/var/lib/boltd/boltd.conf" enabled
|
||||
}
|
||||
|
||||
tb_admin() {
|
||||
local action=${1:-} status=0 state
|
||||
if [[ $action != "guard" ]]; then
|
||||
exec 9> "$TB_LOCK"
|
||||
flock -x 9 || return 1
|
||||
fi
|
||||
case "$action" in
|
||||
prepare|enable|owner|migrate)
|
||||
# A deferred setup needs explicit consent after the accessories have
|
||||
# been disconnected; a later migration must not silently finish it.
|
||||
if [[ -f $TB_PENDING && $action != "enable" ]]; then return 0; fi
|
||||
if [[ $action == "migrate" && -f $TB_STATE ]] && tb_state | jq -e '.enabled == false' >/dev/null; then return 0; fi
|
||||
if tb_capture_ready "$(if [[ $action == "owner" ]]; then echo true; else echo false; fi)" \
|
||||
"$(if [[ $action == "prepare" ]]; then echo true; else echo false; fi)"; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if (( status == 2 )); then return 0; else return "$status"; fi
|
||||
fi ;;
|
||||
esac
|
||||
case "$action" in
|
||||
guard) tb_guard ;;
|
||||
prepare) tb_prepare && rm -f -- "$TB_PENDING" ;;
|
||||
enable) tb_transaction tb_enable && rm -f -- "$TB_PENDING" ;;
|
||||
owner) tb_transaction tb_enable true && rm -f -- "$TB_PENDING" ;;
|
||||
migrate)
|
||||
state='{"enabled":true}'
|
||||
if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi
|
||||
if jq -e .enabled <<< "$state" >/dev/null; then
|
||||
tb_transaction tb_enable
|
||||
fi ;;
|
||||
disable)
|
||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
||||
tb_cancel_pending
|
||||
else
|
||||
tb_transaction tb_disable
|
||||
fi ;;
|
||||
recover) tb_config_restore ;;
|
||||
boot-enable|boot-disable|boot-recover)
|
||||
systemctl stop "$TB_SERVICE" || return 1
|
||||
case "$action" in
|
||||
boot-enable) tb_boot_change true || status=$? ;;
|
||||
boot-disable) tb_boot_change false || status=$? ;;
|
||||
boot-recover) tb_boot_recover || status=$? ;;
|
||||
esac
|
||||
systemctl start "$TB_SERVICE" || return 1
|
||||
return "$status" ;;
|
||||
reset-root) [[ $# == 2 ]] && tb_reset_root "$2" ;;
|
||||
*) tb_fail 'Unknown Thunderbolt setup action' ;;
|
||||
esac
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
# Device identity is portable; the full live rule is still used when granting
|
||||
# access to the connection shown in the approval dialog.
|
||||
usb_authorization_portable_rule() {
|
||||
local rule="$1" token="" char quoted=0 escaped=0 index
|
||||
local -a tokens=() portable=()
|
||||
|
||||
# Split canonical USBGuard output without evaluating device-controlled text.
|
||||
# An attribute name inside a quoted name/serial is never a rule attribute.
|
||||
for (( index=0; index<${#rule}; index++ )); do
|
||||
char=${rule:index:1}
|
||||
if (( escaped )); then
|
||||
token+="$char"
|
||||
escaped=0
|
||||
elif (( quoted )) && [[ $char == '\' ]]; then
|
||||
token+="$char"
|
||||
escaped=1
|
||||
elif [[ $char == '"' ]]; then
|
||||
token+="$char"
|
||||
quoted=$((1 - quoted))
|
||||
elif (( ! quoted )) && [[ $char == " " ]]; then
|
||||
if [[ -n $token ]]; then tokens+=("$token"); token=""; fi
|
||||
else
|
||||
token+="$char"
|
||||
fi
|
||||
done
|
||||
(( ! quoted && ! escaped )) || return 1
|
||||
if [[ -n $token ]]; then tokens+=("$token"); fi
|
||||
[[ ${tokens[0]:-} == "allow" || ${tokens[0]:-} == "block" ]] || return 1
|
||||
portable=(allow)
|
||||
for (( index=1; index<${#tokens[@]}; index++ )); do
|
||||
case "${tokens[index]}" in
|
||||
via-port | parent-hash)
|
||||
(( ++index < ${#tokens[@]} )) || return 1
|
||||
[[ ${tokens[index]} == \"*\" ]] || return 1
|
||||
;;
|
||||
label) return 1 ;;
|
||||
*) portable+=("${tokens[index]}") ;;
|
||||
esac
|
||||
done
|
||||
local IFS=" "
|
||||
printf '%s label "omarchy-usb-authorization-v1"\n' "${portable[*]}"
|
||||
}
|
||||
|
||||
usb_authorization_permanent() {
|
||||
local id="$1" expected="$2" devices current="" allowed portable policy line target new_id matches rule saved=0
|
||||
|
||||
[[ $id =~ ^[0-9]+$ && ( $expected == block\ * || $expected == allow\ * ) ]] || return 1
|
||||
(( ${#expected} <= 16384 )) || return 1
|
||||
devices=$(usbguard list-devices) || return 1
|
||||
while IFS= read -r line; do
|
||||
if [[ $line == "$id: "* ]]; then current=${line#"$id: "}; fi
|
||||
done <<<"$devices"
|
||||
[[ $current == "$expected" ]] || return 1
|
||||
allowed="allow ${expected#* }"
|
||||
portable=$(usb_authorization_portable_rule "$current") || return 1
|
||||
usbguard-rule-parser "$portable" >/dev/null || return 1
|
||||
|
||||
# Respect earlier administrator deny rules. Do not report permanent success
|
||||
# when a saved allow would lose to an explicit block/reject on reconnect.
|
||||
policy=$(usbguard list-rules) || return 1
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^[0-9]+:[[:space:]](allow|block|reject)([[:space:]]|$) ]]; then
|
||||
target=${BASH_REMATCH[1]}
|
||||
rule=${line#*: }
|
||||
# --show-devices filters by current target, hiding deny rules for a
|
||||
# temporarily allowed device. A match query compares identity instead.
|
||||
matches=$(usbguard list-devices "match${rule#"$target"}") || return 1
|
||||
if grep -Fqx -- "$id: $current" <<<"$matches"; then
|
||||
if [[ $target != "allow" ]]; then
|
||||
echo "An administrator rule blocks this device. Review that rule before saving approval." >&2
|
||||
return 1
|
||||
fi
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done <<<"$policy"
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^([0-9]+):[[:space:]] && ${line#*: } == "$portable" ]]; then
|
||||
saved=1
|
||||
fi
|
||||
done <<<"$policy"
|
||||
|
||||
# A retry can reuse only an exact owned rule present in both the daemon and
|
||||
# its persistent file. Never remove/reorder rules around manual restrictions.
|
||||
if (( ! saved )) || ! grep -Fqx -- "$portable" /etc/usbguard/rules.conf; then
|
||||
new_id=$(usbguard append-rule "$portable") || return 1
|
||||
[[ $new_id =~ ^[0-9]+$ ]] || return 1
|
||||
grep -Fqx -- "$portable" /etc/usbguard/rules.conf || return 1
|
||||
fi
|
||||
if [[ $current == block\ * ]]; then
|
||||
usbguard allow-device "$expected" || return 1
|
||||
fi
|
||||
devices=$(usbguard list-devices --allowed) || return 1
|
||||
grep -Fqx -- "$1: $allowed" <<<"$devices"
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
source "${BASH_SOURCE[0]%/*}/usb-authorization-policy.sh"
|
||||
|
||||
usb_authorization_config_value() {
|
||||
local config="$1" key="$2"
|
||||
local awk_command=(awk)
|
||||
|
||||
if [[ ! -r $config ]]; then
|
||||
if [[ $config == "/etc/usbguard/usbguard-daemon.conf" ]]; then
|
||||
awk_command=(sudo awk)
|
||||
else
|
||||
echo "Cannot read USBGuard configuration: $config" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
"${awk_command[@]}" -F= -v key="$key" '
|
||||
/^[[:space:]]*#/ { next }
|
||||
$1 ~ "^[[:space:]]*" key "[[:space:]]*$" {
|
||||
value=$0
|
||||
sub(/^[^=]*=[[:space:]]*/, "", value)
|
||||
sub(/[[:space:]]*$/, "", value)
|
||||
print value
|
||||
exit
|
||||
}
|
||||
' "$config"
|
||||
}
|
||||
|
||||
usb_authorization_rules_present() {
|
||||
local rules="$1"
|
||||
|
||||
if [[ -r $rules ]]; then
|
||||
[[ -s $rules ]]
|
||||
elif [[ $rules == "/etc/usbguard/rules.conf" ]]; then
|
||||
sudo test -s "$rules"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
usb_authorization_require_secure_setting() {
|
||||
local config="$1" key="$2" expected="$3" actual
|
||||
|
||||
actual=$(usb_authorization_config_value "$config" "$key")
|
||||
if [[ $actual != "$expected" ]]; then
|
||||
echo "USBGuard's $key must be '$expected' before Omarchy enables it." >&2
|
||||
echo "Current value: ${actual:-<unset>} in $config" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
usb_authorization_require_secure_settings() {
|
||||
local config="$1"
|
||||
|
||||
usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block || return 1
|
||||
usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy || return 1
|
||||
usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy || return 1
|
||||
usb_authorization_require_secure_setting "$config" AuthorizedDefault none || return 1
|
||||
usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false || return 1
|
||||
usb_authorization_require_secure_setting "$config" RuleFile /etc/usbguard/rules.conf || return 1
|
||||
}
|
||||
|
||||
usb_authorization_generate_policy() {
|
||||
local policy="$1" generated line
|
||||
|
||||
if ! generated=$(usbguard generate-policy); then
|
||||
echo "USBGuard could not enumerate devices; nothing was enabled." >&2
|
||||
return 1
|
||||
fi
|
||||
: >"$policy"
|
||||
while IFS= read -r line; do
|
||||
if [[ $line == allow\ * ]]; then
|
||||
usb_authorization_portable_rule "$line" >>"$policy" || return 1
|
||||
elif [[ -n $line ]]; then
|
||||
printf '%s\n' "$line" >>"$policy"
|
||||
fi
|
||||
done <<<"$generated"
|
||||
if ! grep -q '^allow ' "$policy"; then
|
||||
if grep -q '^[[:space:]]*[^#[:space:]]' "$policy"; then
|
||||
echo "USBGuard did not generate a usable policy; nothing was enabled." >&2
|
||||
return 1
|
||||
fi
|
||||
# A successful empty inventory is valid on machines without USB controllers.
|
||||
# Keep the file nonempty so re-enabling preserves this default-deny policy.
|
||||
echo '# No USB devices were present during enrollment.' >>"$policy"
|
||||
fi
|
||||
usbguard-rule-parser -f "$policy" >/dev/null
|
||||
}
|
||||
|
||||
usb_authorization_add_user() {
|
||||
local user="$1"
|
||||
|
||||
# This user can inspect USBGuard events and approve the device named by an
|
||||
# event. Policy and daemon parameters remain unavailable through the IPC ACL.
|
||||
usbguard add-user "$user" \
|
||||
--devices=list,listen,modify \
|
||||
--policy=list \
|
||||
--exceptions=listen
|
||||
}
|
||||
|
||||
usb_authorization_install_policy() {
|
||||
local rules="$1" policy status=0
|
||||
|
||||
policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") || return 1
|
||||
if usb_authorization_generate_policy "$policy"; then
|
||||
install -Dm600 -o root -g root "$policy" "$rules" || status=$?
|
||||
else
|
||||
status=1
|
||||
fi
|
||||
rm -f "$policy"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
usb_authorization_provision_owner() {
|
||||
local user="$1"
|
||||
local rules="${2:-/etc/usbguard/rules.conf}"
|
||||
local config="${3:-/etc/usbguard/usbguard-daemon.conf}"
|
||||
|
||||
usb_authorization_require_secure_settings "$config" || return 1
|
||||
# Enroll the owner's hardware, replacing any policy inherited from the
|
||||
# builder. Do this only after the interactive provisioning steps finish.
|
||||
usb_authorization_install_policy "$rules" || return 1
|
||||
usb_authorization_add_user "$user" || return 1
|
||||
systemctl enable usbguard.service || return 1
|
||||
systemctl restart usbguard.service
|
||||
}
|
||||
|
||||
usb_authorization_trust_present_devices() {
|
||||
local line id devices
|
||||
local count=0
|
||||
devices=$(usbguard list-devices) || return 1
|
||||
local -a inventory
|
||||
mapfile -t inventory <<<"$devices"
|
||||
|
||||
for line in "${inventory[@]}"; do
|
||||
if [[ $line =~ ^([0-9]+):[[:space:]] ]]; then
|
||||
id="${BASH_REMATCH[1]}"
|
||||
sudo /usr/bin/omarchy-usb-authorization-approve "$id" "${line#*: }" || return 1
|
||||
(( ++count ))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( count == 0 )); then
|
||||
echo "USBGuard did not report any connected USB devices; boot authorization was not enabled." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -146,7 +146,6 @@ udiskie
|
||||
ufw
|
||||
ufw-docker
|
||||
unzip
|
||||
usbguard
|
||||
usage
|
||||
uwsm
|
||||
vi
|
||||
|
||||
@@ -25,9 +25,7 @@ for unit in \
|
||||
omarchy-sleep-lock.service \
|
||||
omarchy-migrate-notify.service \
|
||||
omarchy-fcitx5.service \
|
||||
omarchy-crash-watch.service \
|
||||
omarchy-usb-authorization.service \
|
||||
omarchy-thunderbolt-authorization.service; do
|
||||
omarchy-crash-watch.service; do
|
||||
systemctl --user enable --now "$unit" || failed=1
|
||||
done
|
||||
|
||||
|
||||
+1
-44
@@ -6,30 +6,7 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys
|
||||
2. *Firewall is enabled by default*: All incoming traffic is blocked by default except for port 53317 for [LocalSend](https://localsend.org/). Even ssh is off until you turn it on via _Setup > Security > SSHD_, which opens port 22 (rate limited against brute force) as part of the setup. We even lock down Docker access using the [ufw-docker](https://github.com/chaifeng/ufw-docker) setup to prevent that your containers are accidentally exposed to the world.
|
||||
3. *Arch always have the latest updates*: Arch, the underlying distro that Omarchy is built on, is a rolling distribution. This means that any security vulnerability that's discovered and patched in any package is quickly available for install using `omarchy-update`. You're always running the latest, most secure versions of everything that way.
|
||||
4. *Omarchy maintains its own packages and mirror*: Omarchy only relies on packages from Arch's own core/extra/multilib repositories and its own Omarchy Package Repository by default. You can install software directly from AUR, but the base install doesn't — only a few optional installs, like the third-party browsers, pull from the AUR.
|
||||
5. *New USB devices require approval*: Omarchy trusts the USB devices present during installation, then blocks newly connected accessories until you approve them. This keeps an unknown accessory from reaching a matching kernel driver merely because it claims a supported device identity.
|
||||
6. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability.
|
||||
|
||||
## USB Device Authorization
|
||||
|
||||
USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked.
|
||||
|
||||
**Always allow this device** trusts the same device identity across USB ports and hubs, including after reboot. Changes to its identity still require approval. A hub may need its own approval before devices connected through it become available.
|
||||
|
||||
Older approvals can be tied to a port or hub. Run `omarchy-setup-security-usb-authorization --review-existing` to review connected devices and confirm which should be trusted on any port. Existing rules are preserved. For a disconnected device, reconnect it and choose **Always allow this device** if prompted.
|
||||
|
||||
On a preinstalled machine that asks you to create your account at first boot, USB protection starts at the end of that setup. It trusts the devices connected then, so your keyboard works throughout account setup even if it differs from the installer's keyboard.
|
||||
|
||||
You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy.
|
||||
|
||||
Only approve hardware you recognize. A malicious USB device can forge the manufacturer, model, serial number, and device class shown during review. Permanent approval is a convenience for accessories you control, not proof of the device's identity.
|
||||
|
||||
The policy protects devices connected after USBGuard starts during boot. It does not replace operating-system and driver security updates, and USB devices needed before the service starts, such as a keyboard used to unlock an encrypted disk, remain governed by the kernel's boot-time policy.
|
||||
|
||||
For machines whose disk unlock and recovery never depend on any USB devices, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt.
|
||||
|
||||
Older snapshots created before USBGuard was installed and enabled keep all USB disabled even after startup, including keyboards, network adapters, and storage. Trusting the devices connected now does not make them available in those snapshots. Enable boot-time protection only if you can unlock and recover the machine without any USB devices. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup.
|
||||
|
||||
With Secure Boot enabled, changing USB boot protection requires access to your signing keys and enough free space on the system drive for a temporary copy of the boot files. If signing fails, Omarchy restores the previous boot files and settings. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying.
|
||||
5. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability.
|
||||
|
||||
## Changing your passwords
|
||||
|
||||
@@ -56,23 +33,3 @@ Be clear-eyed about this one: while it's on, anything running as your user can d
|
||||
The public key for all ISO signatures and Omarchy repo package is `40DFB630FF42BCFFB047046CF0134EE680CAC571` ([verify at openpgp.org](https://keys.openpgp.org/search?q=pkgs%40omarchy.org)). The `omarchy/omarchy-keyring` package contains this as well and will be used to rollout any potential updates seamlessly.
|
||||
|
||||
You can find the signature for any ISO release by adding .sig to the URL. Like https://iso.omarchy.org/omarchy-x.x.x.iso.sig.
|
||||
|
||||
## Thunderbolt device authorization
|
||||
|
||||
Omarchy requires approval before connecting new Thunderbolt and USB4 PCIe accessories on controllers that support authorization. Devices connected during initial setup are trusted. New devices show a persistent notification with **Allow once**, **Always allow this device**, and **Keep blocked**. Trusted devices reconnect automatically, including while the screen is locked. IOMMU memory protection stays enabled; it does not count as consent to load a device's driver.
|
||||
|
||||
On controllers in secure mode, permanent approval also saves the accessory's authentication key. If a connected accessory has no saved key during initial setup, Omarchy defers Thunderbolt protection and keeps the previous Bolt behavior so your keyboard and storage remain usable. A **Finish Thunderbolt protection setup** notification explains how to continue: keep input available independently of Thunderbolt, safely disconnect the accessories, enable protection, then reconnect and approve each one. Losing a previously saved secure key requires a new explicit approval; Omarchy does not silently create a replacement key while restoring trusted devices.
|
||||
|
||||
_Setup > Security > Thunderbolt_ enables protection. _Remove > Security > Thunderbolt_ restores the previous Bolt behavior, which may automatically approve new devices when IOMMU protection is available. Re-enabling keeps your saved trust list. Omarchy never disconnects active storage when changing these settings.
|
||||
|
||||
When you remove protection, devices enrolled by Omarchy for permanent approval reconnect through Bolt's automatic policy, keeping their secure keys. Devices with a manual Bolt policy from before setup retain that policy. Bolt also resumes managing firmware boot allowlists for automatically approved devices. Keep all Thunderbolt controllers connected during removal so Omarchy can restore their settings if the change fails.
|
||||
|
||||
Firmware can authorize PCIe accessories before Linux runs. A controller configured with security mode `none` bypasses software approval; select user authorization or secure authorization in its firmware settings. Omarchy reports these limitations instead of claiming that the controller is protected. Existing firmware boot allowlists can also authorize devices early. USB accessories attached through a dock require separate USB authorization.
|
||||
|
||||
Approval does not repair a vulnerable driver. Device names and identifiers can be forged; secure connection keys are used where Bolt and the hardware support them.
|
||||
|
||||
_Setup > Security > Thunderbolt Boot_ can clear and verify supported firmware boot allowlists. It captures connected accessories for authorization after Linux starts, prevents Bolt from repopulating those allowlists from automatic device policies, and retains the previous lists for _Remove > Security > Thunderbolt Boot_. This option requires every controller to be connected and to expose a boot allowlist in `user` or `secure` mode. Other hardware needs firmware settings instead; Omarchy cannot verify those settings automatically.
|
||||
|
||||
Enable this only if you can unlock and recover without any Thunderbolt accessories. Even a trusted keyboard or disk may remain unavailable until the policy service starts. Older snapshots and other operating systems can restore their own firmware permissions. A failed change restores the previous settings when possible; an incomplete restoration retains recovery data. Reconnect the original controllers and run `omarchy setup security thunderbolt-boot recover` to retry that recovery.
|
||||
|
||||
If enabling or removing device authorization fails and reports incomplete restoration, run `omarchy setup security thunderbolt-authorization recover` before trying again. This restores the configuration and service state saved before that change.
|
||||
@@ -1,3 +0,0 @@
|
||||
echo "Enable approval for newly connected USB devices"
|
||||
|
||||
omarchy-setup-security-usb-authorization --yes
|
||||
@@ -1,2 +0,0 @@
|
||||
echo "Require approval for new Thunderbolt accessories"
|
||||
omarchy-setup-security-thunderbolt-authorization --quiet
|
||||
@@ -1,7 +0,0 @@
|
||||
echo "Repair USB approval prompts and enable portable device approvals"
|
||||
|
||||
# Preserve an explicit opt-out. Existing users with an active/enabled daemon
|
||||
# need a working watcher even if their old checkout link has become dangling.
|
||||
if systemctl is-enabled --quiet usbguard.service || systemctl is-active --quiet usbguard.service; then
|
||||
omarchy-setup-security-usb-authorization --yes
|
||||
fi
|
||||
@@ -0,0 +1,212 @@
|
||||
echo "Remove approval for new USB and Thunderbolt devices"
|
||||
|
||||
# Approval for new accessories (#11874) shipped briefly to edge and dev. The
|
||||
# package update has already removed its commands, units and Polkit rules; this
|
||||
# undoes what they left on the machine so devices connect as they did before.
|
||||
|
||||
usb_state=$HOME/.local/state/omarchy/usb-authorization
|
||||
tb_dir=/var/lib/omarchy/thunderbolt-authorization
|
||||
usb_boot_drop_in=/etc/limine-entry-tool.d/usb-authorization.conf
|
||||
usb_boot_pending=/var/lib/omarchy/usb-authorization-boot.removing
|
||||
usb_boot_parameter=usbcore.authorized_default=0
|
||||
usbguard_shipped=1791573373 # when usbguard joined the default packages
|
||||
# The access every Omarchy path granted with `usbguard add-user`.
|
||||
usbguard_grant=$'Devices=list,modify,listen\nPolicy=list\nExceptions=listen'
|
||||
|
||||
# The approval watchers. Their unit files are gone, so systemctl disable can't
|
||||
# find them; drop the enablement links directly.
|
||||
for unit in omarchy-usb-authorization.service omarchy-thunderbolt-authorization.service; do
|
||||
systemctl --user stop "$unit" 2>/dev/null || true
|
||||
rm -f "$HOME"/.config/systemd/user/*.wants/"$unit"
|
||||
done
|
||||
rm -f "$HOME/.config/systemd/user/omarchy-usb-authorization.service"
|
||||
systemctl --user daemon-reload
|
||||
|
||||
bolt_list() {
|
||||
sudo busctl --system --json=short call org.freedesktop.bolt /org/freedesktop/bolt org.freedesktop.bolt1.Manager "$1" | jq -r '.data[0][]'
|
||||
}
|
||||
|
||||
# A device or controller missing from Bolt's inventory has nothing to restore.
|
||||
# Any other failed read stops the migration, so it is retried.
|
||||
bolt_has() {
|
||||
grep -qxF "/org/freedesktop/bolt/$1/${2//-/_}" <<<"$bolt_known"
|
||||
}
|
||||
|
||||
bolt_get() {
|
||||
sudo busctl --system --json=short get-property org.freedesktop.bolt "/org/freedesktop/bolt/$1/${2//-/_}" "org.freedesktop.bolt1.$3" "$4" | jq -c .data
|
||||
}
|
||||
|
||||
if [[ -d $tb_dir || -e /etc/omarchy/thunderbolt-authorization.enabled || -e /etc/omarchy/thunderbolt-authorization.pending ]]; then
|
||||
sudo systemctl stop omarchy-thunderbolt-authorization.service 2>/dev/null || true
|
||||
sudo rm -f /etc/systemd/system/multi-user.target.wants/omarchy-thunderbolt-authorization.service \
|
||||
/etc/omarchy/thunderbolt-authorization.enabled /etc/omarchy/thunderbolt-authorization.pending
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
state=$(sudo cat "$tb_dir/policy.json" 2>/dev/null || echo '{}')
|
||||
recovery=$(sudo cat "$tb_dir/boot-recovery.json" 2>/dev/null || echo '{}')
|
||||
|
||||
# Thunderbolt Boot replaced device policies and emptied the firmware boot
|
||||
# allowlists. The originals were saved when it was turned on, or only in the
|
||||
# recovery journal if turning it on was interrupted.
|
||||
firmware=$(jq -c --argjson recovery "$recovery" \
|
||||
'.boot_original // (if $recovery.policies then $recovery | {policies, acls} else {} end)' <<<"$state")
|
||||
|
||||
bolt_devices=$(bolt_list ListDevices)
|
||||
bolt_domains=$(bolt_list ListDomains)
|
||||
bolt_known=$bolt_devices$'\n'$bolt_domains
|
||||
|
||||
while read -r uid policy; do
|
||||
if bolt_has devices "$uid"; then
|
||||
current=$(bolt_get devices "$uid" Device Policy)
|
||||
if [[ $current != "\"$policy\"" ]]; then
|
||||
sudo boltctl config device.policy "$uid" "$policy"
|
||||
fi
|
||||
fi
|
||||
done < <(jq -r '.policies // {} | to_entries[] | "\(.key) \(.value)"' <<<"$firmware")
|
||||
|
||||
# Bolt rejects a BootACL write that changes nothing, so compare first.
|
||||
unrestored=0
|
||||
while read -r uid; do
|
||||
acl=$(jq -c --arg uid "$uid" '.acls[$uid]' <<<"$firmware")
|
||||
if ! bolt_has domains "$uid"; then
|
||||
echo "Thunderbolt controller $uid is not present; its firmware boot allowlist was not restored."
|
||||
unrestored=1
|
||||
continue
|
||||
fi
|
||||
current=$(bolt_get domains "$uid" Domain BootACL)
|
||||
if [[ $current != "$acl" ]]; then
|
||||
mapfile -t entries < <(jq -r '.[]' <<<"$acl")
|
||||
sudo busctl --system set-property org.freedesktop.bolt "/org/freedesktop/bolt/domains/${uid//-/_}" \
|
||||
org.freedesktop.bolt1.Domain BootACL as "${#entries[@]}" "${entries[@]}"
|
||||
fi
|
||||
done < <(jq -r '.acls // {} | keys[]' <<<"$firmware")
|
||||
|
||||
# Accessories approved here were stored with Bolt's manual policy, which
|
||||
# Bolt won't authorize on its own. Hand back the ones Omarchy stored, as the
|
||||
# removal command did, never a manual policy somebody else set.
|
||||
while read -r uid store_time; do
|
||||
if bolt_has devices "$uid"; then
|
||||
current=$(bolt_get devices "$uid" Device Policy)
|
||||
stored=$(bolt_get devices "$uid" Device StoreTime)
|
||||
if [[ $current == '"manual"' && $stored == "$store_time" ]]; then
|
||||
sudo boltctl config device.policy "$uid" auto
|
||||
fi
|
||||
fi
|
||||
done < <(jq -r '(.trusted // {}) as $trusted | .enrolled // {} | to_entries[] | select($trusted[.key]) | "\(.key) \(.value.StoreTime)"' <<<"$state")
|
||||
|
||||
# A first setup that failed and then failed to undo itself can lose
|
||||
# policy.json after switching Bolt off; Bolt's original config is then only
|
||||
# in the setup journal. A deferred install that never finished setup left
|
||||
# Bolt's mode untouched and has neither.
|
||||
setup=$(sudo cat "$tb_dir/setup-recovery.json" 2>/dev/null || echo '{}')
|
||||
config=$(jq -c '[.files // {} | to_entries[] | select(.key | endswith("/boltd.conf")) | .value]' <<<"$setup")
|
||||
mode=$(jq -r '.original_authmode // empty' <<<"$state")
|
||||
if [[ -z $mode && $config != "[]" ]]; then
|
||||
mode=$(jq -r '.[0].data // ""' <<<"$config" | base64 -d | awk '
|
||||
/^[[:space:]]*\[/ { section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) }
|
||||
section && /^[[:space:]]*AuthMode[[:space:]]*=/ { sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); mode = $0 }
|
||||
END { print mode == "" ? "enabled" : mode }
|
||||
')
|
||||
fi
|
||||
if [[ -n $mode ]]; then
|
||||
sudo boltctl config global.auth-mode "$mode"
|
||||
fi
|
||||
|
||||
if (( unrestored )) && [[ -d $tb_dir ]]; then
|
||||
sudo mv -T "$tb_dir" "$tb_dir.unrestored"
|
||||
echo "The saved Thunderbolt settings are kept in $tb_dir.unrestored."
|
||||
else
|
||||
sudo rm -rf "$tb_dir"
|
||||
fi
|
||||
sudo rm -rf /run/omarchy-thunderbolt-authorization
|
||||
fi
|
||||
|
||||
# limine-mkinitcpio reports success even when a kernel image fails to build, so
|
||||
# check every current kernel entry. Limine's command line wins, except under
|
||||
# Secure Boot, where the signed image's embedded one does.
|
||||
usb_boot_cleared() {
|
||||
local entries key value esp secure_boot=0 found=0
|
||||
local efivar=/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c
|
||||
|
||||
if [[ -r $efivar && $(od -An -t u1 -j 4 -N 1 "$efivar") == *1 ]]; then
|
||||
secure_boot=1
|
||||
fi
|
||||
esp=$(sed -n 's/^ESP_PATH=["'\'']\?\([^"'\'']*\).*/\1/p' /etc/default/limine | tail -1)
|
||||
esp=${esp:-/boot}
|
||||
entries=$(sudo awk '
|
||||
/^ \/\/[^\/]/ { entry = 1; next }
|
||||
/^ *\// { entry = 0 }
|
||||
entry && /^ (path|cmdline):/ { key = $1; sub(/^ [a-z]+:[[:space:]]*/, ""); print key "\t" $0 }
|
||||
' "$esp/limine.conf") || return 1
|
||||
|
||||
while IFS=$'\t' read -r key value; do
|
||||
if [[ $key == "cmdline:" ]]; then
|
||||
[[ " $value " != *" $usb_boot_parameter "* ]] || return 1
|
||||
elif [[ $key == "path:" ]]; then
|
||||
found=1
|
||||
if (( secure_boot )); then
|
||||
value=${value#'boot():'}
|
||||
value=$esp${value%%#*}
|
||||
sudo test -r "$value" || return 1
|
||||
if sudo grep -qaF "$usb_boot_parameter" "$value"; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done <<<"$entries"
|
||||
(( found ))
|
||||
}
|
||||
|
||||
# The opt-in USB at Boot setting: the parameter on the kernel command line, for
|
||||
# new boot entries and new snapshots. Keep a marker until the rebuilt entries
|
||||
# are verified, so an interrupted rebuild is retried rather than skipped.
|
||||
if [[ -e $usb_boot_drop_in || -e $usb_boot_pending ]] || grep -Fqx '# Omarchy USB authorization begin' /etc/default/limine 2>/dev/null; then
|
||||
sudo touch "$usb_boot_pending"
|
||||
sudo rm -f "$usb_boot_drop_in"
|
||||
sudo sed -i '/^# Omarchy USB authorization begin$/,/^# Omarchy USB authorization end$/d' /etc/default/limine
|
||||
sudo limine-mkinitcpio
|
||||
if ! usb_boot_cleared; then
|
||||
echo "A boot entry still has $usb_boot_parameter, so USBGuard stays until it is rebuilt. Run omarchy-migrate to retry."
|
||||
exit 1
|
||||
fi
|
||||
sudo rm -f "$usb_boot_pending"
|
||||
fi
|
||||
|
||||
# Leave a USBGuard the user set up before Omarchy shipped it. pacman resets the
|
||||
# install date on every upgrade, so ask its log when usbguard first arrived.
|
||||
usbguard_from_omarchy() {
|
||||
local first
|
||||
|
||||
if [[ -f $usb_state/policy-generated-by-omarchy ]]; then
|
||||
return 0
|
||||
fi
|
||||
first=$(grep -m1 -oP '^\[\K[^]]+(?=\] \[ALPM\] installed usbguard )' /var/log/pacman.log) || return 1
|
||||
(( $(date -d "$first" +%s) >= usbguard_shipped )) || return 1
|
||||
[[ $(sudo cat "/etc/usbguard/IPCAccessControl.d/$USER" 2>/dev/null) == "$usbguard_grant" ]]
|
||||
}
|
||||
|
||||
if omarchy-pkg-present usbguard && usbguard_from_omarchy; then
|
||||
sudo systemctl disable --now usbguard.service
|
||||
# USBGuard leaves the root hubs denying new devices when it exits, until
|
||||
# the next boot. Restore the kernel default and authorize what it blocked.
|
||||
shopt -s nullglob
|
||||
echo 1 | sudo tee /sys/bus/usb/devices/usb*/authorized_default /sys/bus/usb/devices/*/authorized >/dev/null
|
||||
shopt -u nullglob
|
||||
omarchy-pkg-drop usbguard
|
||||
|
||||
# The approval access granted to each user outlives the package.
|
||||
while IFS= read -r -d '' grant; do
|
||||
if [[ $(sudo cat "$grant") == "$usbguard_grant" ]]; then
|
||||
sudo rm -f "$grant"
|
||||
fi
|
||||
done < <(sudo find /etc/usbguard/IPCAccessControl.d -maxdepth 1 -type f -print0 2>/dev/null)
|
||||
sudo rmdir /etc/usbguard/IPCAccessControl.d /etc/usbguard 2>/dev/null || true
|
||||
fi
|
||||
|
||||
for action in /usr/share/polkit-1/actions/org.omarchy.usb.policy /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy; do
|
||||
if [[ -e $action ]] && ! pacman -Qo "$action" &>/dev/null; then
|
||||
sudo rm -f "$action"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$usb_state"
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
shipped_migration="$ROOT/migrations/1791769486.sh"
|
||||
[[ -f $shipped_migration ]] || fail "the device authorization removal migration exists"
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
root="$test_dir/root"
|
||||
home="$test_dir/home"
|
||||
migration="$test_dir/migration.sh"
|
||||
export CALLS="$test_dir/calls" FAKE_ROOT="$root"
|
||||
|
||||
# Run a scratch copy whose machine paths all live under the fake root.
|
||||
sed -e "s#/etc/#$root/etc/#g" -e "s#/var/#$root/var/#g" -e "s#/run/#$root/run/#g" \
|
||||
-e "s#/sys/#$root/sys/#g" -e "s#:-/boot}#:-$root/boot}#" -e "s#/usr/share/polkit-1/#$root/usr/share/polkit-1/#g" \
|
||||
"$shipped_migration" >"$migration"
|
||||
|
||||
mkdir -p "$test_dir/bin"
|
||||
|
||||
cat >"$test_dir/bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'sudo %s\n' "$*" >>"$CALLS"
|
||||
exec "$@"
|
||||
STUB
|
||||
|
||||
cat >"$test_dir/bin/systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'systemctl %s\n' "$*" >>"$CALLS"
|
||||
STUB
|
||||
|
||||
# Bolt: devices/<uid>/<Property> and domains/<uid>/BootACL hold JSON values.
|
||||
cat >"$test_dir/bin/busctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
args=()
|
||||
for arg in "$@"; do [[ $arg == --* ]] || args+=("$arg"); done
|
||||
object=${args[2]#/org/freedesktop/bolt/}
|
||||
uid=${object#*/}
|
||||
file="$FAKE_ROOT/bolt/${object%%/*}/${uid//_/-}/${args[4]}"
|
||||
case ${args[0]} in
|
||||
call)
|
||||
kind=devices
|
||||
[[ ${args[4]} == "ListDevices" ]] || kind=domains
|
||||
for dir in "$FAKE_ROOT/bolt/$kind"/*/; do
|
||||
dir=${dir%/}
|
||||
uid=${dir##*/}
|
||||
printf '/org/freedesktop/bolt/%s/%s\n' "$kind" "${uid//-/_}"
|
||||
done | jq -Rnc '{type:"ao",data:[[inputs]]}'
|
||||
;;
|
||||
get-property)
|
||||
[[ -f $file && -z ${BOLT_READ_FAILS:-} ]] || exit 1
|
||||
printf '{"type":"x","data":%s}\n' "$(<"$file")"
|
||||
;;
|
||||
set-property)
|
||||
[[ -f $file ]] || exit 1
|
||||
value=$(printf '%s\n' "${args[@]:7}" | jq -Rnc '[inputs]')
|
||||
(( args[6] > 0 )) || value='[]'
|
||||
# Like Bolt, refuse a BootACL write that changes nothing.
|
||||
[[ $value != "$(jq -c . "$file")" ]] || exit 1
|
||||
printf '%s\n' "$value" >"$file"
|
||||
;;
|
||||
esac
|
||||
STUB
|
||||
|
||||
cat >"$test_dir/bin/boltctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'boltctl %s\n' "$*" >>"$CALLS"
|
||||
case $2 in
|
||||
device.policy) printf '"%s"\n' "$4" >"$FAKE_ROOT/bolt/devices/$3/Policy" ;;
|
||||
global.auth-mode) printf '%s\n' "$3" >"$FAKE_ROOT/bolt/authmode" ;;
|
||||
esac
|
||||
STUB
|
||||
|
||||
# Rebuilds the current entries from the drop-in. Old snapshot entries keep the
|
||||
# parameter. MKINITCPIO=fail fails; MKINITCPIO=stale "succeeds" without
|
||||
# rebuilding, like a kernel image that failed inside the Limine hook.
|
||||
cat >"$test_dir/bin/limine-mkinitcpio" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'limine-mkinitcpio\n' >>"$CALLS"
|
||||
[[ ${MKINITCPIO:-} != "fail" ]] || exit 1
|
||||
[[ ${MKINITCPIO:-} != "stale" ]] || exit 0
|
||||
parameter=""
|
||||
[[ ! -e $FAKE_ROOT/etc/limine-entry-tool.d/usb-authorization.conf ]] || parameter=" usbcore.authorized_default=0"
|
||||
esp=$(sed -n 's/^ESP_PATH="\(.*\)"$/\1/p' "$FAKE_ROOT/etc/default/limine")
|
||||
esp=${esp:-$FAKE_ROOT/boot}
|
||||
mkdir -p "$esp/EFI/Linux"
|
||||
printf 'MZ...cmdline root=x%s...initrd' "$parameter" >"$esp/EFI/Linux/omarchy_linux.efi"
|
||||
cat >"$esp/limine.conf" <<EOF
|
||||
/+Omarchy
|
||||
comment: machine-id=abc
|
||||
//linux
|
||||
comment: kernel-id=linux
|
||||
path: boot():/EFI/Linux/omarchy_linux.efi#0123
|
||||
cmdline: root=x quiet$parameter
|
||||
//Snapshots
|
||||
///1
|
||||
////linux
|
||||
path: boot():/abc/limine_history/omarchy_linux.efi#4567
|
||||
cmdline: root=x usbcore.authorized_default=0
|
||||
EOF
|
||||
STUB
|
||||
|
||||
cat >"$test_dir/bin/omarchy-pkg-present" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ -e $FAKE_ROOT/packages/$1 ]]
|
||||
STUB
|
||||
|
||||
cat >"$test_dir/bin/omarchy-pkg-drop" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'omarchy-pkg-drop %s\n' "$*" >>"$CALLS"
|
||||
rm -f "$FAKE_ROOT/packages/$1" "$FAKE_ROOT/etc/usbguard/rules.conf"
|
||||
STUB
|
||||
|
||||
cat >"$test_dir/bin/pacman" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
STUB
|
||||
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
grant=$'Devices=list,modify,listen\nPolicy=list\nExceptions=listen'
|
||||
dock=b8f58780-002a-50c4-ffff-ffffffffffff
|
||||
other=aaaaaaaa-0000-0000-0000-000000000001
|
||||
domain=895a5509-000a-3075-ffff-ffffffffffff
|
||||
gone_domain=895a5509-010a-3075-ffff-ffffffffffff
|
||||
|
||||
# A machine that never had the feature, with Bolt in its default mode.
|
||||
reset_machine() {
|
||||
rm -rf "$root" "$home"
|
||||
mkdir -p "$root"/{etc/default,var/log,var/lib/omarchy,run,packages,usr/share/polkit-1/actions} \
|
||||
"$root/sys/bus/usb/devices"/{usb1,usb2,1-1} "$root/bolt/devices"/{$dock,$other} "$root/bolt/domains/$domain" \
|
||||
"$home/.config/systemd/user/graphical-session.target.wants" "$home/.local/state/omarchy"
|
||||
echo 'KERNEL_CMDLINE[default]+="quiet"' >"$root/etc/default/limine"
|
||||
echo enabled >"$root/bolt/authmode"
|
||||
echo '"auto"' >"$root/bolt/devices/$dock/Policy"
|
||||
echo 1791668776 >"$root/bolt/devices/$dock/StoreTime"
|
||||
echo '"manual"' >"$root/bolt/devices/$other/Policy"
|
||||
echo 1700000000 >"$root/bolt/devices/$other/StoreTime"
|
||||
echo '["",""]' >"$root/bolt/domains/$domain/BootACL"
|
||||
echo 1 | tee "$root"/sys/bus/usb/devices/{usb1,usb2}/authorized_default "$root"/sys/bus/usb/devices/{usb1,usb2,1-1}/authorized >/dev/null
|
||||
echo '[2026-01-02T03:04:05-0500] [ALPM] installed jq (1.8)' >"$root/var/log/pacman.log"
|
||||
MKINITCPIO=ok "$test_dir/bin/limine-mkinitcpio"
|
||||
}
|
||||
|
||||
# What #11874's default setup left behind on the author's machine.
|
||||
enable_feature() {
|
||||
touch "$root/packages/usbguard"
|
||||
echo '[2026-10-10T17:44:55-0400] [ALPM] installed usbguard (1.1.4-11)' >>"$root/var/log/pacman.log"
|
||||
mkdir -p "$root/etc/usbguard/IPCAccessControl.d" "$root/etc/omarchy" "$root/var/lib/omarchy/thunderbolt-authorization" \
|
||||
"$root/etc/systemd/system/multi-user.target.wants" "$home/.local/state/omarchy/usb-authorization/requests"
|
||||
echo 'allow id 1d6b:0002' >"$root/etc/usbguard/rules.conf"
|
||||
printf '%s\n' "$grant" >"$root/etc/usbguard/IPCAccessControl.d/tester"
|
||||
echo 0 | tee "$root"/sys/bus/usb/devices/{usb1,usb2}/authorized_default >/dev/null
|
||||
echo 0 >"$root/sys/bus/usb/devices/1-1/authorized"
|
||||
touch "$home/.local/state/omarchy/usb-authorization/policy-generated-by-omarchy"
|
||||
touch "$root/usr/share/polkit-1/actions"/org.omarchy.{usb,thunderbolt}.policy
|
||||
touch "$home/.config/systemd/user/omarchy-usb-authorization.service"
|
||||
ln -s ../omarchy-usb-authorization.service "$home/.config/systemd/user/graphical-session.target.wants/omarchy-usb-authorization.service"
|
||||
ln -s /usr/lib/systemd/user/omarchy-thunderbolt-authorization.service "$home/.config/systemd/user/graphical-session.target.wants/omarchy-thunderbolt-authorization.service"
|
||||
ln -s /etc/systemd/system/omarchy-thunderbolt-authorization.service "$root/etc/systemd/system/multi-user.target.wants/omarchy-thunderbolt-authorization.service"
|
||||
touch "$root/etc/omarchy/thunderbolt-authorization.enabled"
|
||||
echo disabled >"$root/bolt/authmode"
|
||||
echo '"manual"' >"$root/bolt/devices/$dock/Policy"
|
||||
cat >"$root/var/lib/omarchy/thunderbolt-authorization/policy.json" <<EOF
|
||||
{"enabled":true,"original_authmode":"enabled","version":1,
|
||||
"enrolled":{"$dock":{"Name":"TS4","StoreTime":1791668776,"Uid":"$dock","Vendor":"CalDigit, Inc."}},
|
||||
"trusted":{"$dock":{"Name":"TS4","Uid":"$dock","Vendor":"CalDigit, Inc."}}}
|
||||
EOF
|
||||
}
|
||||
|
||||
enable_usb_at_boot() {
|
||||
mkdir -p "$root/etc/limine-entry-tool.d"
|
||||
echo 'KERNEL_CMDLINE[default]+=" usbcore.authorized_default=0"' >"$root/etc/limine-entry-tool.d/usb-authorization.conf"
|
||||
printf '%s\n' '# Omarchy USB authorization begin' 'SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' \
|
||||
'# Omarchy USB authorization end' >>"$root/etc/default/limine"
|
||||
MKINITCPIO=ok "$test_dir/bin/limine-mkinitcpio"
|
||||
}
|
||||
|
||||
run_migration() {
|
||||
: >"$CALLS"
|
||||
HOME="$home" USER=tester PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >"$test_dir/output" 2>&1
|
||||
}
|
||||
|
||||
value() { tr -d '\n' <"$root/bolt/$1"; }
|
||||
|
||||
reset_machine
|
||||
run_migration || fail "migration fails on a machine that never had the feature" "$(cat "$test_dir/output")"
|
||||
! grep -q '^sudo' "$CALLS" || fail "migration asks for sudo on a machine that never had the feature" "$(cat "$CALLS")"
|
||||
pass "a machine that never had the feature is left alone without sudo"
|
||||
|
||||
reset_machine
|
||||
touch "$root/packages/usbguard"
|
||||
printf '%s\n' '[2025-03-01T10:00:00-0500] [ALPM] installed usbguard (1.1.3-1)' \
|
||||
'[2026-10-10T17:44:55-0400] [ALPM] upgraded usbguard (1.1.3-1 -> 1.1.4-11)' >>"$root/var/log/pacman.log"
|
||||
run_migration || fail "migration fails beside the user's own USBGuard" "$(cat "$test_dir/output")"
|
||||
[[ -e $root/packages/usbguard ]] || fail "migration removes a USBGuard installed before Omarchy shipped it"
|
||||
! grep -q '^sudo' "$CALLS" || fail "migration asks for sudo for a USBGuard it does not own" "$(cat "$CALLS")"
|
||||
pass "a USBGuard installed before Omarchy shipped it is kept, even after an upgrade"
|
||||
|
||||
reset_machine
|
||||
touch "$root/packages/usbguard"
|
||||
echo '[2026-10-10T09:00:00-0400] [ALPM] installed usbguard (1.1.4-11)' >>"$root/var/log/pacman.log"
|
||||
run_migration || fail "migration fails beside a USBGuard without Omarchy's grant" "$(cat "$test_dir/output")"
|
||||
[[ -e $root/packages/usbguard ]] || fail "migration removes a USBGuard without Omarchy's ownership evidence"
|
||||
pass "a USBGuard without Omarchy's marker or grant is kept"
|
||||
|
||||
reset_machine
|
||||
enable_feature
|
||||
run_migration || fail "migration fails on a machine with the feature" "$(cat "$test_dir/output")"
|
||||
[[ ! -e $root/packages/usbguard ]] || fail "migration keeps the USBGuard Omarchy installed"
|
||||
[[ $(cat "$root"/sys/bus/usb/devices/*/authorized_default "$root"/sys/bus/usb/devices/*/authorized | sort -u) == "1" ]] ||
|
||||
fail "migration leaves USB devices denied after USBGuard exits"
|
||||
[[ ! -e $root/etc/usbguard ]] || fail "migration leaves Omarchy's USBGuard grants behind" "$(find "$root/etc/usbguard")"
|
||||
[[ $(value authmode) == "enabled" && $(value devices/$dock/Policy) == '"auto"' ]] ||
|
||||
fail "migration does not hand Thunderbolt back to Bolt" "$(value authmode) $(value devices/$dock/Policy)"
|
||||
[[ $(value devices/$other/Policy) == '"manual"' ]] || fail "migration changes a manual policy Omarchy did not set"
|
||||
for path in "$root/var/lib/omarchy/thunderbolt-authorization" "$root/etc/omarchy/thunderbolt-authorization.enabled" \
|
||||
"$root/etc/systemd/system/multi-user.target.wants/omarchy-thunderbolt-authorization.service" \
|
||||
"$root/usr/share/polkit-1/actions/org.omarchy.usb.policy" "$root/usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy" \
|
||||
"$home/.local/state/omarchy/usb-authorization" "$home/.config/systemd/user/omarchy-usb-authorization.service" \
|
||||
"$home/.config/systemd/user/graphical-session.target.wants/omarchy-usb-authorization.service" \
|
||||
"$home/.config/systemd/user/graphical-session.target.wants/omarchy-thunderbolt-authorization.service"; do
|
||||
[[ ! -e $path && ! -L $path ]] || fail "migration leaves $path behind"
|
||||
done
|
||||
pass "the default feature is removed and devices connect as before"
|
||||
|
||||
run_migration || fail "a second run fails" "$(cat "$test_dir/output")"
|
||||
! grep -q '^sudo' "$CALLS" || fail "a second run asks for sudo" "$(cat "$CALLS")"
|
||||
pass "a second run is a no-op"
|
||||
|
||||
# A deferred install that never finished setup left only the pending marker,
|
||||
# without changing Bolt's mode; an administrator's own setting stays.
|
||||
reset_machine
|
||||
mkdir -p "$root/etc/omarchy"
|
||||
touch "$root/etc/omarchy/thunderbolt-authorization.pending"
|
||||
echo disabled >"$root/bolt/authmode"
|
||||
run_migration || fail "migration fails with only the pending marker" "$(cat "$test_dir/output")"
|
||||
[[ $(value authmode) == "disabled" ]] || fail "migration enables Bolt authorization it never changed"
|
||||
[[ ! -e $root/etc/omarchy/thunderbolt-authorization.pending ]] || fail "migration leaves the pending marker"
|
||||
pass "Bolt's mode is untouched when Omarchy never saved one"
|
||||
|
||||
# A first setup failed and its undo deleted policy.json before restoring
|
||||
# boltd.conf: Bolt's original config survives only in the setup journal.
|
||||
for original in "$(printf '[config]\nAuthMode = enabled\n' | base64 -w0)" ""; do
|
||||
reset_machine
|
||||
mkdir -p "$root/etc/omarchy" "$root/var/lib/omarchy/thunderbolt-authorization"
|
||||
touch "$root/etc/omarchy/thunderbolt-authorization.enabled"
|
||||
echo disabled >"$root/bolt/authmode"
|
||||
if [[ -n $original ]]; then
|
||||
saved="{\"data\":\"$original\",\"mode\":\"600\"}"
|
||||
else
|
||||
saved=null
|
||||
fi
|
||||
echo "{\"files\":{\"/var/lib/boltd/boltd.conf\":$saved},\"units\":{}}" \
|
||||
>"$root/var/lib/omarchy/thunderbolt-authorization/setup-recovery.json"
|
||||
run_migration || fail "migration fails with only the setup journal" "$(cat "$test_dir/output")"
|
||||
[[ $(value authmode) == "enabled" ]] || fail "migration leaves Bolt off when its original config is only in the setup journal"
|
||||
done
|
||||
pass "Bolt's original mode is recovered from the setup journal"
|
||||
|
||||
# Turning on Thunderbolt Boot was interrupted: the originals are only in the
|
||||
# recovery journal. One controller's list already matches; another is gone.
|
||||
reset_machine
|
||||
enable_feature
|
||||
echo '"manual"' >"$root/bolt/devices/$other/Policy"
|
||||
echo '["",""]' >"$root/bolt/domains/$domain/BootACL"
|
||||
cat >"$root/var/lib/omarchy/thunderbolt-authorization/boot-recovery.json" <<EOF
|
||||
{"policies":{"$other":"iommu","$dock":"manual"},"acls":{"$domain":["$dock",""]},"state":{}}
|
||||
EOF
|
||||
run_migration || fail "migration fails while recovering Thunderbolt Boot" "$(cat "$test_dir/output")"
|
||||
[[ $(value devices/$other/Policy) == '"iommu"' ]] || fail "migration ignores the Thunderbolt Boot recovery journal"
|
||||
[[ $(value domains/$domain/BootACL) == "[\"$dock\",\"\"]" ]] || fail "migration does not restore the firmware boot allowlist"
|
||||
[[ $(value devices/$dock/Policy) == '"auto"' ]] || fail "migration does not hand the enrolled dock back after recovery"
|
||||
[[ ! -e $root/var/lib/omarchy/thunderbolt-authorization ]] || fail "migration keeps fully restored Thunderbolt state"
|
||||
pass "an interrupted Thunderbolt Boot is recovered from its journal"
|
||||
|
||||
reset_machine
|
||||
enable_feature
|
||||
jq --arg d "$domain" --arg g "$gone_domain" --arg dock "$dock" \
|
||||
'.boot_protection=true | .boot_original={policies:{($dock):"manual"},acls:{($d):["",""],($g):[""]}}' \
|
||||
"$root/var/lib/omarchy/thunderbolt-authorization/policy.json" >"$test_dir/policy.json"
|
||||
mv "$test_dir/policy.json" "$root/var/lib/omarchy/thunderbolt-authorization/policy.json"
|
||||
run_migration || fail "migration fails when a list already matches or a controller is gone" "$(cat "$test_dir/output")"
|
||||
[[ -f $root/var/lib/omarchy/thunderbolt-authorization.unrestored/policy.json ]] ||
|
||||
fail "migration discards settings it could not restore"
|
||||
grep -q "$gone_domain is not present" "$test_dir/output" || fail "migration does not say which controller was skipped"
|
||||
[[ $(value devices/$dock/Policy) == '"auto"' ]] || fail "migration stops before handing back the dock"
|
||||
pass "unrestorable firmware settings are kept aside, and matching lists are not rewritten"
|
||||
|
||||
# A Bolt read that fails, unlike a record Bolt no longer has, must leave the
|
||||
# saved settings in place and the migration pending.
|
||||
reset_machine
|
||||
enable_feature
|
||||
BOLT_READ_FAILS=1 run_migration && fail "migration succeeds although Bolt could not be read"
|
||||
[[ -f $root/var/lib/omarchy/thunderbolt-authorization/policy.json ]] || fail "a failed Bolt read discards the saved settings"
|
||||
run_migration || fail "migration does not finish once Bolt can be read" "$(cat "$test_dir/output")"
|
||||
[[ $(value devices/$dock/Policy) == '"auto"' ]] || fail "the retry does not hand back the dock"
|
||||
pass "a failed Bolt read keeps the saved settings and retries"
|
||||
|
||||
# USB at Boot: a failed rebuild, then one that silently leaves the parameter,
|
||||
# must both keep USBGuard and retry, even though the settings are already gone.
|
||||
reset_machine
|
||||
enable_feature
|
||||
enable_usb_at_boot
|
||||
MKINITCPIO=fail run_migration && fail "migration succeeds although the boot rebuild failed"
|
||||
[[ -e $root/packages/usbguard && -e $root/var/lib/omarchy/usb-authorization-boot.removing ]] ||
|
||||
fail "a failed rebuild removes USBGuard or forgets to retry"
|
||||
MKINITCPIO=stale run_migration && fail "migration trusts a rebuild that left the parameter" "$(cat "$test_dir/output")"
|
||||
[[ -e $root/packages/usbguard ]] || fail "migration removes USBGuard while a boot entry still denies USB"
|
||||
grep -q 'limine-mkinitcpio' "$CALLS" || fail "a retry skips the rebuild once the settings are gone"
|
||||
MKINITCPIO=ok run_migration || fail "migration fails once the rebuild succeeds" "$(cat "$test_dir/output")"
|
||||
[[ ! -e $root/packages/usbguard && ! -e $root/var/lib/omarchy/usb-authorization-boot.removing ]] ||
|
||||
fail "migration does not finish after a successful rebuild"
|
||||
grep -q 'usbcore' "$root/etc/default/limine" && fail "migration leaves the snapshot parameter in /etc/default/limine"
|
||||
pass "USB at Boot is removed only once the rebuilt entries are verified"
|
||||
|
||||
# A boot partition mounted somewhere other than /boot, named by ESP_PATH.
|
||||
reset_machine
|
||||
rm -rf "$root/boot"
|
||||
echo "ESP_PATH=\"$root/efi\"" >>"$root/etc/default/limine"
|
||||
enable_feature
|
||||
enable_usb_at_boot
|
||||
MKINITCPIO=stale run_migration && fail "migration trusts a stale rebuild on a boot partition outside /boot"
|
||||
MKINITCPIO=ok run_migration || fail "migration does not check the boot partition named by ESP_PATH" "$(cat "$test_dir/output")"
|
||||
[[ ! -e $root/packages/usbguard ]] || fail "migration keeps USBGuard after a verified rebuild outside /boot"
|
||||
pass "boot entries are verified on the boot partition named by ESP_PATH"
|
||||
|
||||
# Under Secure Boot the signed image's own command line is what boots.
|
||||
reset_machine
|
||||
enable_feature
|
||||
enable_usb_at_boot
|
||||
mkdir -p "$root/sys/firmware/efi/efivars"
|
||||
printf '\x06\x00\x00\x00\x01' >"$root/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"
|
||||
sed -i 's/ usbcore.authorized_default=0$//' "$root/boot/limine.conf"
|
||||
MKINITCPIO=stale run_migration && fail "migration trusts Limine's command line over the signed image's"
|
||||
MKINITCPIO=ok run_migration || fail "migration fails under Secure Boot after a clean rebuild" "$(cat "$test_dir/output")"
|
||||
pass "Secure Boot images are checked for the parameter too"
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Runs against the private real boltd created by bolt-integration.py. No policy,
|
||||
# busctl, firmware reads, or writes are mocked here.
|
||||
set -euo pipefail
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
TB_STATE="$TB_TEST_DIR/policy.json"
|
||||
TB_RUNTIME="$TB_TEST_DIR/runtime"
|
||||
mkdir -p "$TB_RUNTIME"
|
||||
check() { jq -e "$1" "$2" >/dev/null; }
|
||||
reject() { if "$@"; then echo 'Unexpected approval success' >&2; exit 1; fi; }
|
||||
case "$1" in
|
||||
approve)
|
||||
echo '{"version":1,"enabled":true,"trusted":{}}' | tb_json_write "$TB_STATE"
|
||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
||||
tb_snapshot > "$TB_TEST_DIR/before.json"
|
||||
capture=$(tb_capture)
|
||||
tb_inventory | jq -e --arg uid "$TB_TEST_UID" --argjson capture "$capture" \
|
||||
'.devices[] | select(.Uid==$uid) | $capture[$uid]=={Uid,Name,Vendor}' >/dev/null
|
||||
check 'all(.devices[]; .status=="connected" and (.stored|not))' "$TB_TEST_DIR/before.json"
|
||||
id=$(jq -c --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .identity' "$TB_TEST_DIR/before.json")
|
||||
printf '%s\n' "$id" > "$TB_TEST_DIR/identity.json"
|
||||
tb_approve "$id" true true > "$TB_TEST_DIR/after.json"
|
||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized" and .policy=="manual"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
check 'all(.domains[0].BootACL[]; .=="")' "$TB_TEST_DIR/after.json"
|
||||
inventory=$(tb_inventory)
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.Uid==$uid) | .Stored and .Key=="have"' <<< "$inventory" >/dev/null
|
||||
[[ -s $TB_TEST_KEYS/$TB_TEST_UID ]]
|
||||
sha256sum "$TB_TEST_KEYS/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum"
|
||||
fi
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
device=$(jq -c --arg uid "$TB_TEST_UID" '.stored[] | select(.Uid==$uid)' <<< "$inventory")
|
||||
domain=$(jq -c '.domains[0]' <<< "$inventory")
|
||||
device_path=$(jq -r .path <<< "$device"); domain_path=$(jq -r .path <<< "$domain")
|
||||
tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"auto"'
|
||||
acl=$(jq -c --arg uid "$TB_TEST_UID" '.BootACL | map("") | .[0]=$uid' <<< "$domain")
|
||||
tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$acl" as
|
||||
tb_boot_change true
|
||||
check .boot_protection "$TB_STATE"
|
||||
tb_inventory > "$TB_TEST_DIR/inventory.json"
|
||||
check 'all(.domains[0].BootACL[]; .=="") and .stored[0].Policy=="manual"' "$TB_TEST_DIR/inventory.json"
|
||||
tb_boot_change false
|
||||
check '.boot_protection==false' "$TB_STATE"
|
||||
tb_inventory > "$TB_TEST_DIR/inventory.json"
|
||||
jq -e --argjson acl "$acl" '.domains[0].BootACL==$acl and .stored[0].Policy=="auto"' "$TB_TEST_DIR/inventory.json" >/dev/null
|
||||
jq --arg other "$TB_TEST_OTHER" 'del(.trusted[$other])' "$TB_STATE" | tb_json_write "$TB_STATE"
|
||||
tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"manual"'
|
||||
tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$(jq -c 'map("")' <<< "$acl")" as
|
||||
;;
|
||||
restart)
|
||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
||||
reject tb_approve "$(cat "$TB_TEST_DIR/identity.json")" true
|
||||
tb_reconcile > "$TB_TEST_DIR/after.json"
|
||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
tb_inventory | jq -e '.manager.AuthMode=="disabled"' >/dev/null
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
# Bolt must challenge with the originally saved key, not provision a new
|
||||
# key merely because a reconnecting device advertises the same identity.
|
||||
tb_inventory | jq -e --arg uid "$TB_TEST_UID" \
|
||||
'.devices[] | select(.Uid==$uid) | .Key=="have" and (.AuthFlags | contains("secure"))' >/dev/null
|
||||
sha256sum --status -c "$TB_TEST_DIR/key-checksum"
|
||||
fi
|
||||
;;
|
||||
daemon)
|
||||
TB_MARKER="$TB_TEST_DIR/enabled" TB_LOCK="$TB_TEST_DIR/root.lock"
|
||||
touch "$TB_MARKER"
|
||||
tb_daemon
|
||||
;;
|
||||
status)
|
||||
snapshot=$(tb_public_snapshot)
|
||||
jq -e --arg owner "$(tb_owner)" '.owner==$owner' <<< "$snapshot" >/dev/null
|
||||
printf '%s\n' "$snapshot"
|
||||
;;
|
||||
once)
|
||||
snapshot=$(tb_public_snapshot)
|
||||
id=$(jq -c --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .identity' <<< "$snapshot")
|
||||
exec 9> "$TB_TEST_DIR/root.lock"
|
||||
flock -x 9
|
||||
tb_approve "$id" false > "$TB_TEST_DIR/once.json"
|
||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="authorized" and (.trusted|not)' "$TB_TEST_DIR/once.json" >/dev/null
|
||||
;;
|
||||
*) exit 64 ;;
|
||||
esac
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Only the hardware fixture is Python: upstream Bolt uses UMockdev/GObject.
|
||||
|
||||
All Omarchy behavior and assertions run in bolt-exercise.sh against real boltd,
|
||||
real busctl, a private system bus, and disposable sysfs/firmware/store state.
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
root = Path(sys.argv.pop(1))
|
||||
loader = importlib.machinery.SourceFileLoader('bolt_upstream', str(Path(os.environ['BOLT_TEST_SOURCE']) / 'tests/test-integration'))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
u = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(u)
|
||||
|
||||
|
||||
class ApprovalIntegration(u.BoltTest):
|
||||
def exercise(self, security, numeric=False):
|
||||
self.user_config(AuthMode='disabled', DefaultPolicy='manual')
|
||||
_, host = self.add_domain_host(security=security, iommu='1')
|
||||
path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0')
|
||||
_, other = self.add_device(host, 2, 'Unknown', 'Example', authorized=0, key=None, boot='0')
|
||||
if numeric:
|
||||
# Linux omits these files when the peripheral has no descriptive names.
|
||||
for attribute in ('device_name', 'vendor_name'):
|
||||
Path(self.testbed.get_root_dir(), path.lstrip('/'), attribute).unlink()
|
||||
self.daemon_start()
|
||||
self.polkitd_start()
|
||||
self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage'])
|
||||
env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(),
|
||||
'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other,
|
||||
'TB_TEST_KEYS': str(Path(self.dbpath, 'keys')), 'TB_TEST_SECURITY': security}
|
||||
command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh')]
|
||||
subprocess.run(command + ['approve'], env=env, check=True)
|
||||
self.daemon_stop()
|
||||
self.testbed.set_attribute(path, 'authorized', '0')
|
||||
if security == 'secure':
|
||||
self.testbed.set_attribute(path, 'key', '\n')
|
||||
self.daemon_start()
|
||||
subprocess.run(command + ['restart'], env=env, check=True)
|
||||
# The real Bash daemon publishes a fresh readable snapshot and reconnects
|
||||
# to Bolt after a restart. No custom Omarchy D-Bus server is involved.
|
||||
child = subprocess.Popen(command + ['daemon'], env=env)
|
||||
try:
|
||||
for _ in range(100):
|
||||
result = subprocess.run(command + ['status'], env=env, capture_output=True)
|
||||
if result.returncode == 0:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError('Bash daemon did not publish a valid snapshot')
|
||||
subprocess.run(command + ['once'], env=env, check=True)
|
||||
self.daemon_stop()
|
||||
self.daemon_start()
|
||||
for _ in range(100):
|
||||
if subprocess.run(command + ['status'], env=env, capture_output=True).returncode == 0:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError('Bash daemon did not publish the new Bolt generation')
|
||||
finally:
|
||||
child.terminate()
|
||||
child.wait(timeout=15)
|
||||
self.daemon_stop()
|
||||
|
||||
def test_user_approval(self):
|
||||
self.exercise('user')
|
||||
|
||||
def test_secure_approval(self):
|
||||
self.exercise('secure')
|
||||
|
||||
def test_numeric_identity(self):
|
||||
self.exercise('user', numeric=True)
|
||||
|
||||
def removal(self, security, boot=False, disconnected=False):
|
||||
self.user_config(AuthMode='disabled')
|
||||
_, host = self.add_domain_host(security=security, iommu='1')
|
||||
path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0')
|
||||
other_path, other = self.add_device(host, 2, 'Existing', 'Example', authorized=0, key=None, boot='0')
|
||||
self.daemon_start()
|
||||
self.polkitd_start()
|
||||
self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage'])
|
||||
env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(),
|
||||
'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other,
|
||||
'TB_TEST_STORE': self.dbpath, 'TB_TEST_SECURITY': security}
|
||||
command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-removal.sh')]
|
||||
service = 'omarchy-thunderbolt-authorization.service'
|
||||
child = None
|
||||
|
||||
def operation(action, unit):
|
||||
nonlocal child
|
||||
if unit == 'bolt.service':
|
||||
if action == 'stop':
|
||||
self.daemon_stop()
|
||||
elif self.daemon is None:
|
||||
self.daemon_start()
|
||||
elif unit == service:
|
||||
if action == 'stop' and child is not None:
|
||||
child.terminate()
|
||||
child.wait(timeout=15)
|
||||
child = None
|
||||
elif action == 'start' and child is None:
|
||||
child = subprocess.Popen(['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh'), 'daemon'], env=env)
|
||||
else:
|
||||
raise RuntimeError('Unexpected private service: ' + unit)
|
||||
marker = Path(self.rundir, unit + '.active')
|
||||
if action == 'start':
|
||||
marker.touch()
|
||||
else:
|
||||
marker.unlink(missing_ok=True)
|
||||
|
||||
def run(action):
|
||||
process = subprocess.Popen(command + [action], env=env)
|
||||
control = Path(self.rundir, 'control')
|
||||
deadline = time.monotonic() + 90
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Private setup timed out: ' + action)
|
||||
if control.exists():
|
||||
operation(*control.read_text().strip().split())
|
||||
control.unlink()
|
||||
time.sleep(0.05)
|
||||
if process.returncode:
|
||||
raise subprocess.CalledProcessError(process.returncode, command + [action])
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=15)
|
||||
|
||||
def wait_for(action):
|
||||
for _ in range(50):
|
||||
result = subprocess.run(command + [action], env=env, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(result.stdout, end='', flush=True)
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(action + ': ' + result.stdout + result.stderr)
|
||||
|
||||
def unplug():
|
||||
for device_path in (path, other_path):
|
||||
self.testbed.uevent(device_path, 'remove')
|
||||
self.testbed.remove_device(device_path)
|
||||
wait_for('disconnected')
|
||||
|
||||
try:
|
||||
Path(self.rundir, 'bolt.service.active').touch()
|
||||
run('approve')
|
||||
operation('start', service)
|
||||
Path(self.rundir, service + '.enabled').touch()
|
||||
if boot:
|
||||
run('boot-enable')
|
||||
run('rollback')
|
||||
if disconnected:
|
||||
unplug()
|
||||
run('disable')
|
||||
if not disconnected:
|
||||
unplug()
|
||||
for number, device_uid, name in ((1, uid, 'Dock'), (2, other, 'Existing')):
|
||||
attributes = ['device_name', name, 'device', '0x23', 'vendor_name', 'Example',
|
||||
'vendor', '0x23', 'authorized', '0', 'unique_id', device_uid, 'boot', '0']
|
||||
if number == 1 and security == 'secure':
|
||||
attributes += ['key', '\n']
|
||||
self.testbed.add_device('thunderbolt', '0-' + str(number), host, attributes, ['DEVTYPE', 'thunderbolt_device'])
|
||||
wait_for('reconnected')
|
||||
finally:
|
||||
operation('stop', service)
|
||||
self.daemon_stop()
|
||||
|
||||
def test_user_removal(self):
|
||||
self.removal('user')
|
||||
|
||||
def test_secure_removal(self):
|
||||
self.removal('secure', boot=True, disconnected=True)
|
||||
|
||||
|
||||
names = sys.argv[1:] or ('test_user_approval', 'test_secure_approval', 'test_numeric_identity', 'test_user_removal', 'test_secure_removal')
|
||||
suite = unittest.TestSuite(ApprovalIntegration(name) for name in names)
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
sys.exit(not result.wasSuccessful())
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Real Bolt policy/approval/setup calls. Only systemd is bridged to the private
|
||||
# daemon processes owned by bolt-integration.py; never contact host systemd.
|
||||
set -euo pipefail
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
TB_STATE="$TB_TEST_DIR/policy.json" TB_CONFIG="$TB_TEST_STORE/boltd.conf"
|
||||
TB_MARKER="$TB_TEST_DIR/enabled" TB_PENDING="$TB_TEST_DIR/pending"
|
||||
TB_RUNTIME="$TB_TEST_DIR/runtime" TB_LOCK="$TB_TEST_DIR/root.lock"
|
||||
mkdir -p "$TB_RUNTIME"
|
||||
|
||||
service_request() {
|
||||
printf '%s\n' "$*" > "$TB_TEST_DIR/control.tmp"
|
||||
mv "$TB_TEST_DIR/control.tmp" "$TB_TEST_DIR/control"
|
||||
for (( attempt=0; attempt<300; attempt++ )); do
|
||||
[[ -f $TB_TEST_DIR/control ]] || return 0
|
||||
sleep 0.05
|
||||
done
|
||||
echo "Private service operation timed out: $*" >&2
|
||||
return 1
|
||||
}
|
||||
systemctl() {
|
||||
local action=$1 unit
|
||||
shift
|
||||
printf '%s\n' "$action $*" >> "$TB_TEST_DIR/services.log"
|
||||
case "$action" in
|
||||
show)
|
||||
unit=${!#}
|
||||
if [[ -f $TB_TEST_DIR/$unit.active ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi
|
||||
if [[ -f $TB_TEST_DIR/$unit.enabled ]]; then echo UnitFileState=enabled; else echo UnitFileState=disabled; fi ;;
|
||||
start|stop)
|
||||
for unit in "$@"; do service_request "$action" "$unit" || return 1; done ;;
|
||||
enable)
|
||||
unit=${!#}
|
||||
touch "$TB_TEST_DIR/$unit.enabled"
|
||||
if [[ $1 == "--now" ]]; then service_request start "$unit"; fi ;;
|
||||
disable)
|
||||
if [[ -f $TB_TEST_DIR/fail-disable ]]; then rm "$TB_TEST_DIR/fail-disable"; return 1; fi
|
||||
rm -f -- "$TB_TEST_DIR/$1.enabled" ;;
|
||||
daemon-reload) ;;
|
||||
*) echo "Unexpected systemctl action: $action $*" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
check_inventory() { tb_inventory | jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" "$1" >/dev/null; }
|
||||
case "$1" in
|
||||
approve)
|
||||
jq -cn '{version:1,enabled:true,trusted:{},original_authmode:"enabled"}' > "$TB_STATE"
|
||||
echo removal-test > "$TB_RUNTIME/generation"
|
||||
touch "$TB_MARKER"
|
||||
identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_UID" '.devices[]|select(.identity.Uid==$uid)|.identity')
|
||||
tb_approve "$identity" true true >/dev/null
|
||||
# An existing manual record, even explicitly trusted through Omarchy,
|
||||
# must keep the policy its administrator chose before enrollment.
|
||||
inventory=$(tb_inventory)
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
path=$(jq -r --arg uid "$TB_TEST_OTHER" '.devices[]|select(.Uid==$uid)|.path' <<< "$inventory")
|
||||
tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null
|
||||
tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$TB_TEST_OTHER" manual '' >/dev/null
|
||||
identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_OTHER" '.devices[]|select(.identity.Uid==$uid)|.identity')
|
||||
tb_approve "$identity" true true >/dev/null
|
||||
jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" \
|
||||
'.enrolled[$uid].StoreTime>0 and .enrolled[$other]==null and .trusted[$other]!=null' "$TB_STATE" >/dev/null
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
sha256sum "$TB_TEST_STORE/keys/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum"
|
||||
fi ;;
|
||||
boot-enable) tb_admin boot-enable ;;
|
||||
rollback)
|
||||
cp "$TB_STATE" "$TB_TEST_DIR/old-policy.json"
|
||||
tb_inventory | tb_boot_saved > "$TB_TEST_DIR/old-firmware.json"
|
||||
touch "$TB_TEST_DIR/fail-disable"
|
||||
if tb_admin disable; then echo 'Failed removal unexpectedly succeeded' >&2; exit 1; fi
|
||||
cmp "$TB_STATE" "$TB_TEST_DIR/old-policy.json"
|
||||
cmp <(tb_inventory | tb_boot_saved | jq -cS .) <(jq -cS . "$TB_TEST_DIR/old-firmware.json")
|
||||
[[ -f $TB_MARKER && -f $TB_TEST_DIR/$TB_SERVICE.active && -f $TB_TEST_DIR/$TB_SERVICE.enabled && ! -f $TB_TEST_DIR/setup-recovery.json ]]
|
||||
check_inventory '.manager.AuthMode=="disabled"'
|
||||
echo 'Removal failure restored policy, firmware ACLs and approval service' ;;
|
||||
disable)
|
||||
tb_admin disable
|
||||
[[ ! -f $TB_MARKER && ! -f $TB_TEST_DIR/$TB_SERVICE.active && ! -f $TB_TEST_DIR/$TB_SERVICE.enabled ]]
|
||||
jq -e '.enabled==false and .enrolled==null' "$TB_STATE" >/dev/null
|
||||
check_inventory '.manager.AuthMode=="enabled" and any(.stored[]; .Uid==$uid and .Policy=="auto") and any(.stored[]; .Uid==$other and .Policy=="manual")'
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then sha256sum --status -c "$TB_TEST_DIR/key-checksum"; fi
|
||||
echo 'Removal handed our record back to Bolt and preserved existing manual policy' ;;
|
||||
disconnected)
|
||||
check_inventory 'any(.stored[]; .Uid==$uid and .Status=="disconnected") and any(.stored[]; .Uid==$other and .Status=="disconnected")' ;;
|
||||
reconnected)
|
||||
check_inventory 'any(.devices[]; .Uid==$uid and .Policy=="auto" and (.Status|startswith("authorized"))) and any(.devices[]; .Uid==$other and .Policy=="manual" and .Status=="connected")'
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
check_inventory 'any(.devices[]; .Uid==$uid and .Key=="have" and (.AuthFlags|contains("secure")))'
|
||||
sha256sum --status -c "$TB_TEST_DIR/key-checksum"
|
||||
fi
|
||||
echo "Automatic reconnect succeeded in $TB_TEST_SECURITY mode; pre-existing manual device stayed blocked" ;;
|
||||
*) exit 64 ;;
|
||||
esac
|
||||
@@ -1,728 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# These behavior cases were first run as the existing Python assertions against
|
||||
# the Bash implementation. Fixtures replace only Bolt, systemd and desktop I/O.
|
||||
set -euo pipefail
|
||||
source "$ROOT/test/shell.d/base-test.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-review.sh"
|
||||
|
||||
fixture() {
|
||||
T=$(mktemp -d)
|
||||
trap 'rm -rf -- "$T"' EXIT
|
||||
export HOME="$T/home"
|
||||
TB_STATE="$T/policy.json" TB_CONFIG="$T/boltd.conf" TB_MARKER="$T/enabled"
|
||||
TB_PENDING="$T/pending" TB_SYSFS="$T/sysfs" TB_KEYS="$T/keys"
|
||||
TB_RUNTIME="$T/runtime" TB_LOCK="$T/root.lock"
|
||||
mkdir -p "$TB_RUNTIME" "$HOME" "$TB_SYSFS" "$TB_KEYS"
|
||||
echo generation-a > "$TB_RUNTIME/generation"
|
||||
echo '{"version":1,"enabled":true,"trusted":{},"original_authmode":"enabled"}' > "$TB_STATE"
|
||||
cat > "$T/inventory" <<'JSON'
|
||||
{"owner":":1.5","manager":{"AuthMode":"disabled"},"domains":[{"Uid":"host","SecurityLevel":"user","IOMMU":true,"SysfsPath":"/sys/domain","path":"/domain","BootACL":["device-1",""]}],"devices":[{"Uid":"device-1","Name":"Dock","Vendor":"Vendor","Parent":"host","SysfsPath":"/sys/devices/test/0-1","ConnectTime":1,"Status":"connected","Stored":false,"Policy":"default","AuthFlags":"","path":"/devices/1","inode":"1"}]}
|
||||
JSON
|
||||
: > "$T/operations"; : > "$T/units"; : > "$T/notifications"
|
||||
echo '{}' > "$T/capture"
|
||||
tb_requests
|
||||
}
|
||||
|
||||
edit_inventory() { jq "$@" "$T/inventory" > "$T/next"; mv "$T/next" "$T/inventory"; }
|
||||
edit_state() { jq "$@" "$TB_STATE" | tb_json_write "$TB_STATE"; }
|
||||
check() { jq -e "$1" "$2" >/dev/null || fail "$1" "$(cat "$2")"; }
|
||||
reject() { if ("$@") > "$T/output" 2> "$T/error"; then fail "unexpected success: $*"; fi; }
|
||||
identity() { tb_snapshot | jq -c '.devices[0].identity'; }
|
||||
|
||||
tb_inventory() {
|
||||
[[ ! -f $T/inventory-failure ]] || { tb_fail 'inventory unavailable'; return 1; }
|
||||
jq '. + {stored:[.devices[] | select(.Stored)]}' "$T/inventory"
|
||||
}
|
||||
tb_properties() { jq -c --arg path "$2" '(.devices + .domains)[] | select(.path==$path)' "$T/inventory"; }
|
||||
tb_bus() {
|
||||
local command=$1 path=$3 member=$5 value
|
||||
printf '%s\n' "$member" >> "$T/operations"
|
||||
case "$member" in
|
||||
Authorize)
|
||||
[[ ! -f $T/authorize-failure ]] || return 1
|
||||
if [[ ! -f $T/authorize-noop ]]; then edit_inventory --arg path "$path" '(.devices[] | select(.path==$path)).Status="authorized"'; fi ;;
|
||||
EnrollDevice)
|
||||
if [[ ! -f $T/enroll-noop ]]; then
|
||||
edit_inventory '(.devices[0] | .Stored=true | .Policy="manual" | .StoreTime=100) as $d | .devices[0]=$d'
|
||||
if [[ -f $TB_SYSFS/0-1/key && $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ && ! -f $T/key-store-failure ]]; then
|
||||
edit_inventory '.devices[0].Key="have"'
|
||||
fi
|
||||
fi
|
||||
if [[ -f $T/enroll-lost-reply ]]; then rm "$T/enroll-lost-reply"; return 1; fi ;;
|
||||
Policy|BootACL)
|
||||
if [[ $member == "BootACL" && -f $T/boot-failure ]] || [[ $member == "Policy" && ${7:-} == "auto" && -f $T/restore-failure ]]; then
|
||||
tb_fail 'firmware rejected write'; return 1
|
||||
fi
|
||||
[[ $member != "Policy" || ! -f $T/policy-noop ]] || return 0
|
||||
if [[ $member == "BootACL" ]]; then
|
||||
shift 7
|
||||
value=$(jq -cn '$ARGS.positional' --args "$@")
|
||||
else
|
||||
value=$(jq -cn --arg value "$7" '$value')
|
||||
fi
|
||||
edit_inventory --arg path "$path" --arg key "$member" --argjson value "$value" \
|
||||
'((.devices[],.domains[]) | select(.path==$path))[$key]=$value'
|
||||
if [[ $member == "Policy" ]]; then
|
||||
# Real Bolt changes firmware ACLs when a stored policy changes.
|
||||
edit_inventory --arg path "$path" --arg value "$7" '
|
||||
(.devices[] | select(.path==$path) | .Uid) as $uid |
|
||||
.domains[].BootACL |= (if $value=="auto" and (index($uid)==null) then
|
||||
(index("") // 0) as $slot | .[$slot]=$uid
|
||||
elif $value!="auto" then map(if .==$uid then "" else . end) else . end)'
|
||||
fi ;;
|
||||
*) tb_fail "Unexpected Bolt call: $*"; return 1 ;;
|
||||
esac
|
||||
}
|
||||
tb_capture() { [[ ! -f $T/capture-failure ]] && cat "$T/capture"; }
|
||||
tb_unit_state() { jq -cn --arg unit "$1" '{active:($unit=="bolt.service"),enabled:false}'; }
|
||||
systemctl() {
|
||||
if [[ $1 == --root=* ]]; then command systemctl "$@"; return; fi
|
||||
printf '%s\n' "$*" >> "$T/units"
|
||||
if [[ -f $T/unit-failure && $* == "$(cat "$T/unit-failure")" ]]; then
|
||||
[[ -f $T/repeat-failure ]] || rm "$T/unit-failure"
|
||||
tb_fail 'systemd failed'; return 1
|
||||
fi
|
||||
if [[ $* == "start bolt.service" && -f $TB_CONFIG ]]; then
|
||||
edit_inventory --arg mode "$(tb_config_authmode "$TB_CONFIG")" '.manager.AuthMode=$mode'
|
||||
if [[ ! -f $TB_MARKER && -f $T/restart-policy-failure ]]; then
|
||||
rm "$T/restart-policy-failure"
|
||||
edit_inventory '.devices[0].Policy="manual"'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
tb_public_snapshot() { cat "$T/public"; }
|
||||
tb_notify() {
|
||||
echo "$1" >> "$T/notifications"
|
||||
if [[ -f $T/notify-failure ]]; then rm "$T/notify-failure"; return 1; fi
|
||||
}
|
||||
tb_attention() { echo attention >> "$T/notifications"; }
|
||||
gum() { if [[ $1 == "choose" ]]; then cat "$T/choice"; fi; }
|
||||
tb_request_apply() {
|
||||
jq --argjson identity "$2" --argjson permanent "$1" \
|
||||
'(.devices[] | select(.identity==$identity)) |= (.status="authorized" | .trusted=$permanent | .stored=$permanent)' "$T/public" > "$T/next"
|
||||
mv "$T/next" "$T/public"
|
||||
[[ ! -f $T/lost-reply ]] || { tb_fail 'reply lost'; return 1; }
|
||||
cat "$T/public"
|
||||
}
|
||||
request() { local paths=("$TB_REQUESTS"/request-*.json); printf '%s\n' "${paths[0]}"; }
|
||||
request_count() { local paths; shopt -s nullglob; paths=("$TB_REQUESTS"/request-*.json); echo "${#paths[@]}"; }
|
||||
public_fixture() { tb_snapshot > "$T/public"; }
|
||||
boot_fixture() { edit_inventory '.devices[0] |= (.Stored=true | .Policy="auto")'; }
|
||||
review_tick() {
|
||||
local path watch
|
||||
path=$(request); watch=$(jq -r .watch "$path")
|
||||
tb_scan "$watch" >/dev/null
|
||||
}
|
||||
|
||||
test_iommu_is_not_consent() {
|
||||
edit_inventory '.devices[0] |= (.Stored=true | .Policy="iommu")'
|
||||
tb_reconcile > "$T/result"
|
||||
[[ ! -s $T/operations ]]; check '.devices[0].trusted==false' "$T/result"
|
||||
}
|
||||
test_once_is_not_saved() {
|
||||
tb_approve "$(identity)" false > "$T/result"
|
||||
check '.warnings==[]' "$T/result"; check '.trusted=={}' "$TB_STATE"
|
||||
edit_inventory '.devices[0].Status="connected"'
|
||||
tb_reconcile >/dev/null
|
||||
[[ $(cat "$T/operations") == "Authorize" ]]
|
||||
}
|
||||
test_always_survives_restart() {
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.owner=":1.9" | .devices[0] |= (.Status="connected" | .inode="2")'
|
||||
echo generation-b > "$TB_RUNTIME/generation"
|
||||
tb_reconcile > "$T/result"
|
||||
check '.devices[0].status=="authorized" and .devices[0].trusted' "$T/result"
|
||||
check '.manager.AuthMode=="disabled"' "$T/inventory"
|
||||
}
|
||||
test_enrollment_lost_reply_retains_ownership() {
|
||||
touch "$T/enroll-lost-reply"
|
||||
reject tb_approve "$(identity)" true
|
||||
check '.trusted=={} and .enrolled["device-1"].StoreTime==null' "$TB_STATE"
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
check '.enrolled["device-1"].StoreTime==100 and .trusted["device-1"]!=null' "$TB_STATE"
|
||||
}
|
||||
secure_fixture() {
|
||||
mkdir -p "$TB_SYSFS/0-1"
|
||||
echo 11111111-2222-3333-4444-555555555555 > "$TB_SYSFS/0-1/unique_id"
|
||||
echo 0 > "$TB_SYSFS/0-1/authorized"
|
||||
: > "$TB_SYSFS/0-1/key"
|
||||
edit_inventory --arg path "$TB_SYSFS/0-1" --arg inode "$(stat -Lc %i "$TB_SYSFS/0-1")" \
|
||||
'.domains[0].SecurityLevel="secure" | .devices[0] |= (.Secure=true | .Key="missing" |
|
||||
.Uid="11111111-2222-3333-4444-555555555555" | .SysfsPath=$path | .inode=$inode)'
|
||||
}
|
||||
test_secure_approval_initializes_before_trust() {
|
||||
secure_fixture
|
||||
tb_approve "$(identity)" true true > "$T/result"
|
||||
[[ $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ ]]
|
||||
check '.devices[0].trusted and .devices[0].stored' "$T/result"
|
||||
cp "$TB_SYSFS/0-1/key" "$T/original-key"
|
||||
tb_approve "$(identity)" true true >/dev/null
|
||||
cmp "$TB_SYSFS/0-1/key" "$T/original-key"
|
||||
}
|
||||
test_secure_automatic_restore_cannot_initialize_keys() {
|
||||
secure_fixture
|
||||
jq '.devices[0] | {(.Uid):{Uid,Name,Vendor}}' "$T/inventory" > "$T/trusted"
|
||||
edit_state --slurpfile trusted "$T/trusted" '.trusted=$trusted[0]'
|
||||
tb_reconcile > "$T/result"
|
||||
check '.devices[0].trusted==false' "$T/result"
|
||||
[[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]]
|
||||
reject tb_approve "$(identity)" true
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_secure_missing_saved_key_cannot_claim_success() {
|
||||
secure_fixture
|
||||
touch "$T/key-store-failure"
|
||||
reject tb_approve "$(identity)" true true
|
||||
check '.trusted=={}' "$TB_STATE"
|
||||
tb_snapshot > "$T/result"; check '.devices[0].trusted==false' "$T/result"
|
||||
}
|
||||
test_secure_key_failure_does_not_authorize() {
|
||||
secure_fixture
|
||||
openssl() { return 1; }
|
||||
reject tb_approve "$(identity)" true true
|
||||
[[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]]
|
||||
unset -f openssl
|
||||
echo invalid > "$TB_SYSFS/0-1/key"
|
||||
reject tb_approve "$(identity)" true true
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_authorized_keyless_device_requires_explicit_reconnect() {
|
||||
secure_fixture
|
||||
edit_inventory '.devices[0].Status="authorized"'
|
||||
echo 1 > "$TB_SYSFS/0-1/authorized"
|
||||
reject tb_approve "$(identity)" true true
|
||||
rg -q 'disconnect, reconnect' "$T/error"
|
||||
[[ ! -s $TB_SYSFS/0-1/key ]]; check '.trusted=={}' "$TB_STATE"
|
||||
edit_inventory '.devices[0].Status="connected"'
|
||||
tb_reconcile >/dev/null
|
||||
[[ $(cat "$T/operations") == "EnrollDevice" ]]
|
||||
}
|
||||
test_stale_identity_rejected() {
|
||||
local id field
|
||||
id=$(identity)
|
||||
for field in inode Name ConnectTime; do
|
||||
cp "$T/inventory" "$T/original"
|
||||
edit_inventory --arg field "$field" '.devices[0][$field]="replacement"'
|
||||
reject tb_approve "$id" true
|
||||
mv "$T/original" "$T/inventory"
|
||||
done
|
||||
edit_inventory '.owner=":1.9"'; reject tb_approve "$id" true
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_false_success_rejected() {
|
||||
local id; id=$(identity)
|
||||
touch "$T/authorize-noop"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
||||
rm "$T/authorize-noop"; touch "$T/enroll-noop"
|
||||
reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
||||
rm "$T/enroll-noop"; tb_approve "$id" true > "$T/result"
|
||||
check '.devices[0].trusted' "$T/result"
|
||||
[[ $(rg -c '^Authorize$' "$T/operations") == 2 ]]
|
||||
}
|
||||
test_write_failure_retains_retry() {
|
||||
local id; id=$(identity)
|
||||
eval "$(declare -f tb_json_write | sed '1s/tb_json_write/disk_write/')"
|
||||
tb_json_write() { [[ ! -f $T/disk-full ]] && disk_write "$@"; }
|
||||
touch "$T/disk-full"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
||||
rm "$T/disk-full"; tb_approve "$id" true > "$T/result"
|
||||
check '.devices[0].trusted' "$T/result"
|
||||
[[ $(rg -c '^Authorize$' "$T/operations") == 1 ]]
|
||||
}
|
||||
test_firmware_bypass_warns() {
|
||||
edit_inventory '.domains[0].SecurityLevel="none" | .devices[0] |= (.Status="authorized" | .AuthFlags="boot")'
|
||||
tb_snapshot > "$T/result"; check '.warnings|length==2' "$T/result"; [[ ! -s $T/operations ]]
|
||||
}
|
||||
test_global_enable_not_protected() {
|
||||
edit_inventory '.manager.AuthMode="enabled"'
|
||||
reject tb_snapshot; rg -q 'not active' "$T/error"
|
||||
reject tb_reconcile
|
||||
}
|
||||
test_display_only_needs_no_approval() {
|
||||
edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}'
|
||||
edit_inventory '.devices[0].AuthFlags="nopcie"'
|
||||
tb_reconcile > "$T/result"; check '.error==""' "$T/result"; [[ ! -s $T/operations ]]
|
||||
}
|
||||
test_empty_capture_preserved() {
|
||||
rm "$TB_STATE"; tb_prepare
|
||||
touch "$T/capture-failure"; tb_prepare
|
||||
check '.trusted=={}' "$TB_STATE"; [[ $(tb_config_authmode "$TB_CONFIG") == "disabled" ]]
|
||||
}
|
||||
test_capture_failure_does_not_publish() {
|
||||
rm "$TB_STATE"; touch "$T/capture-failure"; reject tb_prepare
|
||||
[[ ! -e $TB_STATE && ! -e $TB_MARKER ]]
|
||||
}
|
||||
test_capture_optional_names_match_numeric_identity() {
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
mkdir -p "$TB_SYSFS/0-1"
|
||||
echo device-1 > "$TB_SYSFS/0-1/unique_id"
|
||||
echo 0x1234 > "$TB_SYSFS/0-1/device"
|
||||
echo 0xab > "$TB_SYSFS/0-1/vendor"
|
||||
tb_capture > "$T/result"
|
||||
check '.["device-1"]=={Uid:"device-1",Name:"0x1234",Vendor:"0xab"}' "$T/result"
|
||||
echo Dock > "$TB_SYSFS/0-1/device_name"
|
||||
tb_capture > "$T/result"; check '.["device-1"].Name=="Dock" and .["device-1"].Vendor=="0xab"' "$T/result"
|
||||
rm "$TB_SYSFS/0-1/device_name"; echo Vendor > "$TB_SYSFS/0-1/vendor_name"
|
||||
tb_capture > "$T/result"; check '.["device-1"].Name=="0x1234" and .["device-1"].Vendor=="Vendor"' "$T/result"
|
||||
mkdir "$TB_SYSFS/0-1/device_name"
|
||||
reject tb_capture
|
||||
}
|
||||
test_first_activation_defers_without_touching_bolt() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
printf '[config]\nAuthMode=enabled\n' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original"
|
||||
tb_admin owner
|
||||
[[ -f $TB_PENDING && ! -f $TB_STATE && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
cmp "$TB_CONFIG" "$T/original"
|
||||
rm -rf "$TB_SYSFS/0-1"
|
||||
tb_admin migrate
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
tb_admin enable
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_saved_secure_binding_allows_initial_capture() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
edit_inventory '.devices[0] |= (.Stored=true | .Key="have")'
|
||||
tb_admin owner
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_key_without_record_defers_initial_capture() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
edit_inventory '.devices[0].Key="have"'
|
||||
tb_admin owner
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
}
|
||||
test_offline_prepare_cannot_trust_installers_bolt() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
edit_inventory '.devices[0] |= (.Stored=true | .Key="have")'
|
||||
tb_admin prepare
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
}
|
||||
test_deferred_opt_out_retains_initial_enrollment() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
tb_admin owner
|
||||
tb_admin disable
|
||||
[[ ! -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
check '.enabled==false and .enrollment_pending==true' "$TB_STATE"
|
||||
tb_admin migrate
|
||||
[[ ! -f $TB_PENDING && ! -s $T/units ]]
|
||||
tb_admin enable
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
rm -rf "$TB_SYSFS/0-1"
|
||||
echo '{"new-device":{}}' > "$T/capture"
|
||||
tb_admin enable
|
||||
check '.enabled and .enrollment_pending==null and .trusted=={"new-device":{}}' "$TB_STATE"
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_pending_notice_retries_without_claiming_protection() {
|
||||
touch "$TB_PENDING"
|
||||
local count=0 notices=0
|
||||
tb_wait() { count=$((count + 1)); (( count < 3 )) || exit 99; }
|
||||
tb_setup_notice() { notices=$((notices + 1)); (( notices > 1 )) && echo pending >> "$T/notifications"; }
|
||||
reject tb_watch
|
||||
[[ $(cat "$T/notifications") == "pending" ]]
|
||||
tb_status > "$T/result"; rg -q 'protection is deferred' "$T/result"
|
||||
}
|
||||
test_active_policy_is_not_deferred() {
|
||||
secure_fixture; touch "$TB_MARKER"
|
||||
tb_admin owner
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_pending_only_factory_reset_reaches_both_roots() {
|
||||
local function_name root="$T/top/@factory"
|
||||
local TOP_MNT="$T/top" NEXT_NAME=@next PROVISIONING_DIR=/var/lib/omarchy/provisioning LOG_FILE="$T/reset.log" swap_done=0
|
||||
mkdir -p "$root/etc/omarchy" "$root/etc/systemd/system" "$root/usr/bin" \
|
||||
"$root/usr/share/omarchy/install/provisioning" "$T/top/@"
|
||||
touch "$root/etc/omarchy/thunderbolt-authorization.pending" "$root/usr/bin/omarchy-provision-owner"
|
||||
chmod +x "$root/usr/bin/omarchy-provision-owner"
|
||||
echo 'root:x:0:0:root:/root:/bin/bash' > "$root/etc/passwd"
|
||||
cp "$ROOT/install/provisioning/omarchy-provision-owner.service" \
|
||||
"$ROOT/install/provisioning/omarchy-system-factory-reset-finish.service" "$root/usr/share/omarchy/install/provisioning/"
|
||||
cp "$ROOT/etc/systemd/system/omarchy-thunderbolt-authorization.service" "$root/etc/systemd/system/"
|
||||
for function_name in install_provisioning_units scrub_factory_accounts sanitize_factory_baseline stage_full_reset; do
|
||||
eval "$(sed -n "/^$function_name() {$/,/^}/p" "$ROOT/bin/omarchy-system-factory-reset")"
|
||||
done
|
||||
log() { :; }
|
||||
btrfs() {
|
||||
if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then cp -a "$3" "$4"
|
||||
elif [[ $1 == "property" ]]; then :
|
||||
else return 1; fi
|
||||
}
|
||||
usermod() { [[ $1 == "--root" && $2 == "$TOP_MNT/"* && $3 == "--password" && $4 == "!" && $5 == "root" ]]; }
|
||||
systemd-id128() { echo new-machine-id; }
|
||||
encrypted_install() { return 1; }
|
||||
rebuild_next_boot() { :; }
|
||||
sync() { :; }
|
||||
systemctl() {
|
||||
[[ ( $1 == "--root=$TOP_MNT/@next" || $1 == "--root=$TOP_MNT/@factory" ) && $2 == "disable" && $3 == "$TB_SERVICE" ]] || return 1
|
||||
command systemctl "$@"
|
||||
}
|
||||
function /usr/bin/omarchy-thunderbolt-authorization-admin() {
|
||||
[[ $1 == "reset-root" ]] || return 1
|
||||
printf '%s\n' "$2" >> "$T/reset-roots"
|
||||
(
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
tb_reset_root "$2"
|
||||
)
|
||||
}
|
||||
stage_full_reset
|
||||
[[ $(wc -l < "$T/reset-roots") == 2 && $swap_done == 1 ]]
|
||||
[[ ! -f $TOP_MNT/@/etc/omarchy/thunderbolt-authorization.pending &&
|
||||
! -f $TOP_MNT/@factory/etc/omarchy/thunderbolt-authorization.pending ]]
|
||||
[[ -f $TOP_MNT/@/var/lib/omarchy/provisioning/pending ]]
|
||||
}
|
||||
test_config_preserves_settings() {
|
||||
rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG"
|
||||
tb_prepare; rg -q '^DefaultPolicy=manual$' "$TB_CONFIG"
|
||||
check '.original_authmode=="enabled"' "$TB_STATE"
|
||||
}
|
||||
test_fresh_owner_replaces_trust() {
|
||||
rm "$TB_STATE"; echo '{"old":{}}' > "$T/capture"; tb_prepare
|
||||
echo '{"new":{}}' > "$T/capture"; tb_prepare true
|
||||
check '.trusted=={"new":{}} and .original_authmode=="enabled"' "$TB_STATE"
|
||||
}
|
||||
test_enable_rollback() {
|
||||
rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG"
|
||||
cp "$TB_CONFIG" "$T/original"; echo 'start bolt.service' > "$T/unit-failure"
|
||||
reject tb_transaction tb_enable
|
||||
cmp "$TB_CONFIG" "$T/original"; [[ ! -e $TB_STATE && ! -e $TB_MARKER && ! -e $T/setup-recovery.json ]]
|
||||
[[ $(tail -2 "$T/units") == $'start bolt.service\nstop omarchy-thunderbolt-authorization.service' ]]
|
||||
}
|
||||
test_failed_restore_keeps_checkpoint() {
|
||||
rm "$TB_STATE"; echo '[config]' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original"
|
||||
echo 'stop omarchy-thunderbolt-authorization.service bolt.service' > "$T/unit-failure"; touch "$T/repeat-failure"
|
||||
reject tb_transaction tb_enable; rg -q 'restoration is incomplete' "$T/error"
|
||||
[[ -f $T/setup-recovery.json ]]; reject tb_transaction tb_enable; rg -q 'needs recovery' "$T/error"
|
||||
echo partial > "$TB_CONFIG"; rm "$T/unit-failure"
|
||||
tb_config_restore; cmp "$TB_CONFIG" "$T/original"
|
||||
}
|
||||
test_disable_restores_matching_boot_state() {
|
||||
boot_fixture; tb_prepare; tb_boot_change true; cp "$TB_STATE" "$T/original"
|
||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
||||
reject tb_transaction tb_disable
|
||||
cmp "$TB_STATE" "$T/original"; [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory"
|
||||
}
|
||||
test_disable_hands_off_only_our_records() {
|
||||
tb_prepare
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.devices += [(.devices[0] | .Uid="existing" | .path="/devices/2" | .Policy="manual")]'
|
||||
tb_approve "$(tb_snapshot | jq -c '.devices[1].identity')" true >/dev/null
|
||||
tb_admin disable
|
||||
check '.enabled==false and .enrolled==null and (.trusted|length)==2' "$TB_STATE"
|
||||
check '.manager.AuthMode=="enabled" and .devices[0].Policy=="auto" and .devices[1].Policy=="manual"' "$T/inventory"
|
||||
[[ ! -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
# After a completed handoff, an administrator may choose manual themselves.
|
||||
edit_inventory '.devices[0].Policy="manual"'
|
||||
tb_admin enable
|
||||
tb_admin disable
|
||||
check 'all(.devices[]; .Policy=="manual")' "$T/inventory"
|
||||
}
|
||||
test_disable_hands_off_disconnected_device() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.devices[0].Status="disconnected"'
|
||||
tb_admin disable
|
||||
check '.devices[0].Policy=="auto"' "$T/inventory"
|
||||
}
|
||||
test_disable_preserves_recreated_manual_record() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.devices[0].StoreTime=200'
|
||||
tb_admin disable
|
||||
check '.devices[0].Policy=="manual"' "$T/inventory"
|
||||
}
|
||||
test_disable_preserves_original_disabled_mode() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_state '.original_authmode="disabled"'
|
||||
tb_admin disable
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="auto"' "$T/inventory"
|
||||
}
|
||||
test_disable_handoff_after_boot_protection() {
|
||||
tb_prepare; tb_boot_change true
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
tb_admin disable
|
||||
check '.enabled==false and .boot_protection==false' "$TB_STATE"
|
||||
check '.devices[0].Policy=="auto" and (.domains[0].BootACL|index("device-1"))!=null' "$T/inventory"
|
||||
}
|
||||
test_disable_handoff_rolls_back_policies_and_acl() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.domains[0].BootACL=["unrelated",""]'
|
||||
cp "$TB_STATE" "$T/original"
|
||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
||||
reject tb_admin disable
|
||||
cmp "$TB_STATE" "$T/original"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["unrelated",""]' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_handoff_failure_keeps_protection() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
tb_unit_state() { echo '{"active":true,"enabled":true}'; }
|
||||
touch "$T/restore-failure"
|
||||
reject tb_admin disable
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
! rg -q '^disable ' "$T/units"
|
||||
[[ $(tail -1 "$T/units") == "start omarchy-thunderbolt-authorization.service" ]]
|
||||
}
|
||||
test_disable_handoff_verifies_policy_writes() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
touch "$T/policy-noop"
|
||||
reject tb_admin disable
|
||||
rg -q 'did not retain the requested Policy' "$T/error"
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_handoff_verifies_policy_after_restart() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
touch "$T/restart-policy-failure"
|
||||
reject tb_admin disable
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_handoff_incomplete_rollback_is_recoverable() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
||||
touch "$T/boot-failure"
|
||||
reject tb_admin disable
|
||||
rg -q 'restoration is incomplete' "$T/error"
|
||||
[[ -f $T/setup-recovery.json ]]
|
||||
rm "$T/boot-failure"
|
||||
tb_admin recover
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["device-1",""]' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_offline_controller_is_unchanged() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.domains[0].SysfsPath=""'
|
||||
reject tb_admin disable
|
||||
rg -q 'Reconnect every Thunderbolt controller' "$T/error"
|
||||
check '.enabled' "$TB_STATE"
|
||||
[[ ! -s $T/units && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_boot_policy_reconnection_and_restore() {
|
||||
boot_fixture; tb_boot_change true
|
||||
check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory"
|
||||
check '.boot_protection and .trusted["device-1"]!=null' "$TB_STATE"
|
||||
edit_inventory '.devices[0].Policy="auto" | .domains[0].BootACL=["device-1",""]'
|
||||
tb_snapshot > "$T/result"; check '.warnings|length>0' "$T/result"
|
||||
tb_reconcile > "$T/result"; check '.error==""' "$T/result"
|
||||
check '.domains[0].BootACL==["",""]' "$T/inventory"
|
||||
tb_boot_change false
|
||||
check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1",""]' "$T/inventory"
|
||||
}
|
||||
test_boot_change_rollback() {
|
||||
boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]'
|
||||
cp "$TB_STATE" "$T/original"; touch "$T/boot-failure"
|
||||
reject tb_boot_change true
|
||||
check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1","other"]' "$T/inventory"
|
||||
cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]]
|
||||
}
|
||||
test_boot_recovery_retained() {
|
||||
boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]'
|
||||
cp "$TB_STATE" "$T/original"; touch "$T/boot-failure" "$T/restore-failure"
|
||||
reject tb_boot_change true; rg -q 'recovery is incomplete' "$T/error"; [[ -f $T/boot-recovery.json ]]
|
||||
rm "$T/boot-failure" "$T/restore-failure"; tb_boot_recover
|
||||
cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]]
|
||||
}
|
||||
test_unsupported_boot_cannot_succeed() {
|
||||
boot_fixture; cp "$T/inventory" "$T/original"
|
||||
local expression
|
||||
for expression in '.SysfsPath=""' '.SecurityLevel="none"' '.BootACL=[]'; do
|
||||
cp "$T/original" "$T/inventory"; edit_inventory ".domains[0] |= ($expression)"
|
||||
reject tb_boot_change true
|
||||
done
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_install_defers_owner() {
|
||||
install() { :; }
|
||||
function /usr/bin/omarchy-thunderbolt-authorization-admin() { echo "helper $*"; }
|
||||
export OMARCHY_PATH=$ROOT OMARCHY_INSTALL_USER=''
|
||||
source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output"
|
||||
! rg -q helper "$T/output"; [[ ! -s $T/units ]]
|
||||
OMARCHY_INSTALL_USER=lab
|
||||
source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output"
|
||||
rg -q 'helper prepare' "$T/output"; rg -q '^enable omarchy-thunderbolt-authorization.service$' "$T/units"
|
||||
}
|
||||
test_offline_reset_scrubs_private_state() {
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
local root="$T/clone" unit="$TB_SERVICE"
|
||||
mkdir -p "$root/etc/systemd/system" "$root${TB_STATE%/*}" "$root${TB_MARKER%/*}"
|
||||
cp "$ROOT/etc/systemd/system/$unit" "$root/etc/systemd/system/"
|
||||
command systemctl --root="$root" enable "$unit" >/dev/null 2>&1
|
||||
touch "$root$TB_MARKER" "$root$TB_STATE" "$root${TB_STATE%/*}/setup-recovery.json"
|
||||
local kind
|
||||
for kind in devices keys domains; do mkdir -p "$root/var/lib/boltd/$kind"; touch "$root/var/lib/boltd/$kind/old"; done
|
||||
tb_reset_root "$root"
|
||||
[[ ! -e $root$TB_MARKER && ! -e $root${TB_STATE%/*} && ! -e $root/etc/systemd/system/multi-user.target.wants/$unit ]]
|
||||
[[ $(ls "$root/var/lib/boltd") == "boltd.conf" && $(tb_config_authmode "$root/var/lib/boltd/boltd.conf") == "enabled" ]]
|
||||
}
|
||||
test_live_reset_refused() { reject tb_reset_root /; }
|
||||
test_trusted_reconnect_no_alert() {
|
||||
edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}'
|
||||
public_fixture; tb_scan watch1 >/dev/null; [[ ! -s $T/notifications ]]
|
||||
}
|
||||
test_unavailable_warns_once() {
|
||||
tb_scan() { return 1; }
|
||||
tb_wait() { count=$((count+1)); ((count<4)) || exit 99; }
|
||||
local count=0
|
||||
reject tb_watch
|
||||
[[ $(cat "$T/notifications") == "attention" ]]
|
||||
}
|
||||
test_failed_delivery_retries_other_devices() {
|
||||
edit_inventory '.devices += [(.devices[0] | .Uid="device-2")]'; public_fixture; touch "$T/notify-failure"
|
||||
tb_scan watch1 >/dev/null; [[ $(request_count) == 1 ]]
|
||||
tb_scan watch1 >/dev/null; [[ $(request_count) == 2 && $(wc -l < "$T/notifications") == 3 ]]
|
||||
}
|
||||
test_restarts_restore_requests() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local old; old=$(request)
|
||||
tb_scan watch1 >/dev/null; [[ $(wc -l < "$T/notifications") == 1 ]]
|
||||
edit_inventory '.owner=":1.9"'; public_fixture
|
||||
tb_scan watch1 >/dev/null; [[ $(request) != "$old" ]]
|
||||
tb_scan watch2 >/dev/null; [[ $(request_count) == 1 && $(wc -l < "$T/notifications") == 3 ]]
|
||||
}
|
||||
test_inventory_failure_preserves_request() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
||||
rm "$T/public"; reject tb_scan watch1; [[ -f $path ]]
|
||||
}
|
||||
test_replacement_during_dialog_rejected() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
||||
gum() {
|
||||
if [[ $1 == "choose" ]]; then
|
||||
jq '.devices[0].identity.Uid="replacement"' "$T/public" > "$T/next"; mv "$T/next" "$T/public"
|
||||
echo 'Always allow this device'
|
||||
fi
|
||||
}
|
||||
reject tb_review "${path##*/}" # A filename is not a valid token.
|
||||
reject tb_review "$(basename "$path" .json)"
|
||||
check '.devices[0].status=="connected"' "$T/public"
|
||||
}
|
||||
test_lost_reply_keeps_approval_intent() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
||||
echo 'Always allow this device' > "$T/choice"; touch "$T/lost-reply"
|
||||
sleep() { review_tick; }
|
||||
reject tb_review "$(basename "$path" .json)"
|
||||
check '.approval=="Always allow this device"' "$path"
|
||||
tb_scan watch1 >/dev/null; [[ -f $path && $(wc -l < "$T/notifications") == 2 ]]
|
||||
tb_scan watch2 >/dev/null; [[ ! -f $path && $(wc -l < "$T/notifications") == 3 ]]; path=$(request)
|
||||
rm "$T/lost-reply"; gum() { fail 'Saved approval must not ask again'; }
|
||||
tb_review "$(basename "$path" .json)" >/dev/null
|
||||
[[ ! -f $path ]]
|
||||
}
|
||||
test_disconnect_and_untrusted_text() {
|
||||
edit_inventory '.devices[0].Name="$(touch /tmp/untrusted)\u001b[2J"'
|
||||
public_fixture; tb_scan watch1 >/dev/null
|
||||
[[ $(basename "$(request)") =~ ^request-[0-9a-f]{64}.json$ ]]
|
||||
[[ $(jq '.devices[0].Name' "$T/inventory" | tb_safe_text) != *$'\e'* ]]
|
||||
edit_inventory '.devices=[]'; public_fixture; tb_scan watch1 >/dev/null; [[ $(request_count) == 0 ]]
|
||||
}
|
||||
test_snapshot_freshness_and_disabled_state() {
|
||||
# Exercise the real public reader, including freshness after daemon failure.
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
TB_RUNTIME="$T/runtime"
|
||||
jq -cn --argjson now "$(date +%s)" '{available:true,time:$now,generation:"generation-a"}' > "$TB_RUNTIME/snapshot.json"
|
||||
tb_public_snapshot >/dev/null
|
||||
jq '.time-=30' "$TB_RUNTIME/snapshot.json" > "$T/next"; mv "$T/next" "$TB_RUNTIME/snapshot.json"
|
||||
reject tb_public_snapshot
|
||||
echo generation-b > "$TB_RUNTIME/generation"; reject tb_public_snapshot
|
||||
}
|
||||
test_wait_times_out_without_input() {
|
||||
local start=$EPOCHSECONDS
|
||||
TB_TICK='' tb_wait 1
|
||||
(( EPOCHSECONDS - start >= 1 && EPOCHSECONDS - start < 10 ))
|
||||
}
|
||||
test_daemon_rechecks_controller_free_bolt_less_often() {
|
||||
touch "$TB_MARKER"; TB_IDLE_RECHECK=600
|
||||
local tick=0 extra='{}'
|
||||
expect() { [[ $(wc -l < "$T/reconciles") == "$1" ]] || fail "tick $tick: expected $1 reconciles" "$(wc -l < "$T/reconciles")"; }
|
||||
tb_reconcile() {
|
||||
echo >> "$T/reconciles"
|
||||
jq -cn --argjson time "$EPOCHSECONDS" --argjson extra "$extra" \
|
||||
'{available:true,time:$time,generation:"g",domains:[],devices:[],warnings:[],error:"",boot_protection:false} + $extra'
|
||||
}
|
||||
tb_wait() {
|
||||
tick=$((tick + 1))
|
||||
case $tick in
|
||||
1) expect 1 ;;
|
||||
2) expect 1; mkdir "$TB_SYSFS/domain0" ;; # idle: no reconcile
|
||||
3) expect 2; rmdir "$TB_SYSFS/domain0" ;; # a controller appeared
|
||||
4) expect 2; edit_state '.trusted.x={}' ;;
|
||||
5) expect 3; touch "${TB_STATE%/*}/boot-recovery.json" ;; # the policy changed
|
||||
6) expect 4; rm "${TB_STATE%/*}/boot-recovery.json" # a recovery checkpoint
|
||||
extra='{"devices":[{"Uid":"device-1"}]}'; edit_state '.trusted.y={}' ;;
|
||||
7) expect 5 ;; # devices from here on
|
||||
8) expect 6; rm "$TB_MARKER" ;; # so every tick reconciles
|
||||
esac
|
||||
}
|
||||
tb_daemon
|
||||
[[ $tick == 8 ]]
|
||||
}
|
||||
test_daemon_rechecks_after_the_idle_interval() {
|
||||
touch "$TB_MARKER"; TB_IDLE_RECHECK=2
|
||||
local tick=0
|
||||
tb_reconcile() {
|
||||
echo >> "$T/reconciles"
|
||||
jq -cn --argjson time "$EPOCHSECONDS" '{available:true,time:$time,generation:"g",domains:[],devices:[],warnings:[],error:"",boot_protection:false}'
|
||||
}
|
||||
tb_wait() {
|
||||
tick=$((tick + 1))
|
||||
case $tick in
|
||||
1) sleep 2.1 ;;
|
||||
2) [[ $(wc -l < "$T/reconciles") == 2 ]] || fail "the idle check did not expire"; rm "$TB_MARKER" ;;
|
||||
esac
|
||||
}
|
||||
tb_daemon
|
||||
}
|
||||
test_daemon_failed_reconcile_keeps_every_tick() {
|
||||
touch "$TB_MARKER"; TB_IDLE_RECHECK=600
|
||||
local tick=0
|
||||
tb_reconcile() { echo >> "$T/reconciles"; tb_fail 'Bolt unavailable'; }
|
||||
tb_wait() { tick=$((tick + 1)); (( tick < 3 )) || rm "$TB_MARKER"; }
|
||||
tb_daemon 2>/dev/null
|
||||
[[ $(wc -l < "$T/reconciles") == 3 ]]
|
||||
check '.available==false' "$TB_RUNTIME/snapshot.json"
|
||||
}
|
||||
test_watch_skips_only_unchanged_clean_snapshots() {
|
||||
local tick=0 start=$EPOCHSECONDS
|
||||
publish() { jq -cnS --argjson time "$1" --arg generation generation-a --argjson devices "${2:-[]}" \
|
||||
'{available:true,time:$time,generation:$generation,devices:$devices,warnings:[],error:""}' > "$TB_RUNTIME/snapshot.json"; }
|
||||
expect() { [[ $(wc -l < "$T/scans") == "$1" ]] || fail "tick $tick: expected $1 scans" "$(wc -l < "$T/scans")"; }
|
||||
tb_scan() { echo >> "$T/scans"; cat "$TB_RUNTIME/snapshot.json"; }
|
||||
publish "$start"
|
||||
tb_wait() {
|
||||
tick=$((tick + 1))
|
||||
case $tick in
|
||||
1) expect 1 ;;
|
||||
2) expect 1; touch "$TB_REQUESTS/request-x.json" ;; # unchanged: skipped
|
||||
3) expect 2; rm "$TB_REQUESTS/request-x.json" ;; # a request in flight
|
||||
4) expect 2; publish "$((start - 5))" ;;
|
||||
5) expect 3; echo generation-b > "$TB_RUNTIME/generation" ;; # a new snapshot
|
||||
6) expect 4; echo generation-a > "$TB_RUNTIME/generation" # another daemon
|
||||
publish "$start" '[{"identity":{}}]' ;;
|
||||
7) expect 5 ;; # devices from here on
|
||||
8) expect 6; exit 0 ;; # so every tick scans
|
||||
esac
|
||||
}
|
||||
(tb_watch)
|
||||
}
|
||||
test_watch_rescans_a_stale_snapshot() {
|
||||
local tick=0
|
||||
jq -cnS --argjson time "$((EPOCHSECONDS - 30))" \
|
||||
'{available:true,time:$time,generation:"generation-a",devices:[],warnings:[],error:""}' > "$TB_RUNTIME/snapshot.json"
|
||||
tb_scan() { echo >> "$T/scans"; cat "$TB_RUNTIME/snapshot.json"; }
|
||||
tb_wait() { tick=$((tick + 1)); (( tick < 3 )) || exit 0; }
|
||||
(tb_watch)
|
||||
[[ $(wc -l < "$T/scans") == 3 ]]
|
||||
}
|
||||
test_invalid_state_not_skipped_by_migration() {
|
||||
echo '{"enabled":false}' > "$TB_STATE"; reject tb_admin migrate
|
||||
[[ ! -s $T/units ]]
|
||||
}
|
||||
|
||||
for case in $(declare -F | awk '$3 ~ /^test_/ {print $3}'); do
|
||||
(fixture; "$case")
|
||||
pass "Thunderbolt ${case#test_}"
|
||||
done
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$ROOT/test/shell.d/base-test.sh"
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf -- "$fixture"' EXIT
|
||||
for name in admin approve daemon; do
|
||||
# Map the security library and stop before service work, including when the
|
||||
# suite itself runs as root. Startup checks remain the production code.
|
||||
sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \
|
||||
-e 's|source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh|exit 126|' \
|
||||
"$ROOT/bin/omarchy-thunderbolt-authorization-$name" > "$fixture/entry"
|
||||
chmod 755 "$fixture/entry"
|
||||
printf 'set -p\n' > "$fixture/decoy"
|
||||
if BASH_ENV="$fixture/decoy" /usr/bin/bash "$fixture/entry" -p; then
|
||||
fail "$name accepts ordinary Bash with a decoy -p"
|
||||
fi
|
||||
printf 'touch "%s"\n' "$fixture/injected" > "$fixture/startup"
|
||||
# The entrypoint must suppress startup files and exported functions before
|
||||
# checking EUID or reaching the fixture's service-work boundary.
|
||||
if BASH_ENV="$fixture/startup" "$fixture/entry" >/dev/null 2>&1; then
|
||||
fail "$name passed the fixture service-work boundary"
|
||||
fi
|
||||
[[ ! -e $fixture/injected ]] || fail "$name executed BASH_ENV"
|
||||
if /usr/bin/env 'BASH_FUNC_source%%=() { echo injected > "$TB_TEST_INJECTED"; }' \
|
||||
TB_TEST_INJECTED="$fixture/injected" "$fixture/entry" >/dev/null 2>&1; then
|
||||
fail "$name passed the fixture boundary with an exported function"
|
||||
fi
|
||||
[[ ! -e $fixture/injected ]] || fail "$name imported an exported function"
|
||||
pass "Thunderbolt $name rejects unsafe Bash startup"
|
||||
done
|
||||
@@ -1,57 +0,0 @@
|
||||
#include <usbguard/Rule.hpp>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
static std::string read(const char* path) {
|
||||
std::ifstream file(path);
|
||||
std::string text;
|
||||
std::getline(file, text);
|
||||
if (text.empty()) throw std::runtime_error("empty rule");
|
||||
return text;
|
||||
}
|
||||
|
||||
static std::string replace(std::string text, const std::string& from, const std::string& to) {
|
||||
const auto index = text.find(from);
|
||||
if (index == std::string::npos) throw std::runtime_error("missing attribute");
|
||||
text.replace(index, from.size(), to);
|
||||
return text;
|
||||
}
|
||||
|
||||
static void check(const char* name, const usbguard::Rule& policy, const usbguard::Rule& device, bool expected) {
|
||||
if (policy.appliesTo(device) != expected) throw std::runtime_error(name);
|
||||
std::cout << "ok - native USBGuard: " << name << '\n';
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) return 2;
|
||||
// Test the policy produced by the real Bash implementation, not a C++ copy.
|
||||
const auto policy = usbguard::Rule::fromString(read(argv[1]));
|
||||
const auto text = read(argv[2]);
|
||||
const auto device = usbguard::Rule::fromString(text);
|
||||
check("original connection", policy, device, true);
|
||||
auto moved = device;
|
||||
moved.setViaPort("9-9.9");
|
||||
check("another port", policy, moved, true);
|
||||
moved.setParentHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
check("another hub", policy, moved, true);
|
||||
auto reloaded = usbguard::Rule::fromString(policy.toString());
|
||||
check("serialized policy reload on new hub", reloaded, moved, true);
|
||||
check("reconnect on original port", reloaded, device, true);
|
||||
auto different = moved;
|
||||
different.setHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
check("different hash denied", policy, different, false);
|
||||
different = moved;
|
||||
different.setSerial("different");
|
||||
check("different serial denied", policy, different, false);
|
||||
different = moved;
|
||||
different.setName("different");
|
||||
check("different name denied", policy, different, false);
|
||||
check("different product denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046d:c53b")), false);
|
||||
check("different vendor denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046e:c53a")), false);
|
||||
check("different interface denied", policy, usbguard::Rule::fromString(replace(text, "03:00:00", "08:06:50")), false);
|
||||
check("different connection type denied", policy, usbguard::Rule::fromString(replace(text, "with-connect-type \"unknown\"", "with-connect-type \"hotplug\"")), false);
|
||||
// Existing unmarked/manual rules remain location-bound.
|
||||
check("legacy rule still restricts ports and hubs", device, moved, false);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ sed -n '/^rekey_luks() {/,/^}/p; /^run_provisioning() {/,/^}/p; /^cleanup_oem_st
|
||||
/^platform_ready() {/,/^}/p; /^run_setup() {/,/^}/p
|
||||
/^rekey_accepts_password() {/,/^}/p' \
|
||||
"$ROOT/bin/omarchy-provision-owner" |
|
||||
sed -e "s|/etc/|$tmp/etc/|g" -e "s|/usr/bin/omarchy-thunderbolt-authorization-admin|omarchy-thunderbolt-authorization-admin|" >"$tmp/provision.sh"
|
||||
sed "s|/etc/|$tmp/etc/|g" >"$tmp/provision.sh"
|
||||
grep -q '^run_provisioning() {' "$tmp/provision.sh" && grep -q '^run_setup() {' "$tmp/provision.sh" ||
|
||||
fail "omarchy-provision-owner defines its setup and provisioning worker"
|
||||
|
||||
@@ -241,11 +241,6 @@ setup_functions() {
|
||||
configure_hostname() { :; }
|
||||
configure_timezone() { :; }
|
||||
finalize_user() { :; }
|
||||
# Accessory enrollment, recorded with whether the staged unlock was gone.
|
||||
omarchy-pkg-present() { return 1; }
|
||||
omarchy-thunderbolt-authorization-admin() {
|
||||
if luks_staged_unlock_remains; then echo "$* early" >>"$TMP/accessories"; else echo "$*" >>"$TMP/accessories"; fi
|
||||
}
|
||||
limine_entries_stale() {
|
||||
crash_point "re-key returned"
|
||||
[[ -e $TMP/stale ]]
|
||||
@@ -335,7 +330,7 @@ slot_count() {
|
||||
fixture() {
|
||||
local format=${1:-luks2}
|
||||
rm -rf "$tmp/provisioning" "$tmp/etc" "$tmp/boot" "$tmp/log" "$tmp/output" "$tmp/trace" "$tmp/rebuilds" "$tmp/adds" "$tmp/rebuild-fail" "$tmp/kill-noop" \
|
||||
"$tmp/prepare-fail" "$tmp/screen" "$tmp/stale" "$tmp/token-slot" "$tmp/slot-record" "$tmp/record-fail" "$tmp/accessories"
|
||||
"$tmp/prepare-fail" "$tmp/screen" "$tmp/stale" "$tmp/token-slot" "$tmp/slot-record" "$tmp/record-fail"
|
||||
mkdir -p "$tmp/provisioning"
|
||||
chmod 755 "$tmp/provisioning"
|
||||
touch "$tmp/provisioning/pending"
|
||||
@@ -474,8 +469,6 @@ for run_spec in "${matrix[@]}"; do
|
||||
fixture "$format"
|
||||
run attempt "$owner_password" || fail "$backend: uninterrupted setup completes" "$(cat "$tmp/log" "$tmp/output")"
|
||||
assert_provisioned "uninterrupted" "$owner_password"
|
||||
[[ $(cat "$tmp/accessories" 2>/dev/null) == "owner" ]] ||
|
||||
fail "$backend: accessories are enrolled once, after the staged unlock is gone" "$(cat "$tmp/accessories" 2>/dev/null)"
|
||||
total_steps=$(cat "$tmp/steps")
|
||||
(( total_steps >= 11 )) || fail "$backend: every durable step is a crash point" "$(cat "$tmp/trace")"
|
||||
if [[ $platform == "apple" ]]; then
|
||||
|
||||
@@ -352,13 +352,6 @@ assert(
|
||||
defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'),
|
||||
'menu places Passwordless Sudo under Setup > Security'
|
||||
)
|
||||
assert(
|
||||
defaultById['setup.security.usb-authorization'].action.includes('omarchy-setup-security-usb-authorization')
|
||||
&& defaultById['remove.security.usb-authorization'].action.includes('omarchy-remove-security-usb-authorization')
|
||||
&& defaultById['setup.security.usb-authorization-boot'].action.includes('omarchy-setup-security-usb-authorization --boot')
|
||||
&& defaultById['remove.security.usb-authorization-boot'].action.includes('omarchy-remove-security-usb-authorization --boot-only'),
|
||||
'menu can enable and remove USB device authorization under Security'
|
||||
)
|
||||
assert(
|
||||
!defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'],
|
||||
'menu removes the relocated toggles from Trigger > Toggle'
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
bash "$ROOT/test/shell.d/fixtures/thunderbolt/policy-test.sh"
|
||||
bash "$ROOT/test/shell.d/fixtures/thunderbolt/startup-test.sh"
|
||||
|
||||
if [[ -n ${BOLT_TEST_SOURCE:-} ]]; then
|
||||
/usr/bin/python3 "$ROOT/test/shell.d/fixtures/thunderbolt/bolt-integration.py" "$ROOT"
|
||||
else
|
||||
skip "real boltd integration needs BOLT_TEST_SOURCE and an UMockdev environment"
|
||||
fi
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/install/helpers/usb-authorization-policy.sh"
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
identity='id 046d:c53a serial "" name "USB Receiver" hash "KCUFt1MumW4Pfs/8YXWzGQB6Hsnm8qkDqFVjfY0NIBY="'
|
||||
interfaces='with-interface { 03:01:01 03:01:02 03:00:00 } with-connect-type "unknown"'
|
||||
location='parent-hash "m7yTNWlczwBbYn+uRP6TRsY5AceKmAZ0Et6mgy58+/o=" via-port "3-1.1.2.1"'
|
||||
device="allow $identity $location $interfaces"
|
||||
portable=$(usb_authorization_portable_rule "$device")
|
||||
[[ $portable == "allow $identity $interfaces"' label "omarchy-usb-authorization-v1"' ]] || fail "only device location is removed"
|
||||
[[ $(usb_authorization_portable_rule "${device/#allow/block}") == "$portable" ]] || fail "blocked approvals use the same portable identity"
|
||||
pass "portable USB rules retain every device identity attribute"
|
||||
|
||||
# A canonical escaped name cannot cause its contents to become attributes.
|
||||
name='name "Receiver \" via-port \"fake\" parent-hash \"fake\" $(touch /tmp/usb-injection)"'
|
||||
hostile="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $location $interfaces"
|
||||
expected="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $interfaces"' label "omarchy-usb-authorization-v1"'
|
||||
[[ $(usb_authorization_portable_rule "$hostile") == "$expected" ]] || fail "quoted attribute text must remain data"
|
||||
if usb_authorization_portable_rule 'allow id 046d:c53a name "unterminated' >/dev/null; then fail "reject unmatched quote"; fi
|
||||
if usb_authorization_portable_rule 'allow id 046d:c53a via-port' >/dev/null; then fail "reject missing location value"; fi
|
||||
pass "USB portable parsing preserves escaped and hostile descriptor text"
|
||||
|
||||
# Exercise the actual root entrypoint startup boundary without root work.
|
||||
sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \
|
||||
-e 's|source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh|exit 126|' \
|
||||
"$ROOT/bin/omarchy-usb-authorization-approve" >"$scratch/entry"
|
||||
chmod 755 "$scratch/entry"
|
||||
printf 'set -p\n' >"$scratch/decoy"
|
||||
if BASH_ENV="$scratch/decoy" /usr/bin/bash "$scratch/entry" -p; then fail "ordinary Bash with decoy -p is rejected"; fi
|
||||
printf 'touch "%s"\n' "$scratch/injected" >"$scratch/startup"
|
||||
if BASH_ENV="$scratch/startup" "$scratch/entry" >/dev/null 2>&1; then fail "startup fixture boundary"; fi
|
||||
if /usr/bin/env 'BASH_FUNC_source%%=() { touch "$USB_TEST_INJECTED"; }' USB_TEST_INJECTED="$scratch/injected" "$scratch/entry" >/dev/null 2>&1; then fail "exported function fixture boundary"; fi
|
||||
[[ ! -e $scratch/injected ]] || fail "privileged entrypoint executes caller startup code"
|
||||
pass "USB privileged approval rejects unsafe Bash startup"
|
||||
|
||||
if ! command -v g++ >/dev/null || [[ ! -f /usr/include/usbguard/Rule.hpp ]]; then
|
||||
skip "native USBGuard matcher requires g++ and USBGuard development headers"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$device" >"$scratch/device"
|
||||
printf '%s\n' "$portable" >"$scratch/policy"
|
||||
g++ -std=c++17 "$ROOT/test/shell.d/fixtures/usb-authorization/match.cpp" -lusbguard -o "$scratch/match"
|
||||
"$scratch/match" "$scratch/policy" "$scratch/device"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,257 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
machine=12345678901234567890123456789012
|
||||
export calls="$scratch/calls"
|
||||
: >"$calls"
|
||||
|
||||
# Optional integration run uses real sbctl keys and sbattach in isolated paths.
|
||||
sbctl() {
|
||||
printf '%s\n' "$1" >>"$calls"
|
||||
[[ ${SIGN_FAIL:-0} != 1 || $1 != sign ]] || return 1
|
||||
[[ ${VERIFY_FAIL:-0} != 1 || $1 != verify ]] || return 1
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
||||
elif [[ $1 == verify ]]; then
|
||||
jq -n --arg file "${!#}" --argjson signed "${TEST_SIGNED:-1}" '[{file_name: $file, is_signed: $signed}]'
|
||||
fi
|
||||
}
|
||||
sbattach() {
|
||||
if [[ -n ${USB_TEST_SBATTACH:-} ]]; then
|
||||
"$USB_TEST_SBATTACH" "$@"
|
||||
fi
|
||||
}
|
||||
omarchy-cmd-missing() { return 1; }
|
||||
limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; }
|
||||
usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; }
|
||||
# Bootloader enrollment/rollback is exercised with real EFI binaries in its own suite.
|
||||
usb_authorization_verify_bootloader() { return 0; }
|
||||
|
||||
fixture() {
|
||||
local embedded="$1" hash digest image_base
|
||||
esp="$scratch/case-$RANDOM"
|
||||
history="$esp/$machine/limine_history"
|
||||
manifest="$history/snapshots.json"
|
||||
cache="$esp/cache.json"
|
||||
config="$esp/limine.conf"
|
||||
mkdir -p "$history"
|
||||
printf '%s\0' "$embedded" >"$esp/cmdline"
|
||||
cp /usr/lib/systemd/boot/efi/linuxx64.efi.stub "$esp/image.efi"
|
||||
image_base=$(objdump -p "$esp/image.efi" | awk '$1 == "ImageBase" {print "0x" $2}')
|
||||
objcopy --add-section .cmdline="$esp/cmdline" --change-section-vma .cmdline="$((image_base + 0x100000))" "$esp/image.efi"
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
sbctl sign "$esp/image.efi" >/dev/null
|
||||
fi
|
||||
digest=$(sha256sum "$esp/image.efi"); digest=${digest%% *}
|
||||
old_name="test.efi_sha256_$digest"
|
||||
cp "$esp/image.efi" "$history/$old_name"
|
||||
hash=$(b2sum "$esp/image.efi"); hash=${hash%% *}
|
||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n comment: kernel-id=linux\n path: boot():/%s/limine_history/%s#%s\n cmdline: %s rootflags=subvol=/snapshot\n' \
|
||||
"$machine" "$machine" "$old_name" "$hash" "$embedded" >"$config"
|
||||
jq -n --arg name "$old_name" --arg hash "$hash" --arg machine "$machine" --arg cmdline "$embedded" '
|
||||
def kernel:
|
||||
{imageDetails: [{fileHashName: $name, fileName: "test.efi",
|
||||
snapshotFilePathLine: "/" + $machine + "/limine_history/" + $name + "#" + $hash,
|
||||
filePathLine: "/EFI/Linux/test.efi#" + $hash, properties: {HASH: "#" + $hash}}],
|
||||
cmdlineDetails: [{cmdline: $cmdline, snapshotCmdline: $cmdline + " rootflags=subvol=/snapshot"}],
|
||||
allInConfig: ["path: boot():/EFI/Linux/test.efi#" + $hash, "cmdline: " + $cmdline],
|
||||
allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/" + $machine + "/limine_history/" + $name + "#" + $hash, "cmdline: " + $cmdline + " rootflags=subvol=/snapshot"], subKernels: []};
|
||||
{jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel]]}]}
|
||||
' >"$manifest"
|
||||
cp "$manifest" "$manifest.old"
|
||||
cp "$manifest" "$cache"
|
||||
cp "$manifest" "$esp/before.json"
|
||||
cp "$config" "$esp/before.conf"
|
||||
: >"$calls"
|
||||
}
|
||||
|
||||
for expected in enabled disabled; do
|
||||
old='quiet'
|
||||
[[ $expected != disabled ]] || old+=' usbcore.authorized_default=0'
|
||||
fixture "$old"
|
||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
||||
[[ $new_name != "$old_name" && -f $history/$new_name ]] || fail "conflicting archives get new content-addressed copies"
|
||||
cmp "$history/$old_name" "$esp/image.efi" || fail "original archived image remains unchanged"
|
||||
digest=$(sha256sum "$history/$new_name")
|
||||
[[ $new_name == *"${digest%% *}" ]] || fail "replacement name matches its content hash"
|
||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
||||
embedded=$(tr '\0' '\n' <"$esp/section")
|
||||
wanted=quiet
|
||||
[[ $expected != enabled ]] || wanted+=' usbcore.authorized_default=0'
|
||||
[[ $embedded == "$wanted" ]] || fail "replacement must preserve embedded boot arguments and change only USB policy"
|
||||
objcopy -O binary --only-section=.text "$history/$old_name" "$esp/text.before"
|
||||
objcopy -O binary --only-section=.text "$history/$new_name" "$esp/text.after"
|
||||
cmp "$esp/text.before" "$esp/text.after" || fail "archive conversion must not alter executable code"
|
||||
[[ $(grep -c '^sign$' "$calls") == 1 && $(grep -c '^verify$' "$calls") == 2 ]] || fail "deduplicated image must be verified before and after signing"
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
! grep -Fq "$old_name" "$file" || fail "no old image references may survive in manifests"
|
||||
usb_authorization_rewrite_snapshot_manifest "$expected" "$file"
|
||||
# Both cached and structured restore representations must regenerate valid entries.
|
||||
for format in cached structured; do
|
||||
{
|
||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n' "$machine"
|
||||
if [[ $format == cached ]]; then
|
||||
jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$file"
|
||||
else
|
||||
printf 'comment: kernel-id=linux\n'
|
||||
jq -r '.snapshotEntries[0].kernelEntries[0].subKernels[0] | "path: boot():" + .imageDetails[0].snapshotFilePathLine, "cmdline: " + .cmdlineDetails[0].snapshotCmdline' "$file"
|
||||
fi
|
||||
} >"$esp/regenerated.conf"
|
||||
usb_authorization_verify_boot_entries "$expected" "$esp/regenerated.conf" "$esp" "$machine" enabled || fail "regenerated $format $expected snapshot must verify"
|
||||
done
|
||||
done
|
||||
usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine"
|
||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine" enabled || fail "$expected archive transition must verify under Secure Boot"
|
||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
||||
[[ $(grep -c '^sign$' "$calls") == 1 ]] || fail "retry must not resign an already-compatible archive"
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
usb_authorization_verify_signature "$history/$new_name"
|
||||
fi
|
||||
pass "USB $expected transition updates signed history and all cached/structured references"
|
||||
done
|
||||
|
||||
fixture quiet
|
||||
old_hash=$(b2sum "$esp/image.efi"); old_hash=${old_hash%% *}
|
||||
printf ' //current\n comment: kernel-id=current\n path: boot():/image.efi#%s\n cmdline: quiet\n' "$old_hash" >>"$config"
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
grep -Fq "path: boot():/image.efi#$old_hash" "$config" || fail "preflight must not rewrite hashes of unchanged current images sharing archive content"
|
||||
pass "USB archive preflight preserves unchanged current-image hashes"
|
||||
|
||||
fixture 'quiet rd.test="two spaces"'
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
||||
[[ $(tr '\0' '\n' <"$esp/section") == 'quiet rd.test="two spaces" usbcore.authorized_default=0' ]] || fail "unrelated quoted arguments must retain their whitespace"
|
||||
pass "USB archive policy changes preserve unrelated embedded arguments and executable code"
|
||||
|
||||
for failure in SIGN_FAIL VERIFY_FAIL ENROLL_FAIL; do
|
||||
fixture quiet
|
||||
if (export "$failure=1"; usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache") >"$esp/failure.log" 2>&1; then
|
||||
fail "$failure must prevent publication"
|
||||
fi
|
||||
cmp "$config" "$esp/before.conf" || fail "$failure must preserve the original boot menu"
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
cmp "$file" "$esp/before.json" || fail "$failure must preserve every manifest"
|
||||
done
|
||||
cmp "$history/$old_name" "$esp/image.efi" || fail "$failure must preserve the archived image"
|
||||
done
|
||||
pass "USB archive signing, verification and enrollment failures preserve original references"
|
||||
|
||||
fixture quiet
|
||||
printf corrupt >>"$history/$old_name"
|
||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/hash-failure" 2>&1; then
|
||||
fail "corrupt original archive must never be signed"
|
||||
fi
|
||||
[[ ! -s $calls ]] || fail "hash failure must occur before signing"
|
||||
cmp "$config" "$esp/before.conf"
|
||||
SECURE_BOOT=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
[[ ! -s $calls ]] || fail "non-Secure-Boot transitions must not rewrite archive images"
|
||||
pass "USB archive conversion rejects corruption and skips non-Secure-Boot systems"
|
||||
|
||||
if [[ -z ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
fixture quiet
|
||||
if TEST_SIGNED=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/unsigned" 2>&1; then
|
||||
fail "a false-success sbctl result cannot authorize signing"
|
||||
fi
|
||||
! grep -qx sign "$calls" || fail "an unsigned original must not be resigned"
|
||||
fi
|
||||
for missing in menu manifest; do
|
||||
fixture quiet
|
||||
if [[ $missing == menu ]]; then
|
||||
sed -i '/cmdline:/d' "$config"
|
||||
else
|
||||
jq '.snapshotEntries[0].kernelEntries[0].cmdlineDetails = []' "$manifest" >"$esp/modified"
|
||||
mv "$esp/modified" "$manifest"
|
||||
fi
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" || fail "embedded-only $missing must support policy changes"
|
||||
usb_authorization_rewrite_boot_cmdlines enabled "$config" "$machine"
|
||||
usb_authorization_verify_boot_entries enabled "$config" "$esp" "$machine" enabled || fail "embedded-only $missing must retain a valid command line"
|
||||
done
|
||||
for section in .profile .pcrsig; do
|
||||
fixture quiet
|
||||
printf protected >"$esp/protected"
|
||||
objcopy --add-section "$section=$esp/protected" "$history/$old_name" 2>/dev/null
|
||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/protected-failure" 2>&1; then
|
||||
fail "a $section image must require its original builder"
|
||||
fi
|
||||
grep -Fq "($section)" "$esp/protected-failure" || fail "unsupported signing policies need an actionable error"
|
||||
[[ ! -s $calls ]] || fail "unsupported signing policies must not be resigned"
|
||||
done
|
||||
pass "USB archive conversion requires authentic signatures and preserves boot/unlock requirements"
|
||||
|
||||
# Package hooks use this same lock. Installing the optional signature tool
|
||||
# must finish before preparation takes the lock, while sync must run under it.
|
||||
fixture quiet
|
||||
prepare=$(declare -f usb_authorization_prepare_archived_images)
|
||||
prepare=${prepare//\/run\/lock\/boot-partition.lock/$scratch\/preflight.lock}
|
||||
eval "$prepare"
|
||||
omarchy-cmd-missing() { return 0; }
|
||||
omarchy-pkg-add() {
|
||||
flock --nonblock "$scratch/preflight.lock" true || fail "package installation must precede the boot lock"
|
||||
printf 'package\n' >>"$calls"
|
||||
}
|
||||
limine-snapper-sync() {
|
||||
[[ $* == '--no-mutex --no-hooks' ]] || fail "preparation must use the already-held lock"
|
||||
if flock --nonblock "$scratch/preflight.lock" true; then
|
||||
fail "snapshot synchronization must hold the boot lock"
|
||||
fi
|
||||
}
|
||||
usb_authorization_prepare_archived_images enabled "$config" "$esp" "$machine"
|
||||
grep -qx package "$calls" || fail "missing signature tooling must be installed before preparing archives"
|
||||
pass "USB archive preparation installs optional tools before taking Limine's lock"
|
||||
|
||||
# The disposable Lab runs the actual root-only main function with only boot paths
|
||||
# and external build/sync commands redirected into this fixture.
|
||||
if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then
|
||||
(( EUID == 0 )) || fail "main-path fixtures require the disposable guest's root"
|
||||
usb_authorization_prepare_archived_images() {
|
||||
usb_authorization_reconcile_archived_images "$1" "$2" "$3" "$machine" "$cache"
|
||||
}
|
||||
limine-mkinitcpio() { return 0; }
|
||||
usb_authorization_sync_and_verify() {
|
||||
local file
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
usb_authorization_rewrite_snapshot_manifest "$1" "$file" || return 1
|
||||
done
|
||||
usb_authorization_rewrite_boot_cmdlines "$1" "$2" "$machine" || return 1
|
||||
usb_authorization_verify_boot_entries "$1" "$2" "$3" "$machine" enabled
|
||||
}
|
||||
for action in enable disable; do
|
||||
old=quiet
|
||||
[[ $action != disable ]] || old+=' usbcore.authorized_default=0'
|
||||
fixture "$old"
|
||||
drop_in="$esp/entry-tool/usb-authorization.conf"
|
||||
limine_defaults="$esp/defaults"
|
||||
limine_conf="$config"
|
||||
boot_path="$esp"
|
||||
printf '# fixture defaults\n' >"$limine_defaults"
|
||||
if [[ $action == disable ]]; then
|
||||
mkdir -p "${drop_in%/*}"
|
||||
printf '%s\n' "$setting" >"$drop_in"
|
||||
usb_authorization_enable_snapshot_setting "$limine_defaults"
|
||||
fi
|
||||
cp "$limine_defaults" "$esp/defaults.before"
|
||||
if (export SIGN_FAIL=1; usb_authorization_main "$action") >"$esp/main-failed" 2>&1; then
|
||||
fail "$action must stop if archive preparation fails"
|
||||
fi
|
||||
cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes"
|
||||
if [[ $action == enable ]]; then
|
||||
[[ ! -e $drop_in ]] || fail "enable preflight failure must not create the boot setting"
|
||||
else
|
||||
[[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting"
|
||||
fi
|
||||
(usb_authorization_main "$action")
|
||||
if [[ $action == enable ]]; then
|
||||
[[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy"
|
||||
else
|
||||
[[ ! -e $drop_in ]] || fail "successful disable clears the boot policy"
|
||||
fi
|
||||
pass "USB $action main path prepares archives before changing boot policy"
|
||||
done
|
||||
fi
|
||||
@@ -1,183 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
usb_authorization_secure_boot_enabled() { return 0; }
|
||||
usb_authorization_limine_settings() { printf 'x64 %s\n' "${ENROLLMENT:-yes}"; }
|
||||
|
||||
# Real PE enrollment always runs. An optional isolated sbctl configuration also
|
||||
# exercises real signatures; default runs track signed content without host keys.
|
||||
sbctl() {
|
||||
if [[ $1 == sign && ${SIGN_FAIL:-0} == 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
||||
elif [[ $1 == sign ]]; then
|
||||
sha256sum "${!#}" | cut -d ' ' -f1 >>"$scratch/signed"
|
||||
elif [[ $1 == verify ]]; then
|
||||
local digest signed=0
|
||||
digest=$(sha256sum "${!#}")
|
||||
if grep -qx "${digest%% *}" "$scratch/signed"; then signed=1; fi
|
||||
jq -n --arg file "${!#}" --argjson signed "$signed" '[{file_name: $file, is_signed: $signed}]'
|
||||
fi
|
||||
}
|
||||
|
||||
fixture() {
|
||||
case_dir=$(mktemp -d "$scratch/case.XXXXXXXX")
|
||||
boot_path="$case_dir/boot"
|
||||
cache="$case_dir/cache"
|
||||
limine_conf="$boot_path/limine.conf"
|
||||
limine_defaults="$case_dir/defaults"
|
||||
drop_in="$case_dir/dropins/usb-authorization.conf"
|
||||
image="$boot_path/EFI/limine/limine_x64.efi"
|
||||
mkdir -p "${image%/*}" "$cache/lss" "${drop_in%/*}"
|
||||
printf 'initial menu\n' >"$limine_conf"
|
||||
printf 'initial kernel\n' >"$boot_path/kernel.efi"
|
||||
printf 'initial snapshot\n' >"$cache/lss/snapshots.json"
|
||||
printf 'initial defaults\n' >"$limine_defaults"
|
||||
: >"$scratch/signed"
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
if [[ ${ENROLLMENT:-yes} == yes ]]; then
|
||||
digest=$(b2sum "$limine_conf")
|
||||
limine enroll-config "$image" "${digest%% *}" --quiet
|
||||
fi
|
||||
sbctl sign "$image" >/dev/null
|
||||
cp "$image" "$case_dir/original.efi"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
}
|
||||
|
||||
# Matches the installed wrapper: restore unsigned binary, enroll, attempt sign,
|
||||
# then mask failure with a successful final sync. No real ESP is ever touched.
|
||||
limine-enroll-config() {
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
if [[ ${ENROLLMENT:-yes} == yes && ${SKIP_ENROLLMENT:-0} != 1 ]]; then
|
||||
local digest
|
||||
digest=$(b2sum "$limine_conf")
|
||||
limine enroll-config "$image" "${digest%% *}" --quiet || return 1
|
||||
fi
|
||||
sbctl sign "$image" || true
|
||||
sync -f "$image"
|
||||
}
|
||||
|
||||
# Relocate the outer real flock into scratch. The runner stands in for the
|
||||
# namespace child and deliberately mutates the same files as rebuild hooks.
|
||||
transaction=$(declare -f usb_authorization_boot_transaction)
|
||||
transaction=${transaction//\/run\/lock\/boot-partition.lock/$scratch\/boot.lock}
|
||||
eval "$transaction"
|
||||
usb_authorization_run_transaction() {
|
||||
if flock --nonblock "$scratch/boot.lock" true; then
|
||||
fail "the real boot lock must cover the entire transaction"
|
||||
fi
|
||||
printf 'changed menu\n' >"$limine_conf"
|
||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
||||
printf 'changed metadata\n' >"$cache/lss/snapshots.json"
|
||||
printf 'new cached backup\n' >"$cache/lss/snapshots.json.old"
|
||||
mkdir -p "$boot_path/$(</etc/machine-id)/limine_history"
|
||||
printf 'new archived backup\n' >"$boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old"
|
||||
printf 'changed defaults\n' >"$limine_defaults"
|
||||
printf '%s\n' "$setting" >"$drop_in"
|
||||
if [[ ${EARLY_FAILURE:-0} == 1 ]]; then
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
return 1
|
||||
fi
|
||||
if [[ ${UNCHECKED_HOOK:-0} == 1 ]]; then
|
||||
limine-enroll-config
|
||||
else
|
||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in SIGN_FAIL SKIP_ENROLLMENT EARLY_FAILURE; do
|
||||
fixture
|
||||
if (export "$failure=1"; usb_authorization_boot_transaction enable "$cache") >"$case_dir/failure.log" 2>&1; then
|
||||
fail "$failure must reject the boot transition"
|
||||
fi
|
||||
cmp "$image" "$case_dir/original.efi" || fail "$failure must restore the signed bootloader byte-for-byte without signing"
|
||||
[[ $(<"$limine_conf") == 'initial menu' ]] || fail "$failure must restore matching menu"
|
||||
[[ $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "$failure must restore images referenced by that menu"
|
||||
[[ $(<"$cache/lss/snapshots.json") == 'initial snapshot' ]] || fail "$failure must restore cached snapshot metadata"
|
||||
[[ ! -e $cache/lss/snapshots.json.old && ! -e $boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old ]] || fail "$failure must remove newly created recovery metadata"
|
||||
[[ $(<"$limine_defaults") == 'initial defaults' && ! -e $drop_in ]] || fail "$failure must restore settings including an absent drop-in"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
done
|
||||
pass "USB boot transitions restore signed Limine, matching menu, images, metadata and settings on failure"
|
||||
|
||||
fixture
|
||||
printf 'original opt-in\n' >"$drop_in"
|
||||
if (SIGN_FAIL=1 UNCHECKED_HOOK=1 usb_authorization_boot_transaction disable "$cache") >"$case_dir/failure.log" 2>&1; then
|
||||
fail "final verification must reject false-success rebuild hooks"
|
||||
fi
|
||||
cmp "$image" "$case_dir/original.efi" || fail "false-success hooks must restore original signed Limine"
|
||||
[[ $(<"$drop_in") == 'original opt-in' ]] || fail "failed disable must preserve an existing drop-in"
|
||||
pass "USB boot transitions independently verify enrollment after external hooks"
|
||||
|
||||
for enrollment in yes no; do
|
||||
export ENROLLMENT="$enrollment"
|
||||
fixture
|
||||
usb_authorization_boot_transaction enable "$cache"
|
||||
[[ $(<"$limine_conf") == 'changed menu' && -e $drop_in ]] || fail "successful transition must commit"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
done
|
||||
pass "USB boot transitions commit verified signing with enrollment enabled or disabled"
|
||||
|
||||
# Disposable Lab only: exercise the actual namespace runner and both lock
|
||||
# inodes, with the installed enrollment/restore functions and real signatures.
|
||||
if [[ ${USB_TEST_NAMESPACE:-0} == 1 ]]; then
|
||||
(( EUID == 0 )) || fail "namespace tests require root in the disposable guest"
|
||||
[[ -n ${USB_TEST_SBCTL_CONFIG:-} ]] || fail "namespace tests require isolated signing keys"
|
||||
export ENROLLMENT=yes
|
||||
for outcome in failure success; do
|
||||
fixture
|
||||
cp /usr/share/limine/BOOTX64.EFI "$case_dir/limine_x64.efi"
|
||||
tar -cf "$case_dir/limine.bak" -C "$case_dir" limine_x64.efi
|
||||
lock_inode=$(stat -Lc '%d:%i' /run/lock/boot-partition.lock)
|
||||
# Source the shipped functions in both the parent and namespace child.
|
||||
sed '/^if \[\[ ${BASH_SOURCE\[0\]} == "\$0" \]\]; then/,$d' "$ROOT/bin/omarchy-usb-authorization-boot" >"$case_dir/helper"
|
||||
for variable in scratch case_dir boot_path cache limine_conf limine_defaults drop_in image lock_inode; do
|
||||
printf '%s=%q\n' "$variable" "${!variable}" >>"$case_dir/helper"
|
||||
done
|
||||
declare -f sbctl usb_authorization_limine_settings usb_authorization_secure_boot_enabled >>"$case_dir/helper"
|
||||
cat >>"$case_dir/helper" <<'SCRIPT'
|
||||
usb_authorization_main() {
|
||||
[[ $(stat -Lc '%d:%i' /run/lock/boot-partition.lock) != "$lock_inode" ]] || return 1
|
||||
exec 9>/run/lock/boot-partition.lock
|
||||
flock --nonblock 9 || return 1
|
||||
printf 'changed menu\n' >"$limine_conf"
|
||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
||||
source /usr/lib/limine/limine-common-functions
|
||||
LIMINE_DIR_PATH="${image%/*}"
|
||||
LIMINE_EFI_FILE=limine_x64.efi
|
||||
BINARY_TARGET_PATH="$image"
|
||||
BINARY_BACKUP_PATH="$case_dir/limine.bak"
|
||||
LIMINE_CONFIG_PATH="$limine_conf"
|
||||
ENABLE_ENROLL_LIMINE_CONFIG=yes
|
||||
is_sb_installed() { return 0; }
|
||||
is_uefi() { return 0; }
|
||||
limine-enroll-config() { enroll_config; }
|
||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
||||
}
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
usb_authorization_boot_transaction "$1" "$cache"
|
||||
fi
|
||||
SCRIPT
|
||||
if [[ $outcome == failure ]]; then
|
||||
if SIGN_FAIL=1 bash "$case_dir/helper" enable >"$case_dir/namespace.log" 2>&1; then
|
||||
fail "actual namespace must reject installed wrapper's false success"
|
||||
fi
|
||||
grep -Fq 'Failed to sign' "$case_dir/namespace.log" || fail "failure must reach the installed enrollment function"
|
||||
cmp "$image" "$case_dir/original.efi" || fail "namespace rollback restores original signed bootloader"
|
||||
[[ $(<"$limine_conf") == 'initial menu' && $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "namespace rollback restores matching boot files"
|
||||
else
|
||||
bash "$case_dir/helper" disable >"$case_dir/namespace.log" 2>&1
|
||||
[[ $(<"$limine_conf") == 'changed menu' ]] || fail "namespace success commits"
|
||||
fi
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
flock --nonblock /run/lock/boot-partition.lock true || fail "outer lock must be released"
|
||||
done
|
||||
pass "USB real namespace isolates nested locks and recovers installed Limine signing failure"
|
||||
fi
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
# Exercise reset staging in a temporary root with the real offline systemctl.
|
||||
# Never invoke the reset command's main function or any actual disk operations.
|
||||
python - "$ROOT" <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
repo = Path(sys.argv[1])
|
||||
source = (repo / 'bin/omarchy-system-factory-reset').read_text()
|
||||
functions = []
|
||||
for name in ['install_provisioning_units', 'scrub_factory_accounts', 'sanitize_factory_baseline', 'stage_full_reset']:
|
||||
match = re.search(r'^' + name + r'\(\) \{\n.*?^\}', source, re.M | re.S)
|
||||
assert match, name
|
||||
functions.append(match.group())
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='omarchy-usb-reset-') as tmp:
|
||||
scratch = Path(tmp)
|
||||
for scenario in ['enabled', 'disabled', 'absent', 'disable-failure']:
|
||||
root = scratch / scenario
|
||||
top = root / 'top'
|
||||
factory = top / '@factory'
|
||||
for rel in ['etc/systemd/system/basic.target.wants', 'usr/lib/systemd/system',
|
||||
'etc/usbguard', 'usr/bin', 'usr/share/omarchy/install/provisioning']:
|
||||
(factory / rel).mkdir(parents=True, exist_ok=True)
|
||||
(top / '@').mkdir()
|
||||
(top / '@' / 'original-root').touch()
|
||||
(factory / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/bash\n')
|
||||
(factory / 'etc/machine-id').write_text('old-machine-id\n')
|
||||
policy = 'allow id 1234:0001 name "Original owner keyboard" hash "old-owner"\n'
|
||||
(factory / 'etc/usbguard/rules.conf').write_text(policy)
|
||||
unit = factory / 'usr/lib/systemd/system/usbguard.service'
|
||||
enabled_path = Path('etc/systemd/system/basic.target.wants/usbguard.service')
|
||||
if scenario != 'absent':
|
||||
unit.write_text('[Unit]\nDescription=Fixture USBGuard\n[Service]\nExecStart=/bin/true\n[Install]\nWantedBy=basic.target\n')
|
||||
if scenario in ['enabled', 'disable-failure']:
|
||||
(factory / enabled_path).symlink_to('/usr/lib/systemd/system/usbguard.service')
|
||||
owner = factory / 'usr/bin/omarchy-provision-owner'
|
||||
owner.touch()
|
||||
owner.chmod(0o755)
|
||||
for name in ['omarchy-provision-owner.service', 'omarchy-system-factory-reset-finish.service']:
|
||||
shutil.copyfile(repo / 'install/provisioning' / name,
|
||||
factory / 'usr/share/omarchy/install/provisioning' / name)
|
||||
harness = '''#!/bin/bash
|
||||
set -euo pipefail
|
||||
TOP_MNT="$1/top"
|
||||
NEXT_NAME=@next
|
||||
PROVISIONING_DIR=/var/lib/omarchy/provisioning
|
||||
LOG_FILE="$1/log"
|
||||
log() { :; }
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
btrfs() {
|
||||
if [[ $1 == subvolume && $2 == snapshot ]]; then
|
||||
cp -a "$3" "$4"
|
||||
elif [[ $1 == property ]]; then
|
||||
:
|
||||
else
|
||||
return 90
|
||||
fi
|
||||
}
|
||||
# Account cleanup has its own real-tool suite; keep this fixture focused on USB.
|
||||
usermod() {
|
||||
[[ $1 == --root && ( $2 == "$TOP_MNT/$NEXT_NAME" || $2 == "$TOP_MNT/@factory" ) &&
|
||||
$3 == --password && $4 == '!' && $5 == root ]]
|
||||
}
|
||||
systemd-id128() { echo new-machine-id; }
|
||||
encrypted_install() { return 1; }
|
||||
rebuild_next_boot() { :; }
|
||||
sync() { :; }
|
||||
systemctl() {
|
||||
# All service changes must address the staged root, never the live machine.
|
||||
[[ $1 == "--root=$TOP_MNT/$NEXT_NAME" && $2 == disable && $3 == usbguard.service ]] || return 91
|
||||
[[ $2 != "${FAIL_OPERATION:-}" ]] || return 92
|
||||
command systemctl "$@"
|
||||
}
|
||||
''' + '\n\n'.join(functions) + '\nstage_full_reset\n'
|
||||
runner = root / 'run'
|
||||
runner.write_text(harness)
|
||||
env = os.environ.copy()
|
||||
env['FAIL_OPERATION'] = 'disable' if scenario == 'disable-failure' else ''
|
||||
result = subprocess.run(['bash', str(runner), str(root)], env=env, capture_output=True, text=True)
|
||||
if scenario == 'disable-failure':
|
||||
assert result.returncode != 0, 'failed disable must abort reset staging'
|
||||
assert (top / '@' / 'original-root').exists(), 'failure must not activate the reset root'
|
||||
assert 'could not disable USBGuard' in result.stderr
|
||||
else:
|
||||
assert result.returncode == 0, result.stderr
|
||||
staged = top / '@'
|
||||
assert not (staged / enabled_path).is_symlink(), 'reset must not start USBGuard before enrollment'
|
||||
assert (staged / 'var/lib/omarchy/provisioning/pending').exists()
|
||||
assert (staged / 'etc/usbguard/rules.conf').read_text() == policy
|
||||
if scenario == 'enabled':
|
||||
assert (factory / enabled_path).is_symlink(), 'only the staged clone is disabled'
|
||||
# The owner-enrollment helper enables the daemon after policy and
|
||||
# ACL setup. Confirm an ordinary enable can reverse this disable.
|
||||
subprocess.run(['systemctl', '--root=' + str(staged), 'enable', 'usbguard.service'],
|
||||
check=True, capture_output=True, text=True)
|
||||
assert (staged / enabled_path).is_symlink()
|
||||
print('ok - factory USB enrollment staging: ' + scenario)
|
||||
PY
|
||||
Reference in new issue
Block a user