Require approval for new Thunderbolt accessories
This commit is contained in:
1 parent
27bbbb2fe9
commit
60d6b255da
35 files changed
+2503
-2
No files matched your search
@@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience:
|
||||
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
|
||||
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
|
||||
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
|
||||
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument.
|
||||
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
|
||||
|
||||
# Command Naming
|
||||
|
||||
@@ -1040,6 +1040,9 @@ run_provisioning() {
|
||||
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"
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/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."
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/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."
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:hidden=true
|
||||
# omarchy:summary=Provide internal helpers for command-scoped sudo authentication
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
echo "omarchy-security-functions is an internal function library." >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
omarchy_security_require_privileged_bash_startup() {
|
||||
[[ $- == *p* ]] || return 1
|
||||
/usr/bin/env -i /usr/bin/bash -p -c '
|
||||
mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1
|
||||
executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1
|
||||
[[ $executable == "/usr/bin/bash" &&
|
||||
( ${argv[0]:-} == "/bin/bash" || ${argv[0]:-} == "/usr/bin/bash" ) &&
|
||||
${argv[1]:-} == "-p" ]]
|
||||
' omarchy-bash-startup "$$"
|
||||
}
|
||||
|
||||
omarchy_security_sanitize_bash_environment() {
|
||||
local script=$1
|
||||
shift
|
||||
local entry name environment_fd environment_pid
|
||||
local -a unsets=()
|
||||
|
||||
# Read the raw environment: privileged Bash ignores exported functions, but
|
||||
# leaves their records for ordinary child interpreters to import later.
|
||||
exec {environment_fd}< <(/usr/bin/env -0)
|
||||
environment_pid=$!
|
||||
while IFS= read -r -d '' entry <&"$environment_fd"; do
|
||||
name=${entry%%=*}
|
||||
case "$name" in
|
||||
BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%)
|
||||
unsets+=(-u "$name")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec {environment_fd}<&-
|
||||
wait "$environment_pid" || return 1
|
||||
if (( ${#unsets[@]} > 0 )); then
|
||||
exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
omarchy_security_require_source_root() {
|
||||
local command_source command_name=${1##*/}
|
||||
command_source=$(/usr/bin/readlink -e -- "$1") || return 1
|
||||
|
||||
# A runtime root selects the code used by this invocation. Accept the
|
||||
# canonical checkout containing the entrypoint or the package's bin links.
|
||||
if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] ||
|
||||
! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] ||
|
||||
[[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then
|
||||
echo "OMARCHY_PATH does not match this Omarchy command." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
omarchy_security_sudo_supports_no_update() {
|
||||
local help
|
||||
help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1
|
||||
/usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help"
|
||||
}
|
||||
|
||||
omarchy_security_revoke_sudo_timestamp() {
|
||||
/usr/bin/sudo -k
|
||||
}
|
||||
|
||||
omarchy_security_exit_with_revoked_sudo() {
|
||||
local status=$1
|
||||
local message=${2:-Could not invalidate cached sudo authorization.}
|
||||
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! omarchy_security_revoke_sudo_timestamp; then
|
||||
echo "$message" >&2
|
||||
(( status != 0 )) || status=1
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
omarchy_security_install_signal_exit_traps() {
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
}
|
||||
|
||||
omarchy_security_install_sudo_cleanup_traps() {
|
||||
OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.}
|
||||
trap omarchy_security_run_sudo_cleanup_trap EXIT
|
||||
omarchy_security_install_signal_exit_traps
|
||||
}
|
||||
|
||||
omarchy_security_enable_no_update_sudo() {
|
||||
local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update"
|
||||
if ! omarchy_security_sudo_supports_no_update; then
|
||||
echo "This sudo does not support --no-update; refusing mixed-trust work." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then
|
||||
echo "The command-scoped sudo wrapper is missing." >&2
|
||||
return 1
|
||||
fi
|
||||
PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin"
|
||||
OMARCHY_SUDO_NO_UPDATE=1
|
||||
export PATH OMARCHY_SUDO_NO_UPDATE
|
||||
}
|
||||
|
||||
omarchy_security_run_sudo_cleanup_trap() {
|
||||
local status=$?
|
||||
|
||||
omarchy_security_exit_with_revoked_sudo "$status" \
|
||||
"${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}"
|
||||
}
|
||||
|
||||
omarchy_security_assert_root_directory() {
|
||||
local path=$1 expected_mode=$2 canonical owner actual_mode
|
||||
|
||||
[[ $path == /* && -d $path && ! -L $path ]] || return 1
|
||||
canonical=$(/usr/bin/realpath -e -- "$path") || return 1
|
||||
[[ $canonical == "$path" ]] || return 1
|
||||
read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1
|
||||
[[ $owner == "0" && $actual_mode == "$expected_mode" ]]
|
||||
}
|
||||
|
||||
omarchy_security_prepare_private_root_directory() {
|
||||
local path=$1 parent=$2
|
||||
|
||||
omarchy_security_assert_root_directory "$parent" 755 || return 1
|
||||
if [[ -e $path || -L $path ]]; then
|
||||
omarchy_security_assert_root_directory "$path" 700
|
||||
else
|
||||
/usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1
|
||||
omarchy_security_assert_root_directory "$path" 700
|
||||
fi
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#!/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
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/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
|
||||
@@ -320,6 +320,12 @@ 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
|
||||
@@ -368,6 +374,13 @@ 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
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/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 "$@"
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#!/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"
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/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
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/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
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/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"
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/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
|
||||
@@ -179,6 +179,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"},
|
||||
@@ -298,6 +300,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"},
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,16 @@
|
||||
[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
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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. 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.
|
||||
@@ -0,0 +1,17 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
[Service]
|
||||
# Runs before Bolt enumerates hardware, including D-Bus/udev activation.
|
||||
ExecStartPre=/usr/bin/omarchy-thunderbolt-authorization-admin guard
|
||||
@@ -0,0 +1,26 @@
|
||||
[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
|
||||
@@ -10,3 +10,4 @@ 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"
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -0,0 +1,319 @@
|
||||
# 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
|
||||
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; }
|
||||
|
||||
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
|
||||
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
|
||||
(
|
||||
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"
|
||||
sleep 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"
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
# 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
|
||||
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
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
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=''
|
||||
fi
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
if (( failures >= 3 )) && [[ $warned != "unavailable" ]] && tb_attention; then
|
||||
warned=unavailable
|
||||
fi
|
||||
fi
|
||||
sleep 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'
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
# 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
|
||||
}
|
||||
@@ -20,6 +20,7 @@ systemctl --user enable --now \
|
||||
omarchy-migrate-notify.service \
|
||||
omarchy-fcitx5.service \
|
||||
omarchy-crash-watch.service \
|
||||
omarchy-usb-authorization.service
|
||||
omarchy-usb-authorization.service \
|
||||
omarchy-thunderbolt-authorization.service
|
||||
|
||||
omarchy-hook-install theme-set /usr/share/owe/10-owe-sync
|
||||
@@ -48,3 +48,23 @@ 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
echo "Require approval for new Thunderbolt accessories"
|
||||
omarchy-setup-security-thunderbolt-authorization --quiet
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,185 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,640 @@
|
||||
#!/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
|
||||
sleep() { 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; }
|
||||
sleep() { 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_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
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/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
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/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
|
||||
Reference in new issue
Block a user