Merge pull request #11874 from acrogenesis/feature/usb-device-authorization
Require approval for new USB and Thunderbolt devices by default. Reported-by: Jurre van Bergen me@jurrevanbergen.nl
This commit is contained in:
57 files changed
+5775
-2
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,57 @@
|
||||
#include <usbguard/Rule.hpp>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
static std::string read(const char* path) {
|
||||
std::ifstream file(path);
|
||||
std::string text;
|
||||
std::getline(file, text);
|
||||
if (text.empty()) throw std::runtime_error("empty rule");
|
||||
return text;
|
||||
}
|
||||
|
||||
static std::string replace(std::string text, const std::string& from, const std::string& to) {
|
||||
const auto index = text.find(from);
|
||||
if (index == std::string::npos) throw std::runtime_error("missing attribute");
|
||||
text.replace(index, from.size(), to);
|
||||
return text;
|
||||
}
|
||||
|
||||
static void check(const char* name, const usbguard::Rule& policy, const usbguard::Rule& device, bool expected) {
|
||||
if (policy.appliesTo(device) != expected) throw std::runtime_error(name);
|
||||
std::cout << "ok - native USBGuard: " << name << '\n';
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) return 2;
|
||||
// Test the policy produced by the real Bash implementation, not a C++ copy.
|
||||
const auto policy = usbguard::Rule::fromString(read(argv[1]));
|
||||
const auto text = read(argv[2]);
|
||||
const auto device = usbguard::Rule::fromString(text);
|
||||
check("original connection", policy, device, true);
|
||||
auto moved = device;
|
||||
moved.setViaPort("9-9.9");
|
||||
check("another port", policy, moved, true);
|
||||
moved.setParentHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
check("another hub", policy, moved, true);
|
||||
auto reloaded = usbguard::Rule::fromString(policy.toString());
|
||||
check("serialized policy reload on new hub", reloaded, moved, true);
|
||||
check("reconnect on original port", reloaded, device, true);
|
||||
auto different = moved;
|
||||
different.setHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
check("different hash denied", policy, different, false);
|
||||
different = moved;
|
||||
different.setSerial("different");
|
||||
check("different serial denied", policy, different, false);
|
||||
different = moved;
|
||||
different.setName("different");
|
||||
check("different name denied", policy, different, false);
|
||||
check("different product denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046d:c53b")), false);
|
||||
check("different vendor denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046e:c53a")), false);
|
||||
check("different interface denied", policy, usbguard::Rule::fromString(replace(text, "03:00:00", "08:06:50")), false);
|
||||
check("different connection type denied", policy, usbguard::Rule::fromString(replace(text, "with-connect-type \"unknown\"", "with-connect-type \"hotplug\"")), false);
|
||||
// Existing unmarked/manual rules remain location-bound.
|
||||
check("legacy rule still restricts ports and hubs", device, moved, false);
|
||||
}
|
||||
@@ -343,6 +343,13 @@ assert(
|
||||
defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'),
|
||||
'menu places Passwordless Sudo under Setup > Security'
|
||||
)
|
||||
assert(
|
||||
defaultById['setup.security.usb-authorization'].action.includes('omarchy-setup-security-usb-authorization')
|
||||
&& defaultById['remove.security.usb-authorization'].action.includes('omarchy-remove-security-usb-authorization')
|
||||
&& defaultById['setup.security.usb-authorization-boot'].action.includes('omarchy-setup-security-usb-authorization --boot')
|
||||
&& defaultById['remove.security.usb-authorization-boot'].action.includes('omarchy-remove-security-usb-authorization --boot-only'),
|
||||
'menu can enable and remove USB device authorization under Security'
|
||||
)
|
||||
assert(
|
||||
!defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'],
|
||||
'menu removes the relocated toggles from Trigger > Toggle'
|
||||
|
||||
+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
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/install/helpers/usb-authorization-policy.sh"
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
identity='id 046d:c53a serial "" name "USB Receiver" hash "KCUFt1MumW4Pfs/8YXWzGQB6Hsnm8qkDqFVjfY0NIBY="'
|
||||
interfaces='with-interface { 03:01:01 03:01:02 03:00:00 } with-connect-type "unknown"'
|
||||
location='parent-hash "m7yTNWlczwBbYn+uRP6TRsY5AceKmAZ0Et6mgy58+/o=" via-port "3-1.1.2.1"'
|
||||
device="allow $identity $location $interfaces"
|
||||
portable=$(usb_authorization_portable_rule "$device")
|
||||
[[ $portable == "allow $identity $interfaces"' label "omarchy-usb-authorization-v1"' ]] || fail "only device location is removed"
|
||||
[[ $(usb_authorization_portable_rule "${device/#allow/block}") == "$portable" ]] || fail "blocked approvals use the same portable identity"
|
||||
pass "portable USB rules retain every device identity attribute"
|
||||
|
||||
# A canonical escaped name cannot cause its contents to become attributes.
|
||||
name='name "Receiver \" via-port \"fake\" parent-hash \"fake\" $(touch /tmp/usb-injection)"'
|
||||
hostile="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $location $interfaces"
|
||||
expected="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $interfaces"' label "omarchy-usb-authorization-v1"'
|
||||
[[ $(usb_authorization_portable_rule "$hostile") == "$expected" ]] || fail "quoted attribute text must remain data"
|
||||
if usb_authorization_portable_rule 'allow id 046d:c53a name "unterminated' >/dev/null; then fail "reject unmatched quote"; fi
|
||||
if usb_authorization_portable_rule 'allow id 046d:c53a via-port' >/dev/null; then fail "reject missing location value"; fi
|
||||
pass "USB portable parsing preserves escaped and hostile descriptor text"
|
||||
|
||||
# Exercise the actual root entrypoint startup boundary without root work.
|
||||
sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \
|
||||
-e 's|source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh|exit 126|' \
|
||||
"$ROOT/bin/omarchy-usb-authorization-approve" >"$scratch/entry"
|
||||
chmod 755 "$scratch/entry"
|
||||
printf 'set -p\n' >"$scratch/decoy"
|
||||
if BASH_ENV="$scratch/decoy" /usr/bin/bash "$scratch/entry" -p; then fail "ordinary Bash with decoy -p is rejected"; fi
|
||||
printf 'touch "%s"\n' "$scratch/injected" >"$scratch/startup"
|
||||
if BASH_ENV="$scratch/startup" "$scratch/entry" >/dev/null 2>&1; then fail "startup fixture boundary"; fi
|
||||
if /usr/bin/env 'BASH_FUNC_source%%=() { touch "$USB_TEST_INJECTED"; }' USB_TEST_INJECTED="$scratch/injected" "$scratch/entry" >/dev/null 2>&1; then fail "exported function fixture boundary"; fi
|
||||
[[ ! -e $scratch/injected ]] || fail "privileged entrypoint executes caller startup code"
|
||||
pass "USB privileged approval rejects unsafe Bash startup"
|
||||
|
||||
if ! command -v g++ >/dev/null || [[ ! -f /usr/include/usbguard/Rule.hpp ]]; then
|
||||
skip "native USBGuard matcher requires g++ and USBGuard development headers"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$device" >"$scratch/device"
|
||||
printf '%s\n' "$portable" >"$scratch/policy"
|
||||
g++ -std=c++17 "$ROOT/test/shell.d/fixtures/usb-authorization/match.cpp" -lusbguard -o "$scratch/match"
|
||||
"$scratch/match" "$scratch/policy" "$scratch/device"
|
||||
Executable
+967
@@ -0,0 +1,967 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
stub_bin="$scratch/bin"
|
||||
home="$scratch/home"
|
||||
rules="$scratch/etc/usbguard/rules.conf"
|
||||
daemon_config="$scratch/etc/usbguard/usbguard-daemon.conf"
|
||||
calls="$scratch/calls"
|
||||
|
||||
mkdir -p "$stub_bin" "$home" "${rules%/*}"
|
||||
: >"$rules"
|
||||
: >"$calls"
|
||||
|
||||
cat >"$daemon_config" <<'CONF'
|
||||
ImplicitPolicyTarget=block
|
||||
PresentDevicePolicy=apply-policy
|
||||
InsertedDevicePolicy=apply-policy
|
||||
AuthorizedDefault=none
|
||||
RestoreControllerDeviceState=false
|
||||
RuleFile=/etc/usbguard/rules.conf
|
||||
CONF
|
||||
|
||||
cat >"$stub_bin/omarchy-pkg-missing" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-pkg-present" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-pkg-add" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'pkg-add <%s>\n' "$*" >>"$CALLS"
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-pkg-drop" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'pkg-drop <%s>\n' "$*" >>"$CALLS"
|
||||
STUB
|
||||
cat >"$stub_bin/install" <<'STUB'
|
||||
#!/bin/bash
|
||||
if [[ ${!#} == /usr/share/polkit-1/actions/org.omarchy.usb.policy ]]; then exit 0; fi
|
||||
if [[ $1 == -Dm600 && $2 == -o && $3 == root && $4 == -g && $5 == root ]]; then
|
||||
exec /usr/bin/install -Dm600 "$6" "$7"
|
||||
fi
|
||||
exec /usr/bin/install "$@"
|
||||
STUB
|
||||
cat >"$stub_bin/systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'systemctl <%s>\n' "$*" >>"$CALLS"
|
||||
if [[ $* == "show usbguard.service --property=InvocationID --value" ]]; then
|
||||
printf '%s\n' "${TEST_DAEMON_INVOCATION:-11111111111111111111111111111111}"
|
||||
elif [[ $* == "--user show-environment" ]]; then
|
||||
echo "OMARCHY_PATH=${TEST_SESSION_ROOT:-$OMARCHY_PATH}"
|
||||
elif [[ $* == "--user restart omarchy-usb-authorization.service" ]]; then
|
||||
[[ ${TEST_WATCHER_FAIL:-0} == 0 ]]
|
||||
elif [[ $* == "restart usbguard.service" ]]; then
|
||||
touch "$TEST_GUARD_ACTIVE"
|
||||
elif [[ $* == "disable --now usbguard.service" ]]; then
|
||||
[[ ${TEST_STOP_FAIL:-0} == 0 ]] || exit 1
|
||||
[[ ${TEST_STOP_LIES:-0} == 1 ]] || rm -f "$TEST_GUARD_ACTIVE"
|
||||
elif [[ $* == "is-active --quiet usbguard.service" ]]; then
|
||||
[[ -f $TEST_GUARD_ACTIVE ]]
|
||||
elif [[ $* == "is-enabled --quiet usbguard.service" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
STUB
|
||||
cat >"$stub_bin/usbguard" <<'STUB'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
generate-policy)
|
||||
echo 'usbguard <generate-policy>' >>"$CALLS"
|
||||
[[ ${GENERATE_FAIL:-0} == 0 ]] || exit 1
|
||||
if [[ ${GENERATE_EMPTY:-0} == 0 ]]; then
|
||||
printf '%s\n' "${GENERATED_RULE:-allow id 1d6b:0002 name \"Linux Foundation root hub\" hash \"root\"}"
|
||||
fi
|
||||
;;
|
||||
list-devices)
|
||||
if [[ ${2:-} == "--allowed" ]]; then
|
||||
[[ ${APPROVAL_QUERY_FAIL:-0} == 0 ]] || exit 1
|
||||
if [[ -f $TEST_ALLOWED_RULE ]]; then
|
||||
printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")"
|
||||
fi
|
||||
elif [[ ${2:-} == "--blocked" ]]; then
|
||||
[[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1
|
||||
[[ ! -f $TEST_ALLOWED_RULE ]] || exit 0
|
||||
if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi
|
||||
if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then
|
||||
rule="${BLOCKED_DEVICE_RULE:?}"
|
||||
if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then
|
||||
rule=$(<"$BLOCKED_DEVICE_RULE_FILE")
|
||||
fi
|
||||
printf '17: %s\n' "$rule"
|
||||
fi
|
||||
else
|
||||
[[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1
|
||||
if [[ -f $TEST_ALLOWED_RULE ]]; then
|
||||
[[ ${APPROVAL_QUERY_FAIL:-0} == 0 ]] || exit 1
|
||||
printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")"
|
||||
elif [[ -n ${BLOCKED_DEVICE_RULE:-} && ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then
|
||||
rule="$BLOCKED_DEVICE_RULE"
|
||||
if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then
|
||||
rule=$(<"$BLOCKED_DEVICE_RULE_FILE")
|
||||
fi
|
||||
printf '17: %s\n' "$rule"
|
||||
fi
|
||||
echo '4: allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"'
|
||||
echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"'
|
||||
fi
|
||||
;;
|
||||
append-rule)
|
||||
printf 'usbguard <append-rule> <%s>\n' "$2" >>"$CALLS"
|
||||
[[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1
|
||||
if [[ ${APPROVAL_NO_POLICY:-0} == 0 ]]; then printf '%s\n' "$2" >"$TEST_SAVED_RULE"; fi
|
||||
echo 40
|
||||
;;
|
||||
list-rules)
|
||||
if [[ -f $TEST_ALLOWED_RULE && ${APPROVAL_POLICY_QUERY_FAIL:-0} == 1 ]]; then exit 1; fi
|
||||
if [[ -n ${TEST_POLICY_LIST:-} ]]; then cat "$TEST_POLICY_LIST"; fi
|
||||
if [[ -f $TEST_SAVED_RULE ]]; then
|
||||
printf '40: %s\n' "$(<"$TEST_SAVED_RULE")"
|
||||
if [[ -f $TEST_ALLOWED_RULE ]]; then
|
||||
printf '\t17: %s\n' "$(<"$TEST_ALLOWED_RULE")"
|
||||
elif [[ -n ${BLOCKED_DEVICE_RULE:-} ]]; then
|
||||
printf '\t17: %s\n' "$BLOCKED_DEVICE_RULE"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf 'usbguard' >>"$CALLS"
|
||||
printf ' <%s>' "$@" >>"$CALLS"
|
||||
printf '\n' >>"$CALLS"
|
||||
if [[ $1 == "watch" && -n ${TEST_WATCH_ID_FILE:-} ]]; then
|
||||
printf '%s\n' "$OMARCHY_USB_AUTHORIZATION_WATCH_ID" >"$TEST_WATCH_ID_FILE"
|
||||
fi
|
||||
if [[ $1 == "allow-device" && ${!#} == block* ]]; then
|
||||
[[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1
|
||||
if [[ ${APPROVAL_SILENT_FAIL:-0} == 0 ]]; then
|
||||
rule="${!#}"
|
||||
printf '%s\n' "${APPROVAL_REPLACEMENT_RULE:-allow ${rule#block }}" >"$TEST_ALLOWED_RULE"
|
||||
if [[ $2 == "--permanent" && ${APPROVAL_NO_POLICY:-0} == 0 ]]; then
|
||||
cp "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
STUB
|
||||
cat >"$stub_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'sudo' >>"$CALLS"
|
||||
printf ' <%s>' "$@" >>"$CALLS"
|
||||
printf '\n' >>"$CALLS"
|
||||
if [[ $1 == awk && ${!#} == /etc/usbguard/usbguard-daemon.conf ]]; then
|
||||
args=("$@")
|
||||
args[-1]="$TEST_DAEMON_CONFIG"
|
||||
exec "${args[@]}"
|
||||
elif [[ $1 == test && $2 == -s && $3 == /etc/usbguard/rules.conf ]]; then
|
||||
exec /usr/bin/test -s "$TEST_RULES"
|
||||
elif [[ $1 == install && $2 == -Dm600 && $3 == -o && $4 == root && $5 == -g && $6 == root && $8 == /etc/usbguard/rules.conf ]]; then
|
||||
exec /usr/bin/install -Dm600 "$7" "$TEST_RULES"
|
||||
elif [[ $1 == install && ${!#} == /usr/share/polkit-1/actions/org.omarchy.usb.policy ]]; then
|
||||
exit 0
|
||||
elif [[ $1 == /usr/bin/omarchy-usb-authorization-approve ]]; then
|
||||
shift
|
||||
exec "$USB_APPROVE_FIXTURE" "$@"
|
||||
elif [[ $1 == omarchy-usb-authorization-restore-default ]]; then
|
||||
echo 1 >"$TEST_SYSFS/usb1/authorized_default"
|
||||
echo 1 >"$TEST_SYSFS/1-2/authorized"
|
||||
exit 0
|
||||
elif [[ $1 == omarchy-usb-authorization-boot ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exec "$@"
|
||||
STUB
|
||||
cat >"$stub_bin/gum" <<'STUB'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
style) exit 0 ;;
|
||||
choose)
|
||||
echo "gum choose" >>"$CALLS"
|
||||
[[ ${GUM_CANCEL:-0} == 0 ]] || exit 130
|
||||
if [[ -n ${GUM_REPLACE_REQUEST:-} ]]; then
|
||||
jq '.generation = "replacement-generation"' "$GUM_REPLACE_REQUEST" >"$GUM_REPLACE_REQUEST.new"
|
||||
mv "$GUM_REPLACE_REQUEST.new" "$GUM_REPLACE_REQUEST"
|
||||
fi
|
||||
if [[ -n ${GUM_REASSIGN_RULE:-} ]]; then
|
||||
printf '%s\n' "$GUM_REASSIGN_RULE" >"$BLOCKED_DEVICE_RULE_FILE"
|
||||
fi
|
||||
printf '%s\n' "${GUM_CHOICE:-Keep blocked}"
|
||||
;;
|
||||
confirm)
|
||||
if [[ ${GUM_REQUIRE_STDIN:-0} == 1 ]]; then
|
||||
read -r consent
|
||||
[[ $consent == yes ]] || exit 1
|
||||
fi
|
||||
[[ ${GUM_DECLINE:-0} == 0 ]]
|
||||
;;
|
||||
esac
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-notification-send" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'notification' >>"$CALLS"
|
||||
printf ' <%s>' "$@" >>"$CALLS"
|
||||
printf '\n' >>"$CALLS"
|
||||
if [[ -n ${NOTIFICATION_FAILURE_MARKER:-} && ! -e $NOTIFICATION_FAILURE_MARKER ]]; then
|
||||
touch "$NOTIFICATION_FAILURE_MARKER"
|
||||
exit 1
|
||||
fi
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-notification-wait" <<'STUB'
|
||||
#!/bin/bash
|
||||
if [[ -n ${NOTIFICATION_READY_FILE:-} ]]; then
|
||||
touch "$NOTIFICATION_READY_FILE.waited"
|
||||
[[ -e $NOTIFICATION_READY_FILE ]]
|
||||
fi
|
||||
STUB
|
||||
cat >"$stub_bin/pkexec" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ $1 == /usr/bin/omarchy-usb-authorization-approve ]] || exit 1
|
||||
shift
|
||||
exec "$USB_APPROVE_FIXTURE" "$@"
|
||||
STUB
|
||||
cat >"$stub_bin/usbguard-rule-parser" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
export HOME="$home"
|
||||
export USER=tester
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
export CALLS="$calls"
|
||||
export PATH="$stub_bin:$ROOT/bin:$PATH"
|
||||
export TEST_DAEMON_CONFIG="$daemon_config"
|
||||
export TEST_RULES="$rules"
|
||||
export TEST_ALLOWED_RULE="$scratch/allowed-rule"
|
||||
export TEST_SAVED_RULE="$scratch/saved-rule"
|
||||
export TEST_GUARD_ACTIVE="$scratch/guard-active"
|
||||
export USB_APPROVE_FIXTURE="$scratch/approve"
|
||||
export USB_POLICY_FIXTURE="$scratch/policy.sh"
|
||||
sed "s|/etc/usbguard/rules.conf|$TEST_SAVED_RULE|g" "$ROOT/install/helpers/usb-authorization-policy.sh" >"$USB_POLICY_FIXTURE"
|
||||
cat >"$USB_APPROVE_FIXTURE" <<'STUB'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$USB_POLICY_FIXTURE"
|
||||
# Boot inventory uses the same helper; permanent behavior is exercised below.
|
||||
if [[ $1 == 4 || $1 == 5 ]]; then
|
||||
printf 'boot-approve <%s> <%s>\n' "$1" "$2" >>"$CALLS"
|
||||
exit 0
|
||||
fi
|
||||
usb_authorization_permanent "$@"
|
||||
STUB
|
||||
chmod +x "$USB_APPROVE_FIXTURE"
|
||||
sed -e "s|/usr/bin/omarchy-usb-authorization-approve|$USB_APPROVE_FIXTURE|g" \
|
||||
-e "s|/usr/share/omarchy/install/helpers/usb-authorization-policy.sh|$USB_POLICY_FIXTURE|g" \
|
||||
"$ROOT/bin/omarchy-setup-security-usb-authorization" >"$scratch/setup"
|
||||
chmod +x "$scratch/setup"
|
||||
|
||||
if "$ROOT/bin/omarchy-usb-authorization-restore-default" >/dev/null 2>&1; then
|
||||
fail "the fixed-path USB authorization restore helper requires root"
|
||||
fi
|
||||
if "$ROOT/bin/omarchy-usb-authorization-boot" enable >/dev/null 2>&1; then
|
||||
fail "the fixed-path boot authorization helper requires root"
|
||||
fi
|
||||
! grep -q 'OMARCHY_USB_AUTHORIZATION_.*ROOT\|OMARCHY_USB_AUTHORIZATION_.*FILE\|OMARCHY_USB_AUTHORIZATION_.*CONFIG' \
|
||||
"$ROOT/bin/omarchy-setup-security-usb-authorization" \
|
||||
"$ROOT/bin/omarchy-remove-security-usb-authorization" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-boot" ||
|
||||
fail "user-facing USB authorization commands do not elevate caller-selected paths"
|
||||
pass "USB authorization keeps privileged paths fixed"
|
||||
|
||||
"$scratch/setup" --yes >"$scratch/setup-output"
|
||||
|
||||
grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root" label "omarchy-usb-authorization-v1"' "$rules" ||
|
||||
fail "USB authorization setup trusts devices present during enrollment"
|
||||
[[ $(stat -c %a "$rules") == 600 ]] || fail "USB authorization policy is root-private"
|
||||
grep -Fqx 'usbguard <add-user> <tester> <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" ||
|
||||
fail "USB authorization grants only the IPC access needed by the approval flow"
|
||||
grep -Fqx 'systemctl <restart usbguard.service>' "$calls" ||
|
||||
fail "USB authorization reloads its user ACL before the watcher starts"
|
||||
[[ -f $home/.config/systemd/user/omarchy-usb-authorization.service && ! -L $home/.config/systemd/user/omarchy-usb-authorization.service ]] ||
|
||||
fail "USB authorization installs the graphical-session watcher"
|
||||
grep -Fqx 'pkg-add <usbguard>' "$calls" || fail "USB authorization ensures USBGuard is installed"
|
||||
pass "USB authorization enrolls present devices before enabling default-deny"
|
||||
|
||||
generate_count=$(grep -c '^usbguard <generate-policy>$' "$calls")
|
||||
"$scratch/setup" --yes >"$scratch/setup-existing-output"
|
||||
generate_count_after=$(grep -c '^usbguard <generate-policy>$' "$calls")
|
||||
(( generate_count_after == generate_count )) || fail "re-enabling preserves the existing trusted-device policy"
|
||||
pass "USB authorization setup preserves an existing policy"
|
||||
|
||||
# Failures before the watcher is ready must never start enforcement. A copied
|
||||
# unit also repairs the dangling checkout link reported on the affected host.
|
||||
unit="$home/.config/systemd/user/omarchy-usb-authorization.service"
|
||||
rm -f "$unit"
|
||||
ln -s "$scratch/deleted-checkout/service" "$unit"
|
||||
"$scratch/setup" --yes >/dev/null
|
||||
[[ -f $unit && ! -L $unit ]] || fail "setup repairs a dangling watcher unit"
|
||||
for failure in missing-unit missing-helper session-mismatch watcher-start; do
|
||||
rm -f "$TEST_GUARD_ACTIVE"
|
||||
: >"$calls"
|
||||
cp "$scratch/setup" "$scratch/failing-setup"
|
||||
options=()
|
||||
case "$failure" in
|
||||
missing-unit) sed -i "s|^unit_source=.*|unit_source=$scratch/missing-unit|" "$scratch/failing-setup" ;;
|
||||
missing-helper) sed -i "s|$USB_POLICY_FIXTURE|$scratch/missing-helper|g" "$scratch/failing-setup" ;;
|
||||
session-mismatch) options+=(TEST_SESSION_ROOT=/missing-runtime) ;;
|
||||
watcher-start) options+=(TEST_WATCHER_FAIL=1) ;;
|
||||
esac
|
||||
if env "${options[@]}" "$scratch/failing-setup" --yes >"$scratch/$failure.log" 2>&1; then
|
||||
fail "setup must reject $failure"
|
||||
fi
|
||||
[[ ! -e $TEST_GUARD_ACTIVE ]] || fail "$failure starts blocking without a watcher"
|
||||
! grep -Eq 'systemctl <(enable|restart) usbguard.service>' "$calls" || fail "$failure enables enforcement"
|
||||
done
|
||||
"$scratch/setup" --yes >/dev/null
|
||||
watch_line=$(grep -n '^systemctl <--user restart omarchy-usb-authorization.service>' "$calls" | tail -1 | cut -d: -f1)
|
||||
guard_line=$(grep -n '^systemctl <restart usbguard.service>' "$calls" | tail -1 | cut -d: -f1)
|
||||
(( watch_line < guard_line )) || fail "watcher must start before enforcement"
|
||||
pass "USB setup repairs dangling units and refuses enforcement without a working watcher"
|
||||
|
||||
: >"$rules"
|
||||
GENERATE_EMPTY=1 "$scratch/setup" --yes >"$scratch/setup-empty-output"
|
||||
[[ -s $rules ]] || fail "empty USB inventory must persist an initialized policy"
|
||||
! grep -q '^allow ' "$rules" || fail "empty inventory must not add permissive rules"
|
||||
generate_count=$(grep -c '^usbguard <generate-policy>$' "$calls")
|
||||
"$scratch/setup" --yes >"$scratch/setup-empty-again-output"
|
||||
[[ $(grep -c '^usbguard <generate-policy>$' "$calls") == "$generate_count" ]] ||
|
||||
fail "re-enabling an empty policy must not enroll newly attached devices"
|
||||
: >"$rules"
|
||||
if GENERATE_FAIL=1 "$scratch/setup" --yes >"$scratch/setup-failed-output" 2>&1; then
|
||||
fail "enumeration failure must not be accepted as an empty USB inventory"
|
||||
fi
|
||||
[[ ! -s $rules ]] || fail "failed enumeration must not install a policy"
|
||||
pass "USB setup accepts an empty inventory while rejecting enumeration failure"
|
||||
|
||||
: >"$calls"
|
||||
"$scratch/setup" --boot --yes >"$scratch/setup-boot-output"
|
||||
grep -Fq 'boot-approve <4>' "$calls" ||
|
||||
fail "boot authorization trusts the connected root controller"
|
||||
grep -Fq 'boot-approve <5>' "$calls" ||
|
||||
fail "boot authorization trusts the connected USB devices"
|
||||
grep -Fqx 'sudo <omarchy-usb-authorization-boot> <enable>' "$calls" ||
|
||||
fail "boot authorization uses the fixed-path boot-image helper"
|
||||
grep -Fq 'takes effect after reboot' "$scratch/setup-boot-output" ||
|
||||
fail "boot authorization explains when the change becomes active"
|
||||
pass "USB authorization can snapshot connected devices for early-boot denial"
|
||||
|
||||
boot_conf="$scratch/limine.conf"
|
||||
machine_id=$(</etc/machine-id)
|
||||
touch "$scratch/vmlinuz-linux"
|
||||
boot_hash=$(b2sum "$scratch/vmlinuz-linux")
|
||||
boot_hash=${boot_hash%% *}
|
||||
printf 'quiet splash\0' >"$scratch/embedded-cmdline"
|
||||
historical_uki="$scratch/embedded.efi_sha256_fixture"
|
||||
objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki"
|
||||
objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki"
|
||||
embedded_hash=$(b2sum "$historical_uki")
|
||||
embedded_hash=${embedded_hash%% *}
|
||||
cat >"$boot_conf" <<CONF
|
||||
/+Omarchy
|
||||
comment: machine-id=$machine_id
|
||||
//linux
|
||||
comment: kernel-id=linux
|
||||
path: boot():/vmlinuz-linux#$boot_hash
|
||||
cmdline: quiet splash
|
||||
//linux-embedded
|
||||
comment: kernel-id=linux-embedded
|
||||
path: boot():/embedded.efi_sha256_fixture#$embedded_hash
|
||||
cmdline: quiet splash
|
||||
///Snapshots
|
||||
////linux
|
||||
comment: kernel-id=linux
|
||||
path: boot():/vmlinuz-linux#$boot_hash
|
||||
cmdline: rootflags=subvol=/snapshot quiet splash
|
||||
/Other Linux
|
||||
comment: machine-id=ffffffffffffffffffffffffffffffff
|
||||
//linux
|
||||
comment: kernel-id=linux
|
||||
path: boot():/other-linux
|
||||
cmdline: quiet splash
|
||||
CONF
|
||||
|
||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
||||
limine-mkinitcpio() { return 0; }
|
||||
limine-enroll-config() { return 0; }
|
||||
# Signed bootloader enrollment and rollback have a dedicated real-PE suite.
|
||||
usb_authorization_verify_bootloader() { return 0; }
|
||||
|
||||
if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then
|
||||
fail "boot authorization rejects Limine's false-success status when entries were not rebuilt"
|
||||
fi
|
||||
usb_authorization_rewrite_boot_cmdlines enabled "$boot_conf" "$machine_id"
|
||||
usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" disabled ||
|
||||
fail "a non-Secure-Boot snapshot uses its updated external command line"
|
||||
if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then
|
||||
fail "an external allow parameter cannot hide a stale embedded UKI command line"
|
||||
fi
|
||||
printf 'quiet splash usbcore.authorized_default=0\0' >"$scratch/embedded-cmdline"
|
||||
objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki"
|
||||
objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki"
|
||||
updated_embedded_hash=$(b2sum "$historical_uki")
|
||||
updated_embedded_hash=${updated_embedded_hash%% *}
|
||||
sed -i "s/embedded.efi_sha256_fixture#$embedded_hash/embedded.efi_sha256_fixture#$updated_embedded_hash/" "$boot_conf"
|
||||
usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled ||
|
||||
fail "boot authorization updates current and snapshot entries for this machine"
|
||||
[[ $(grep -c 'usbcore.authorized_default=0' "$boot_conf") == 3 ]] ||
|
||||
fail "boot authorization leaves another operating system's kernel entries unchanged"
|
||||
printf 'stale image' >"$scratch/vmlinuz-linux"
|
||||
if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then
|
||||
fail "boot authorization rejects a stale Limine verification hash"
|
||||
fi
|
||||
: >"$scratch/vmlinuz-linux"
|
||||
if usb_authorization_rebuild_and_verify disabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then
|
||||
fail "boot authorization detects a stale deny-by-default boot entry during removal"
|
||||
fi
|
||||
usb_authorization_rewrite_boot_cmdlines disabled "$boot_conf" "$machine_id"
|
||||
printf 'quiet splash\0' >"$scratch/embedded-cmdline"
|
||||
objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki"
|
||||
objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki"
|
||||
disabled_embedded_hash=$(b2sum "$historical_uki")
|
||||
disabled_embedded_hash=${disabled_embedded_hash%% *}
|
||||
sed -i "s/embedded.efi_sha256_fixture#$updated_embedded_hash/embedded.efi_sha256_fixture#$disabled_embedded_hash/" "$boot_conf"
|
||||
usb_authorization_rebuild_and_verify disabled "$boot_conf" "$scratch" "$machine_id" enabled ||
|
||||
fail "boot authorization removes the parameter from current and snapshot entries"
|
||||
|
||||
snapshot_config="$scratch/limine-defaults"
|
||||
printf 'TARGET_OS_NAME="Omarchy"\n' >"$snapshot_config"
|
||||
usb_authorization_enable_snapshot_setting "$snapshot_config"
|
||||
grep -Fqx 'SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' "$snapshot_config" ||
|
||||
fail "future Limine snapshots inherit boot-time USB authorization"
|
||||
usb_authorization_enable_snapshot_setting "$snapshot_config"
|
||||
[[ $(grep -Fxc '# Omarchy USB authorization begin' "$snapshot_config") == 1 ]] ||
|
||||
fail "the snapshot setting remains idempotent"
|
||||
usb_authorization_disable_snapshot_setting "$snapshot_config"
|
||||
! grep -Fq 'usbcore.authorized_default=0' "$snapshot_config" ||
|
||||
fail "removing boot authorization restores future snapshot defaults"
|
||||
pass "boot authorization verifies generated entries despite Limine false-success exits"
|
||||
|
||||
manifest="$scratch/snapshots.json"
|
||||
jq -n --arg hash "$boot_hash" '
|
||||
def kernel:
|
||||
{cmdlineDetails: [{limineKey: "CMDLINE", cmdline: "quiet rootflags=subvol=@", snapshotCmdline: "quiet rootflags=subvol=/snapshot"}],
|
||||
allInConfig: [" cmdline: quiet rootflags=subvol=@"],
|
||||
allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/vmlinuz-linux#" + $hash, "cmdline: quiet rootflags=subvol=/snapshot"],
|
||||
subKernels: [], properties: {untouched: "usbcore.authorized_default=0"}};
|
||||
{jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel | .allInSnapshotConfig = []]]}], uuid: "preserve-me"}
|
||||
' >"$manifest"
|
||||
cp "$manifest" "$scratch/manifest-original.json"
|
||||
for expected in enabled disabled; do
|
||||
usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest"
|
||||
cp "$manifest" "$scratch/manifest-once.json"
|
||||
usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest"
|
||||
cmp -s "$manifest" "$scratch/manifest-once.json" || fail "manifest updates are idempotent"
|
||||
# Model both Limine render paths: cached lines and structured details.
|
||||
for representation in cached structured; do
|
||||
{
|
||||
printf '/Omarchy\ncomment: machine-id=%s\n //linux\n' "$machine_id"
|
||||
if [[ $representation == "cached" ]]; then
|
||||
jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$manifest"
|
||||
else
|
||||
printf 'comment: kernel-id=linux\npath: boot():/vmlinuz-linux#%s\n' "$boot_hash"
|
||||
jq -r '"cmdline: " + .snapshotEntries[0].kernelEntries[0].subKernels[0].cmdlineDetails[0].snapshotCmdline' "$manifest"
|
||||
fi
|
||||
} >"$scratch/regenerated.conf"
|
||||
usb_authorization_verify_boot_entries "$expected" "$scratch/regenerated.conf" "$scratch" "$machine_id" disabled ||
|
||||
fail "snapshot synchronization preserves $expected policy from $representation entries"
|
||||
done
|
||||
jq -e '.snapshotEntries[0].kernelEntries[0].properties.untouched == "usbcore.authorized_default=0"' "$manifest" >/dev/null ||
|
||||
fail "manifest changes preserve unrelated metadata"
|
||||
done
|
||||
jq -S . "$manifest" >"$scratch/manifest-after.json"
|
||||
jq -S . "$scratch/manifest-original.json" >"$scratch/manifest-before.json"
|
||||
cmp -s "$scratch/manifest-before.json" "$scratch/manifest-after.json" ||
|
||||
fail "disable restores all stored command lines without changing other snapshot data"
|
||||
printf '{bad json' >"$scratch/bad-manifest.json"
|
||||
if usb_authorization_rewrite_snapshot_manifest enabled "$scratch/bad-manifest.json" 2>/dev/null; then
|
||||
fail "invalid manifests must fail closed"
|
||||
fi
|
||||
[[ $(<"$scratch/bad-manifest.json") == '{bad json' ]] || fail "failed manifest updates preserve the original"
|
||||
pass "USB boot policy survives snapshot regeneration from cached and structured manifests"
|
||||
|
||||
|
||||
: >"$calls"
|
||||
"$ROOT/bin/omarchy-remove-security-usb-authorization" --boot-only --yes >"$scratch/remove-boot-output"
|
||||
grep -Fqx 'sudo <omarchy-usb-authorization-boot> <disable>' "$calls" ||
|
||||
fail "boot-only removal rebuilds the permissive boot policy"
|
||||
! grep -Fq 'systemctl <--user disable --now omarchy-usb-authorization.service>' "$calls" ||
|
||||
fail "boot-only removal leaves the userspace approval watcher enabled"
|
||||
grep -Fq 'takes effect after reboot' "$scratch/remove-boot-output" ||
|
||||
fail "boot-only removal explains when the change becomes active"
|
||||
pass "early-boot denial can be removed without disabling USBGuard"
|
||||
|
||||
grep -qx 'usbguard' "$ROOT/install/omarchy-base.packages" || fail "USBGuard is installed by default"
|
||||
grep -Fq 'config/usb-authorization.sh' "$ROOT/install/config/all.sh" ||
|
||||
fail "fresh installs configure the USB authorization policy"
|
||||
grep -Fq 'omarchy-usb-authorization.service' "$ROOT/install/user/first-run/enable-user-units.sh" ||
|
||||
fail "fresh installs enable the USB approval watcher"
|
||||
grep -Fq 'usb_authorization_provision_owner "$username"' "$ROOT/bin/omarchy-provision-owner" ||
|
||||
fail "deferred provisioning enrolls the owner before finishing"
|
||||
grep -Fqx 'omarchy-setup-security-usb-authorization --yes' "$ROOT/migrations/1789433473.sh" ||
|
||||
fail "existing installs enable USB authorization during migration"
|
||||
|
||||
: >"$rules"
|
||||
: >"$calls"
|
||||
export OMARCHY_INSTALL="$ROOT/install"
|
||||
export OMARCHY_INSTALL_USER=tester
|
||||
export OMARCHY_USB_AUTHORIZATION_RULES_FILE="$rules"
|
||||
export OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG="$daemon_config"
|
||||
bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-output"
|
||||
grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root" label "omarchy-usb-authorization-v1"' "$rules" ||
|
||||
fail "fresh installation enrolls devices present during installation"
|
||||
grep -Fqx 'usbguard <add-user> <tester> <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" ||
|
||||
fail "fresh installation grants the owner narrowly scoped approval access"
|
||||
grep -Fqx 'systemctl <enable usbguard.service>' "$calls" ||
|
||||
fail "fresh installation enables USBGuard for the first boot"
|
||||
pass "USB authorization is the default for fresh and existing installations"
|
||||
|
||||
: >"$rules"
|
||||
GENERATE_EMPTY=1 bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-empty-output"
|
||||
[[ -s $rules ]] || fail "fresh install accepts a successful empty USB inventory"
|
||||
! grep -q '^allow ' "$rules" || fail "empty fresh-install inventory stays default-deny"
|
||||
: >"$rules"
|
||||
: >"$calls"
|
||||
if GENERATE_FAIL=1 bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-enumeration-failed" 2>&1; then
|
||||
fail "fresh install must reject a generator failure"
|
||||
fi
|
||||
! grep -Fq 'systemctl <enable usbguard.service>' "$calls" || fail "failed enumeration must not enable USBGuard"
|
||||
pass "fresh installation distinguishes no USB hardware from enumeration failure"
|
||||
|
||||
: >"$rules"
|
||||
: >"$calls"
|
||||
OMARCHY_INSTALL_USER="" bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-deferred-output"
|
||||
[[ ! -s $rules ]] || fail "deferred installation must not enroll the builder's hardware"
|
||||
! grep -q '^usbguard' "$calls" || fail "deferred installation must not enumerate or grant owner ACLs"
|
||||
grep -Fqx 'systemctl <disable usbguard.service>' "$calls" || fail "deferred installation leaves enforcement disabled"
|
||||
! grep -Fq 'systemctl <enable usbguard.service>' "$calls" || fail "deferred installation must not enable enforcement"
|
||||
|
||||
source "$ROOT/install/helpers/usb-authorization.sh"
|
||||
echo 'allow id 1234:0001 name "Builder keyboard" hash "builder"' >"$rules"
|
||||
owner_rule='allow id 1234:0002 name "Owner keyboard" hash "owner"'
|
||||
: >"$calls"
|
||||
GENERATED_RULE="$owner_rule" usb_authorization_provision_owner owner "$rules" "$daemon_config"
|
||||
[[ $(<"$rules") == "$owner_rule"' label "omarchy-usb-authorization-v1"' ]] || fail "owner enrollment must replace inherited builder trust"
|
||||
grep -Fqx 'usbguard <add-user> <owner> <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" ||
|
||||
fail "owner provisioning grants approval access"
|
||||
[[ $(tail -2 "$calls") == $'systemctl <enable usbguard.service>\nsystemctl <restart usbguard.service>' ]] ||
|
||||
fail "enforcement starts only after owner policy and ACL setup"
|
||||
|
||||
: >"$calls"
|
||||
if GENERATE_FAIL=1 usb_authorization_provision_owner owner "$rules" "$daemon_config" >"$scratch/owner-failed" 2>&1; then
|
||||
fail "owner enrollment must reject enumeration failure"
|
||||
fi
|
||||
[[ $(<"$rules") == "$owner_rule"' label "omarchy-usb-authorization-v1"' ]] || fail "failed enrollment preserves the old policy"
|
||||
! grep -q '^systemctl' "$calls" || fail "failed owner enrollment must not enable enforcement"
|
||||
: >"$calls"
|
||||
sed 's/ImplicitPolicyTarget=block/ImplicitPolicyTarget=allow/' "$daemon_config" >"$scratch/insecure-daemon.conf"
|
||||
if usb_authorization_provision_owner owner "$rules" "$scratch/insecure-daemon.conf" >"$scratch/owner-insecure" 2>&1; then
|
||||
fail "owner enrollment rejects insecure settings even when called in a conditional"
|
||||
fi
|
||||
[[ ! -s $calls ]] || fail "invalid settings must prevent all enrollment changes"
|
||||
pass "deferred installs enroll owner hardware before enabling enforcement"
|
||||
|
||||
malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00'
|
||||
export BLOCKED_DEVICE_RULE="$malicious_rule"
|
||||
notification_count=$(grep -c '^notification' "$calls" || true)
|
||||
for presence in Insert Present; do
|
||||
USBGUARD_IPC_SIGNAL=Device.PresenceChanged \
|
||||
USBGUARD_DEVICE_EVENT="$presence" \
|
||||
USBGUARD_DEVICE_TARGET=block \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
done
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_TARGET_NEW=allow \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_RULE="${malicious_rule/#block/allow}" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ $(grep -c '^notification' "$calls" || true) == "$notification_count" ]] ||
|
||||
fail "a trusted device's transient blocked presence must not prompt"
|
||||
BLOCKED_DEVICE_PRESENT=0 \
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ $(grep -c '^notification' "$calls" || true) == "$notification_count" ]] ||
|
||||
fail "a stale blocked event must not prompt after authorization or removal"
|
||||
pass "USB alerts use the final policy and discard stale events"
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_EVENT=Insert \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
|
||||
request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit)
|
||||
[[ -n $request ]] || fail "a blocked USB device creates a review request"
|
||||
[[ $(stat -c %a "$request") == 600 ]] || fail "device-controlled request data stays private"
|
||||
[[ $(jq -r .id "$request") == 17 ]] || fail "the request records the USBGuard device id"
|
||||
[[ $(jq -r .rule "$request") == "$malicious_rule" ]] || fail "the request preserves the identity snapshot"
|
||||
! grep -Fq "$malicious_rule" "$calls" || fail "device text never enters the notification's launch command"
|
||||
[[ ! -e $scratch/injected ]] || fail "device text cannot execute during notification"
|
||||
token=$(basename "${request%.json}")
|
||||
grep -Eq "notification .*<omarchy-usb-authorization-review> <$token>$" "$calls" ||
|
||||
fail "the notification action carries only an opaque request token"
|
||||
pass "blocked device metadata remains data across the desktop notification boundary"
|
||||
|
||||
notification_count=$(grep -c '^notification' "$calls")
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" &
|
||||
scan_pid=$!
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
wait "$scan_pid"
|
||||
[[ $(grep -c '^notification' "$calls") == "$notification_count" ]] ||
|
||||
fail "overlapping scan and policy events must not duplicate pending prompts"
|
||||
pass "USB reconnect scans and policy events deduplicate pending requests"
|
||||
|
||||
# Dismissing/canceling a notification leaves its request, even after the shell
|
||||
# discards the archived action. Reusing the ID after daemon restart must prompt.
|
||||
notification_count=$(grep -c '^notification' "$calls")
|
||||
export TEST_DAEMON_INVOCATION=22222222222222222222222222222222
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" &
|
||||
scan_pid=$!
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
wait "$scan_pid"
|
||||
[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] ||
|
||||
fail "reused IDs must get exactly one new notification after daemon restart"
|
||||
[[ -f $request ]] || fail "the recovered notification must have a reviewable request"
|
||||
|
||||
# Starting another watcher/session also expires old notification deduplication.
|
||||
notification_count=$(grep -c '^notification' "$calls")
|
||||
export OMARCHY_USB_AUTHORIZATION_WATCH_ID=new-session
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] ||
|
||||
fail "a new watcher must recover an orphaned approval action"
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] ||
|
||||
fail "same-generation scans must still deduplicate"
|
||||
|
||||
# A removal that arrives late must not erase a new connection's request.
|
||||
USBGUARD_IPC_SIGNAL=Device.PresenceChanged USBGUARD_DEVICE_EVENT=Remove \
|
||||
USBGUARD_DEVICE_ID=17 USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ -f $request ]] || fail "delayed removal must preserve a currently blocked device's request"
|
||||
BLOCKED_DEVICE_PRESENT=0 \
|
||||
USBGUARD_IPC_SIGNAL=Device.PresenceChanged USBGUARD_DEVICE_EVENT=Remove \
|
||||
USBGUARD_DEVICE_ID=17 USBGUARD_DEVICE_RULE="${malicious_rule/#block/allow}" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ ! -e $request ]] || fail "device removal must clean up its pending request"
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ $(grep -c '^notification' "$calls") == $((notification_count + 2)) ]] ||
|
||||
fail "a reappeared device must receive a fresh notification"
|
||||
|
||||
# Upgrade from requests written before generations existed, and remove stale
|
||||
# requests for devices no longer in the inventory.
|
||||
jq 'del(.generation)' "$request" >"$scratch/legacy-request"
|
||||
mv "$scratch/legacy-request" "$request"
|
||||
legacy_orphan="${request%/*}/request-$(printf '%064d' 0).json"
|
||||
printf '{"id":"99","rule":"block id 0000:0000"}\n' >"$legacy_orphan"
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ $(grep -c '^notification' "$calls") == $((notification_count + 3)) ]] ||
|
||||
fail "legacy request markers must not suppress notification recovery"
|
||||
[[ ! -e $legacy_orphan ]] || fail "reconnect must clean up old-generation orphan requests"
|
||||
|
||||
TEST_WATCH_ID_FILE="$scratch/watch-one" "$ROOT/bin/omarchy-usb-authorization-watch"
|
||||
TEST_WATCH_ID_FILE="$scratch/watch-two" "$ROOT/bin/omarchy-usb-authorization-watch"
|
||||
[[ -s $scratch/watch-one && $(<"$scratch/watch-one") != "$(<"$scratch/watch-two")" ]] ||
|
||||
fail "each watcher must export a fresh lifetime identifier"
|
||||
pass "USB prompts recover across daemon and session generations and device removal"
|
||||
|
||||
GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-once-output"
|
||||
grep -Fqx "usbguard <allow-device> <$malicious_rule>" "$calls" || fail "review can allow the exact device once"
|
||||
[[ ! -e $request ]] || fail "a completed review consumes its request"
|
||||
pass "USB review allows a still-matching device once"
|
||||
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
|
||||
# A daemon restart discards the temporary approval. The IPC reconnect must
|
||||
# recover the blocked device even when its startup policy events were missed.
|
||||
notification_count=$(grep -c '^notification' "$calls")
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ -f $request ]] || fail "reconnection recreates a consumed Allow once request"
|
||||
[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] ||
|
||||
fail "reconnection prompts again for a temporary approval lost on restart"
|
||||
GUM_CHOICE='Keep blocked' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >/dev/null
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_TARGET_OLD=allow \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
[[ -f $request ]] || fail "a later allow-to-block policy transition creates a request"
|
||||
GUM_CHOICE='Keep blocked' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >/dev/null
|
||||
pass "USB approval recovers after IPC reconnects and policy transitions"
|
||||
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_EVENT=Insert \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit)
|
||||
token=$(basename "${request%.json}")
|
||||
GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-always-output"
|
||||
grep -Fq 'usbguard <append-rule> <allow ' "$calls" || fail "review can persist a portable device rule"
|
||||
append_count=$(grep -c '^usbguard <append-rule>' "$calls")
|
||||
"$USB_APPROVE_FIXTURE" 17 "allow ${malicious_rule#block }"
|
||||
[[ $(grep -c '^usbguard <append-rule>' "$calls") == "$append_count" ]] || fail "confirmed permanent retry must preserve rule ordering without duplicate rules"
|
||||
pass "USB review supports explicit persistent trust"
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
|
||||
# Unmarked rules might be intentional manual restrictions. Review is opt-in,
|
||||
# never accepted through --yes, and only the selected live identity is added.
|
||||
legacy_rule='allow id 046d:c53a name "Receiver" hash "receiver" parent-hash "hub-a" via-port "1-2"'
|
||||
printf '%s\n' "$legacy_rule" >"$TEST_ALLOWED_RULE"
|
||||
printf '12: %s\n\t17: %s\n' "$legacy_rule" "$legacy_rule" >"$scratch/legacy-policy"
|
||||
cp "$scratch/legacy-policy" "$scratch/legacy-before"
|
||||
if "$scratch/setup" --review-existing --yes >/dev/null 2>&1; then fail "legacy migration requires individual consent"; fi
|
||||
TEST_POLICY_LIST="$scratch/legacy-policy" GUM_DECLINE=1 "$scratch/setup" --review-existing >/dev/null
|
||||
[[ ! -e $TEST_SAVED_RULE ]] || fail "declining migration adds portable trust"
|
||||
printf 'yes\nyes\nyes\n' | TEST_POLICY_LIST="$scratch/legacy-policy" GUM_REQUIRE_STDIN=1 "$scratch/setup" --review-existing >/dev/null
|
||||
[[ $(<"$TEST_SAVED_RULE") == 'allow id 046d:c53a name "Receiver" hash "receiver" label "omarchy-usb-authorization-v1"' ]] || fail "legacy review saves only identity constraints"
|
||||
cmp -s "$scratch/legacy-policy" "$scratch/legacy-before" || fail "legacy review rewrites existing manual rules"
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
|
||||
printf '12: block id 07a6:8513\n\t17: %s\n' "$malicious_rule" >"$scratch/deny-policy"
|
||||
if TEST_POLICY_LIST="$scratch/deny-policy" "$USB_APPROVE_FIXTURE" 17 "$malicious_rule" >/dev/null 2>&1; then fail "permanent approval must respect earlier manual denial"; fi
|
||||
[[ ! -e $TEST_SAVED_RULE && ! -e $TEST_ALLOWED_RULE ]] || fail "manual denial changed policy or authorization"
|
||||
printf '%s\n' "allow ${malicious_rule#block }" >"$TEST_ALLOWED_RULE"
|
||||
if TEST_POLICY_LIST="$scratch/deny-policy" "$USB_APPROVE_FIXTURE" 17 "allow ${malicious_rule#block }" >/dev/null 2>&1; then fail "temporary allow cannot hide an earlier manual denial"; fi
|
||||
rm -f "$TEST_ALLOWED_RULE"
|
||||
printf '12: block\n' >"$scratch/deny-all-policy"
|
||||
if TEST_POLICY_LIST="$scratch/deny-all-policy" "$USB_APPROVE_FIXTURE" 17 "$malicious_rule" >/dev/null 2>&1; then fail "unconditional manual denial must be preserved"; fi
|
||||
pass "USB legacy review requires consent and preserves manual policy"
|
||||
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_EVENT=Insert \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit)
|
||||
token=$(basename "${request%.json}")
|
||||
device_rule_file="$scratch/blocked-device-rule"
|
||||
printf '%s\n' "$malicious_rule" >"$device_rule_file"
|
||||
replacement_rule='block id 1d50:60c7 name "Replacement gadget" hash "replacement"'
|
||||
allow_count=$(grep -c '^usbguard <allow-device>' "$calls")
|
||||
if BLOCKED_DEVICE_RULE_FILE="$device_rule_file" \
|
||||
GUM_REASSIGN_RULE="$replacement_rule" \
|
||||
GUM_CHOICE='Always allow this device' \
|
||||
"$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-reassigned-output" 2>&1; then
|
||||
fail "review rejects a USBGuard ID reassigned while the approval dialog is open"
|
||||
fi
|
||||
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] ||
|
||||
fail "an ID reassigned during review cannot authorize a different device"
|
||||
[[ ! -e $request ]] || fail "a reassigned-ID review request is discarded"
|
||||
pass "USB review revalidates identity after the approval dialog"
|
||||
|
||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
||||
USBGUARD_DEVICE_ID=17 \
|
||||
USBGUARD_DEVICE_EVENT=Insert \
|
||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
||||
USBGUARD_DEVICE_RULE="$malicious_rule" \
|
||||
"$ROOT/bin/omarchy-usb-authorization-event"
|
||||
request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit)
|
||||
token=$(basename "${request%.json}")
|
||||
allow_count=$(grep -c '^usbguard <allow-device>' "$calls")
|
||||
if BLOCKED_DEVICE_PRESENT=0 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-stale-output" 2>&1; then
|
||||
fail "review rejects a request after its device disappears"
|
||||
fi
|
||||
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] ||
|
||||
fail "a reused USBGuard id cannot authorize a different device"
|
||||
[[ ! -e $request ]] || fail "a stale review request is discarded"
|
||||
pass "USB review binds approval to the device snapshot"
|
||||
|
||||
for choice in 'Allow once' 'Always allow this device'; do
|
||||
for failure in APPROVAL_SILENT_FAIL APPROVAL_EXIT_FAIL APPROVAL_QUERY_FAIL APPROVAL_REPLACEMENT_RULE; do
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request"
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
value=1
|
||||
[[ $failure != "APPROVAL_REPLACEMENT_RULE" ]] || value='allow id 9999:9999 hash "replacement"'
|
||||
if env "$failure=$value" GUM_CHOICE="$choice" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-failed" 2>&1; then
|
||||
fail "$choice must reject $failure"
|
||||
fi
|
||||
[[ -f $request ]] || fail "unverified approval must retain the request"
|
||||
! grep -Eq 'USB accessory (allowed until|added to)' "$scratch/review-failed" || fail "failed approval must not report success"
|
||||
done
|
||||
done
|
||||
for failure in APPROVAL_NO_POLICY APPROVAL_POLICY_QUERY_FAIL; do
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request"
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
if env "$failure=1" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-no-policy" 2>&1; then
|
||||
fail "permanent approval must reject $failure"
|
||||
fi
|
||||
[[ -f $request ]] || fail "unverified permanent approval must retain the request"
|
||||
done
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-retry"
|
||||
[[ ! -e $request ]] || fail "a verified retry consumes the retained request"
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
pass "USB approvals verify device and permanent policy before consuming requests"
|
||||
|
||||
# Exercise retries without resetting the device state left by the first attempt.
|
||||
for choice in 'Allow once' 'Always allow this device'; do
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
failure=APPROVAL_QUERY_FAIL
|
||||
[[ $choice != 'Always allow this device' ]] || failure=APPROVAL_POLICY_QUERY_FAIL
|
||||
if env "$failure=1" GUM_CHOICE="$choice" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-first" 2>&1; then
|
||||
fail "transient verification failure must retain the approval"
|
||||
fi
|
||||
[[ -s $TEST_ALLOWED_RULE && -f $request ]] || fail "fixture must retain an already-allowed device"
|
||||
[[ $(jq -r .approval "$request") == "$choice" ]] || fail "retry must remember the selected approval"
|
||||
[[ $(stat -c %a "$request") == 600 ]] || fail "saved approval intent stays private"
|
||||
allow_count=$(grep -c '^usbguard <allow-device>' "$calls")
|
||||
prompt_count=$(grep -c '^gum choose' "$calls")
|
||||
GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-second"
|
||||
[[ ! -e $request ]] || fail "verified approval retry consumes the request"
|
||||
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "already-allowed retry must not reapply authorization"
|
||||
[[ $(grep -c '^gum choose' "$calls") == "$prompt_count" ]] || fail "retry must resume the original approval without another choice"
|
||||
if [[ $choice == 'Always allow this device' ]]; then
|
||||
grep -Fq 'added to the trusted policy' "$scratch/retry-second" || fail "permanent retry must finish permanent verification"
|
||||
else
|
||||
grep -Fq 'allowed until it is disconnected' "$scratch/retry-second" || fail "once retry must finish temporary verification"
|
||||
fi
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
done
|
||||
pass "USB approval retries resume successful temporary and permanent authorization"
|
||||
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
if APPROVAL_NO_POLICY=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy" 2>&1; then
|
||||
fail "permanent approval requires saved policy"
|
||||
fi
|
||||
allow_count=$(grep -c '^usbguard <allow-device>' "$calls")
|
||||
if APPROVAL_NO_POLICY=1 GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then
|
||||
fail "retry cannot downgrade permanent approval when policy is missing"
|
||||
fi
|
||||
[[ -f $request && $(jq -r .approval "$request") == 'Always allow this device' ]] || fail "missing policy retains original permanent intent"
|
||||
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "retry must not repeat authorization on an allowed device"
|
||||
printf '%s\n' "allow ${malicious_rule#block }" >"$TEST_ALLOWED_RULE"
|
||||
for failure in BLOCKED_QUERY_FAIL APPROVAL_QUERY_FAIL; do
|
||||
if env "$failure=1" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/query-retry" 2>&1; then
|
||||
fail "retry must fail when inventory cannot be read"
|
||||
fi
|
||||
[[ -f $request ]] || fail "inventory query failures must preserve pending approvals"
|
||||
done
|
||||
printf '%s\n' "allow ${replacement_rule#block }" >"$TEST_ALLOWED_RULE"
|
||||
if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-retry" 2>&1; then
|
||||
fail "retry must reject a replacement allowed device"
|
||||
fi
|
||||
[[ ! -e $request ]] || fail "a proven replacement consumes the stale request"
|
||||
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "replacement retry must not authorize anything"
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
pass "USB approval retries preserve permanent intent and validate allowed identity"
|
||||
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
if BLOCKED_QUERY_FAIL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/fresh-query-failure" 2>&1; then
|
||||
fail "fresh review must fail on inventory query failure"
|
||||
fi
|
||||
[[ -f $request ]] || fail "fresh inventory failure must not delete the request"
|
||||
jq '.approval = "invalid"' "$request" >"$scratch/invalid-approval"
|
||||
mv "$scratch/invalid-approval" "$request"
|
||||
if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/invalid-intent" 2>&1; then
|
||||
fail "invalid saved intent must not authorize"
|
||||
fi
|
||||
jq 'del(.approval)' "$request" >"$scratch/fresh-request"
|
||||
mv "$scratch/fresh-request" "$request"
|
||||
if GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/cancel" 2>&1; then
|
||||
fail "canceled review must not authorize"
|
||||
fi
|
||||
jq -e '.approval == null' "$request" >/dev/null || fail "canceling must not store an approval"
|
||||
if GUM_REPLACE_REQUEST="$request" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-request" 2>&1; then
|
||||
fail "open dialog must not approve a replaced request"
|
||||
fi
|
||||
[[ $(jq -r .generation "$request") == 'replacement-generation' ]] || fail "stale dialog must leave replacement request intact"
|
||||
[[ $(grep -c '^usbguard <allow-device>' "$calls") == "$allow_count" ]] || fail "canceled or superseded requests cannot authorize"
|
||||
rm -f "$request"
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event"
|
||||
if APPROVAL_EXIT_FAIL=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/blocked-retry-first" 2>&1; then
|
||||
fail "failed apply must retain a blocked request"
|
||||
fi
|
||||
GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/blocked-retry-second"
|
||||
[[ -s $TEST_SAVED_RULE && ! -e $request ]] || fail "blocked retry must apply its saved permanent choice"
|
||||
rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE"
|
||||
pass "USB review preserves transient failures and rejects canceled or replaced dialogs"
|
||||
|
||||
notification_count=$(grep -c '^notification' "$calls")
|
||||
printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked"
|
||||
NOTIFICATION_READY_FILE="$scratch/notification-ready" \
|
||||
NOTIFICATION_FAILURE_MARKER="$scratch/first-send-failed" \
|
||||
BLOCKED_DEVICES_FILE="$scratch/two-blocked" \
|
||||
USBGUARD_IPC_SIGNAL=IPC.Connected \
|
||||
timeout 10 "$ROOT/bin/omarchy-usb-authorization-event" &
|
||||
scan_pid=$!
|
||||
for ((attempt=0; attempt<50; attempt++)); do
|
||||
[[ -e $scratch/notification-ready.waited ]] && break
|
||||
sleep 0.02
|
||||
done
|
||||
[[ -e $scratch/notification-ready.waited ]] || fail "startup waits for notification readiness"
|
||||
[[ $(grep -c '^notification' "$calls") == "$notification_count" ]] ||
|
||||
fail "startup does not send before the notification server is ready"
|
||||
touch "$scratch/notification-ready"
|
||||
wait "$scan_pid" || fail "startup scan must retry and finish without another USB event"
|
||||
[[ $(grep -c '^notification' "$calls") == $((notification_count + 3)) ]] ||
|
||||
fail "two blocked devices must be notified despite a failed first send"
|
||||
[[ -f $request ]] || fail "successful retry leaves a reviewable request"
|
||||
pass "USB startup waits for notifications and automatically retries delivery for all devices"
|
||||
|
||||
sysfs="$scratch/sys/bus/usb/devices"
|
||||
mkdir -p "$sysfs/usb1" "$sysfs/1-2"
|
||||
echo 0 >"$sysfs/usb1/authorized_default"
|
||||
echo 0 >"$sysfs/1-2/authorized"
|
||||
export TEST_SYSFS="$sysfs"
|
||||
|
||||
for failure in TEST_STOP_FAIL TEST_STOP_LIES; do
|
||||
touch "$TEST_GUARD_ACTIVE"
|
||||
: >"$calls"
|
||||
if env "$failure=1" "$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/$failure.log" 2>&1; then
|
||||
fail "removal must reject $failure"
|
||||
fi
|
||||
[[ -f $unit ]] || fail "failed daemon stop removes watcher"
|
||||
! grep -q '^usbguard <remove-user>' "$calls" || fail "failed daemon stop revokes watcher access"
|
||||
! grep -q '^systemctl <--user disable' "$calls" || fail "failed daemon stop disables watcher"
|
||||
done
|
||||
pass "USB removal preserves approval prompts if stopping enforcement fails"
|
||||
|
||||
"$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/remove-output"
|
||||
|
||||
[[ $(<"$sysfs/usb1/authorized_default") == 1 ]] || fail "removal restores root-hub default authorization"
|
||||
[[ $(<"$sysfs/1-2/authorized") == 1 ]] || fail "removal reauthorizes devices left connected"
|
||||
grep -Fqx 'systemctl <disable --now usbguard.service>' "$calls" || fail "removal stops a service Omarchy enabled"
|
||||
grep -Fqx 'usbguard <remove-user> <tester>' "$calls" || fail "removal drops the user's USBGuard IPC access"
|
||||
grep -Fqx 'sudo <omarchy-usb-authorization-restore-default>' "$calls" ||
|
||||
fail "removal restores USB authorization only through the fixed-path root helper"
|
||||
grep -Fqx 'sudo <omarchy-usb-authorization-boot> <disable>' "$calls" ||
|
||||
fail "removing USB authorization also removes early-boot denial"
|
||||
! grep -Fqx 'pkg-drop <usbguard>' "$calls" || fail "disabling keeps the default USBGuard package installed"
|
||||
[[ ! -e $home/.config/systemd/user/omarchy-usb-authorization.service ]] ||
|
||||
fail "removal deletes the graphical-session watcher"
|
||||
pass "USB authorization removal restores the original default-allow behavior"
|
||||
|
||||
cat >"$stub_bin/omarchy-setup-security-usb-authorization" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'migrate-setup <%s>\n' "$*" >>"$CALLS"
|
||||
STUB
|
||||
chmod +x "$stub_bin/omarchy-setup-security-usb-authorization"
|
||||
: >"$calls"
|
||||
bash -euo pipefail "$ROOT/migrations/1790608617.sh" >/dev/null
|
||||
! grep -q '^migrate-setup' "$calls" || fail "migration overrides USB opt-out"
|
||||
touch "$TEST_GUARD_ACTIVE"
|
||||
bash -euo pipefail "$ROOT/migrations/1790608617.sh" >/dev/null
|
||||
grep -Fqx 'migrate-setup <--yes>' "$calls" || fail "migration repairs an active daemon without a watcher"
|
||||
pass "USB migration repairs enabled protection and preserves opt-out"
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
machine=12345678901234567890123456789012
|
||||
export calls="$scratch/calls"
|
||||
: >"$calls"
|
||||
|
||||
# Optional integration run uses real sbctl keys and sbattach in isolated paths.
|
||||
sbctl() {
|
||||
printf '%s\n' "$1" >>"$calls"
|
||||
[[ ${SIGN_FAIL:-0} != 1 || $1 != sign ]] || return 1
|
||||
[[ ${VERIFY_FAIL:-0} != 1 || $1 != verify ]] || return 1
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
||||
elif [[ $1 == verify ]]; then
|
||||
jq -n --arg file "${!#}" --argjson signed "${TEST_SIGNED:-1}" '[{file_name: $file, is_signed: $signed}]'
|
||||
fi
|
||||
}
|
||||
sbattach() {
|
||||
if [[ -n ${USB_TEST_SBATTACH:-} ]]; then
|
||||
"$USB_TEST_SBATTACH" "$@"
|
||||
fi
|
||||
}
|
||||
omarchy-cmd-missing() { return 1; }
|
||||
limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; }
|
||||
usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; }
|
||||
# Bootloader enrollment/rollback is exercised with real EFI binaries in its own suite.
|
||||
usb_authorization_verify_bootloader() { return 0; }
|
||||
|
||||
fixture() {
|
||||
local embedded="$1" hash digest image_base
|
||||
esp="$scratch/case-$RANDOM"
|
||||
history="$esp/$machine/limine_history"
|
||||
manifest="$history/snapshots.json"
|
||||
cache="$esp/cache.json"
|
||||
config="$esp/limine.conf"
|
||||
mkdir -p "$history"
|
||||
printf '%s\0' "$embedded" >"$esp/cmdline"
|
||||
cp /usr/lib/systemd/boot/efi/linuxx64.efi.stub "$esp/image.efi"
|
||||
image_base=$(objdump -p "$esp/image.efi" | awk '$1 == "ImageBase" {print "0x" $2}')
|
||||
objcopy --add-section .cmdline="$esp/cmdline" --change-section-vma .cmdline="$((image_base + 0x100000))" "$esp/image.efi"
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
sbctl sign "$esp/image.efi" >/dev/null
|
||||
fi
|
||||
digest=$(sha256sum "$esp/image.efi"); digest=${digest%% *}
|
||||
old_name="test.efi_sha256_$digest"
|
||||
cp "$esp/image.efi" "$history/$old_name"
|
||||
hash=$(b2sum "$esp/image.efi"); hash=${hash%% *}
|
||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n comment: kernel-id=linux\n path: boot():/%s/limine_history/%s#%s\n cmdline: %s rootflags=subvol=/snapshot\n' \
|
||||
"$machine" "$machine" "$old_name" "$hash" "$embedded" >"$config"
|
||||
jq -n --arg name "$old_name" --arg hash "$hash" --arg machine "$machine" --arg cmdline "$embedded" '
|
||||
def kernel:
|
||||
{imageDetails: [{fileHashName: $name, fileName: "test.efi",
|
||||
snapshotFilePathLine: "/" + $machine + "/limine_history/" + $name + "#" + $hash,
|
||||
filePathLine: "/EFI/Linux/test.efi#" + $hash, properties: {HASH: "#" + $hash}}],
|
||||
cmdlineDetails: [{cmdline: $cmdline, snapshotCmdline: $cmdline + " rootflags=subvol=/snapshot"}],
|
||||
allInConfig: ["path: boot():/EFI/Linux/test.efi#" + $hash, "cmdline: " + $cmdline],
|
||||
allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/" + $machine + "/limine_history/" + $name + "#" + $hash, "cmdline: " + $cmdline + " rootflags=subvol=/snapshot"], subKernels: []};
|
||||
{jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel]]}]}
|
||||
' >"$manifest"
|
||||
cp "$manifest" "$manifest.old"
|
||||
cp "$manifest" "$cache"
|
||||
cp "$manifest" "$esp/before.json"
|
||||
cp "$config" "$esp/before.conf"
|
||||
: >"$calls"
|
||||
}
|
||||
|
||||
for expected in enabled disabled; do
|
||||
old='quiet'
|
||||
[[ $expected != disabled ]] || old+=' usbcore.authorized_default=0'
|
||||
fixture "$old"
|
||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
||||
[[ $new_name != "$old_name" && -f $history/$new_name ]] || fail "conflicting archives get new content-addressed copies"
|
||||
cmp "$history/$old_name" "$esp/image.efi" || fail "original archived image remains unchanged"
|
||||
digest=$(sha256sum "$history/$new_name")
|
||||
[[ $new_name == *"${digest%% *}" ]] || fail "replacement name matches its content hash"
|
||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
||||
embedded=$(tr '\0' '\n' <"$esp/section")
|
||||
wanted=quiet
|
||||
[[ $expected != enabled ]] || wanted+=' usbcore.authorized_default=0'
|
||||
[[ $embedded == "$wanted" ]] || fail "replacement must preserve embedded boot arguments and change only USB policy"
|
||||
objcopy -O binary --only-section=.text "$history/$old_name" "$esp/text.before"
|
||||
objcopy -O binary --only-section=.text "$history/$new_name" "$esp/text.after"
|
||||
cmp "$esp/text.before" "$esp/text.after" || fail "archive conversion must not alter executable code"
|
||||
[[ $(grep -c '^sign$' "$calls") == 1 && $(grep -c '^verify$' "$calls") == 2 ]] || fail "deduplicated image must be verified before and after signing"
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
! grep -Fq "$old_name" "$file" || fail "no old image references may survive in manifests"
|
||||
usb_authorization_rewrite_snapshot_manifest "$expected" "$file"
|
||||
# Both cached and structured restore representations must regenerate valid entries.
|
||||
for format in cached structured; do
|
||||
{
|
||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n' "$machine"
|
||||
if [[ $format == cached ]]; then
|
||||
jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$file"
|
||||
else
|
||||
printf 'comment: kernel-id=linux\n'
|
||||
jq -r '.snapshotEntries[0].kernelEntries[0].subKernels[0] | "path: boot():" + .imageDetails[0].snapshotFilePathLine, "cmdline: " + .cmdlineDetails[0].snapshotCmdline' "$file"
|
||||
fi
|
||||
} >"$esp/regenerated.conf"
|
||||
usb_authorization_verify_boot_entries "$expected" "$esp/regenerated.conf" "$esp" "$machine" enabled || fail "regenerated $format $expected snapshot must verify"
|
||||
done
|
||||
done
|
||||
usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine"
|
||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine" enabled || fail "$expected archive transition must verify under Secure Boot"
|
||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
||||
[[ $(grep -c '^sign$' "$calls") == 1 ]] || fail "retry must not resign an already-compatible archive"
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
usb_authorization_verify_signature "$history/$new_name"
|
||||
fi
|
||||
pass "USB $expected transition updates signed history and all cached/structured references"
|
||||
done
|
||||
|
||||
fixture quiet
|
||||
old_hash=$(b2sum "$esp/image.efi"); old_hash=${old_hash%% *}
|
||||
printf ' //current\n comment: kernel-id=current\n path: boot():/image.efi#%s\n cmdline: quiet\n' "$old_hash" >>"$config"
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
grep -Fq "path: boot():/image.efi#$old_hash" "$config" || fail "preflight must not rewrite hashes of unchanged current images sharing archive content"
|
||||
pass "USB archive preflight preserves unchanged current-image hashes"
|
||||
|
||||
fixture 'quiet rd.test="two spaces"'
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
||||
[[ $(tr '\0' '\n' <"$esp/section") == 'quiet rd.test="two spaces" usbcore.authorized_default=0' ]] || fail "unrelated quoted arguments must retain their whitespace"
|
||||
pass "USB archive policy changes preserve unrelated embedded arguments and executable code"
|
||||
|
||||
for failure in SIGN_FAIL VERIFY_FAIL ENROLL_FAIL; do
|
||||
fixture quiet
|
||||
if (export "$failure=1"; usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache") >"$esp/failure.log" 2>&1; then
|
||||
fail "$failure must prevent publication"
|
||||
fi
|
||||
cmp "$config" "$esp/before.conf" || fail "$failure must preserve the original boot menu"
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
cmp "$file" "$esp/before.json" || fail "$failure must preserve every manifest"
|
||||
done
|
||||
cmp "$history/$old_name" "$esp/image.efi" || fail "$failure must preserve the archived image"
|
||||
done
|
||||
pass "USB archive signing, verification and enrollment failures preserve original references"
|
||||
|
||||
fixture quiet
|
||||
printf corrupt >>"$history/$old_name"
|
||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/hash-failure" 2>&1; then
|
||||
fail "corrupt original archive must never be signed"
|
||||
fi
|
||||
[[ ! -s $calls ]] || fail "hash failure must occur before signing"
|
||||
cmp "$config" "$esp/before.conf"
|
||||
SECURE_BOOT=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
[[ ! -s $calls ]] || fail "non-Secure-Boot transitions must not rewrite archive images"
|
||||
pass "USB archive conversion rejects corruption and skips non-Secure-Boot systems"
|
||||
|
||||
if [[ -z ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
fixture quiet
|
||||
if TEST_SIGNED=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/unsigned" 2>&1; then
|
||||
fail "a false-success sbctl result cannot authorize signing"
|
||||
fi
|
||||
! grep -qx sign "$calls" || fail "an unsigned original must not be resigned"
|
||||
fi
|
||||
for missing in menu manifest; do
|
||||
fixture quiet
|
||||
if [[ $missing == menu ]]; then
|
||||
sed -i '/cmdline:/d' "$config"
|
||||
else
|
||||
jq '.snapshotEntries[0].kernelEntries[0].cmdlineDetails = []' "$manifest" >"$esp/modified"
|
||||
mv "$esp/modified" "$manifest"
|
||||
fi
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" || fail "embedded-only $missing must support policy changes"
|
||||
usb_authorization_rewrite_boot_cmdlines enabled "$config" "$machine"
|
||||
usb_authorization_verify_boot_entries enabled "$config" "$esp" "$machine" enabled || fail "embedded-only $missing must retain a valid command line"
|
||||
done
|
||||
for section in .profile .pcrsig; do
|
||||
fixture quiet
|
||||
printf protected >"$esp/protected"
|
||||
objcopy --add-section "$section=$esp/protected" "$history/$old_name" 2>/dev/null
|
||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/protected-failure" 2>&1; then
|
||||
fail "a $section image must require its original builder"
|
||||
fi
|
||||
grep -Fq "($section)" "$esp/protected-failure" || fail "unsupported signing policies need an actionable error"
|
||||
[[ ! -s $calls ]] || fail "unsupported signing policies must not be resigned"
|
||||
done
|
||||
pass "USB archive conversion requires authentic signatures and preserves boot/unlock requirements"
|
||||
|
||||
# Package hooks use this same lock. Installing the optional signature tool
|
||||
# must finish before preparation takes the lock, while sync must run under it.
|
||||
fixture quiet
|
||||
prepare=$(declare -f usb_authorization_prepare_archived_images)
|
||||
prepare=${prepare//\/run\/lock\/boot-partition.lock/$scratch\/preflight.lock}
|
||||
eval "$prepare"
|
||||
omarchy-cmd-missing() { return 0; }
|
||||
omarchy-pkg-add() {
|
||||
flock --nonblock "$scratch/preflight.lock" true || fail "package installation must precede the boot lock"
|
||||
printf 'package\n' >>"$calls"
|
||||
}
|
||||
limine-snapper-sync() {
|
||||
[[ $* == '--no-mutex --no-hooks' ]] || fail "preparation must use the already-held lock"
|
||||
if flock --nonblock "$scratch/preflight.lock" true; then
|
||||
fail "snapshot synchronization must hold the boot lock"
|
||||
fi
|
||||
}
|
||||
usb_authorization_prepare_archived_images enabled "$config" "$esp" "$machine"
|
||||
grep -qx package "$calls" || fail "missing signature tooling must be installed before preparing archives"
|
||||
pass "USB archive preparation installs optional tools before taking Limine's lock"
|
||||
|
||||
# The disposable Lab runs the actual root-only main function with only boot paths
|
||||
# and external build/sync commands redirected into this fixture.
|
||||
if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then
|
||||
(( EUID == 0 )) || fail "main-path fixtures require the disposable guest's root"
|
||||
usb_authorization_prepare_archived_images() {
|
||||
usb_authorization_reconcile_archived_images "$1" "$2" "$3" "$machine" "$cache"
|
||||
}
|
||||
limine-mkinitcpio() { return 0; }
|
||||
usb_authorization_sync_and_verify() {
|
||||
local file
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
usb_authorization_rewrite_snapshot_manifest "$1" "$file" || return 1
|
||||
done
|
||||
usb_authorization_rewrite_boot_cmdlines "$1" "$2" "$machine" || return 1
|
||||
usb_authorization_verify_boot_entries "$1" "$2" "$3" "$machine" enabled
|
||||
}
|
||||
for action in enable disable; do
|
||||
old=quiet
|
||||
[[ $action != disable ]] || old+=' usbcore.authorized_default=0'
|
||||
fixture "$old"
|
||||
drop_in="$esp/entry-tool/usb-authorization.conf"
|
||||
limine_defaults="$esp/defaults"
|
||||
limine_conf="$config"
|
||||
boot_path="$esp"
|
||||
printf '# fixture defaults\n' >"$limine_defaults"
|
||||
if [[ $action == disable ]]; then
|
||||
mkdir -p "${drop_in%/*}"
|
||||
printf '%s\n' "$setting" >"$drop_in"
|
||||
usb_authorization_enable_snapshot_setting "$limine_defaults"
|
||||
fi
|
||||
cp "$limine_defaults" "$esp/defaults.before"
|
||||
if (export SIGN_FAIL=1; usb_authorization_main "$action") >"$esp/main-failed" 2>&1; then
|
||||
fail "$action must stop if archive preparation fails"
|
||||
fi
|
||||
cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes"
|
||||
if [[ $action == enable ]]; then
|
||||
[[ ! -e $drop_in ]] || fail "enable preflight failure must not create the boot setting"
|
||||
else
|
||||
[[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting"
|
||||
fi
|
||||
(usb_authorization_main "$action")
|
||||
if [[ $action == enable ]]; then
|
||||
[[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy"
|
||||
else
|
||||
[[ ! -e $drop_in ]] || fail "successful disable clears the boot policy"
|
||||
fi
|
||||
pass "USB $action main path prepares archives before changing boot policy"
|
||||
done
|
||||
fi
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
usb_authorization_secure_boot_enabled() { return 0; }
|
||||
usb_authorization_limine_settings() { printf 'x64 %s\n' "${ENROLLMENT:-yes}"; }
|
||||
|
||||
# Real PE enrollment always runs. An optional isolated sbctl configuration also
|
||||
# exercises real signatures; default runs track signed content without host keys.
|
||||
sbctl() {
|
||||
if [[ $1 == sign && ${SIGN_FAIL:-0} == 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
||||
elif [[ $1 == sign ]]; then
|
||||
sha256sum "${!#}" | cut -d ' ' -f1 >>"$scratch/signed"
|
||||
elif [[ $1 == verify ]]; then
|
||||
local digest signed=0
|
||||
digest=$(sha256sum "${!#}")
|
||||
if grep -qx "${digest%% *}" "$scratch/signed"; then signed=1; fi
|
||||
jq -n --arg file "${!#}" --argjson signed "$signed" '[{file_name: $file, is_signed: $signed}]'
|
||||
fi
|
||||
}
|
||||
|
||||
fixture() {
|
||||
case_dir=$(mktemp -d "$scratch/case.XXXXXXXX")
|
||||
boot_path="$case_dir/boot"
|
||||
cache="$case_dir/cache"
|
||||
limine_conf="$boot_path/limine.conf"
|
||||
limine_defaults="$case_dir/defaults"
|
||||
drop_in="$case_dir/dropins/usb-authorization.conf"
|
||||
image="$boot_path/EFI/limine/limine_x64.efi"
|
||||
mkdir -p "${image%/*}" "$cache/lss" "${drop_in%/*}"
|
||||
printf 'initial menu\n' >"$limine_conf"
|
||||
printf 'initial kernel\n' >"$boot_path/kernel.efi"
|
||||
printf 'initial snapshot\n' >"$cache/lss/snapshots.json"
|
||||
printf 'initial defaults\n' >"$limine_defaults"
|
||||
: >"$scratch/signed"
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
if [[ ${ENROLLMENT:-yes} == yes ]]; then
|
||||
digest=$(b2sum "$limine_conf")
|
||||
limine enroll-config "$image" "${digest%% *}" --quiet
|
||||
fi
|
||||
sbctl sign "$image" >/dev/null
|
||||
cp "$image" "$case_dir/original.efi"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
}
|
||||
|
||||
# Matches the installed wrapper: restore unsigned binary, enroll, attempt sign,
|
||||
# then mask failure with a successful final sync. No real ESP is ever touched.
|
||||
limine-enroll-config() {
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
if [[ ${ENROLLMENT:-yes} == yes && ${SKIP_ENROLLMENT:-0} != 1 ]]; then
|
||||
local digest
|
||||
digest=$(b2sum "$limine_conf")
|
||||
limine enroll-config "$image" "${digest%% *}" --quiet || return 1
|
||||
fi
|
||||
sbctl sign "$image" || true
|
||||
sync -f "$image"
|
||||
}
|
||||
|
||||
# Relocate the outer real flock into scratch. The runner stands in for the
|
||||
# namespace child and deliberately mutates the same files as rebuild hooks.
|
||||
transaction=$(declare -f usb_authorization_boot_transaction)
|
||||
transaction=${transaction//\/run\/lock\/boot-partition.lock/$scratch\/boot.lock}
|
||||
eval "$transaction"
|
||||
usb_authorization_run_transaction() {
|
||||
if flock --nonblock "$scratch/boot.lock" true; then
|
||||
fail "the real boot lock must cover the entire transaction"
|
||||
fi
|
||||
printf 'changed menu\n' >"$limine_conf"
|
||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
||||
printf 'changed metadata\n' >"$cache/lss/snapshots.json"
|
||||
printf 'new cached backup\n' >"$cache/lss/snapshots.json.old"
|
||||
mkdir -p "$boot_path/$(</etc/machine-id)/limine_history"
|
||||
printf 'new archived backup\n' >"$boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old"
|
||||
printf 'changed defaults\n' >"$limine_defaults"
|
||||
printf '%s\n' "$setting" >"$drop_in"
|
||||
if [[ ${EARLY_FAILURE:-0} == 1 ]]; then
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
return 1
|
||||
fi
|
||||
if [[ ${UNCHECKED_HOOK:-0} == 1 ]]; then
|
||||
limine-enroll-config
|
||||
else
|
||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in SIGN_FAIL SKIP_ENROLLMENT EARLY_FAILURE; do
|
||||
fixture
|
||||
if (export "$failure=1"; usb_authorization_boot_transaction enable "$cache") >"$case_dir/failure.log" 2>&1; then
|
||||
fail "$failure must reject the boot transition"
|
||||
fi
|
||||
cmp "$image" "$case_dir/original.efi" || fail "$failure must restore the signed bootloader byte-for-byte without signing"
|
||||
[[ $(<"$limine_conf") == 'initial menu' ]] || fail "$failure must restore matching menu"
|
||||
[[ $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "$failure must restore images referenced by that menu"
|
||||
[[ $(<"$cache/lss/snapshots.json") == 'initial snapshot' ]] || fail "$failure must restore cached snapshot metadata"
|
||||
[[ ! -e $cache/lss/snapshots.json.old && ! -e $boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old ]] || fail "$failure must remove newly created recovery metadata"
|
||||
[[ $(<"$limine_defaults") == 'initial defaults' && ! -e $drop_in ]] || fail "$failure must restore settings including an absent drop-in"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
done
|
||||
pass "USB boot transitions restore signed Limine, matching menu, images, metadata and settings on failure"
|
||||
|
||||
fixture
|
||||
printf 'original opt-in\n' >"$drop_in"
|
||||
if (SIGN_FAIL=1 UNCHECKED_HOOK=1 usb_authorization_boot_transaction disable "$cache") >"$case_dir/failure.log" 2>&1; then
|
||||
fail "final verification must reject false-success rebuild hooks"
|
||||
fi
|
||||
cmp "$image" "$case_dir/original.efi" || fail "false-success hooks must restore original signed Limine"
|
||||
[[ $(<"$drop_in") == 'original opt-in' ]] || fail "failed disable must preserve an existing drop-in"
|
||||
pass "USB boot transitions independently verify enrollment after external hooks"
|
||||
|
||||
for enrollment in yes no; do
|
||||
export ENROLLMENT="$enrollment"
|
||||
fixture
|
||||
usb_authorization_boot_transaction enable "$cache"
|
||||
[[ $(<"$limine_conf") == 'changed menu' && -e $drop_in ]] || fail "successful transition must commit"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
done
|
||||
pass "USB boot transitions commit verified signing with enrollment enabled or disabled"
|
||||
|
||||
# Disposable Lab only: exercise the actual namespace runner and both lock
|
||||
# inodes, with the installed enrollment/restore functions and real signatures.
|
||||
if [[ ${USB_TEST_NAMESPACE:-0} == 1 ]]; then
|
||||
(( EUID == 0 )) || fail "namespace tests require root in the disposable guest"
|
||||
[[ -n ${USB_TEST_SBCTL_CONFIG:-} ]] || fail "namespace tests require isolated signing keys"
|
||||
export ENROLLMENT=yes
|
||||
for outcome in failure success; do
|
||||
fixture
|
||||
cp /usr/share/limine/BOOTX64.EFI "$case_dir/limine_x64.efi"
|
||||
tar -cf "$case_dir/limine.bak" -C "$case_dir" limine_x64.efi
|
||||
lock_inode=$(stat -Lc '%d:%i' /run/lock/boot-partition.lock)
|
||||
# Source the shipped functions in both the parent and namespace child.
|
||||
sed '/^if \[\[ ${BASH_SOURCE\[0\]} == "\$0" \]\]; then/,$d' "$ROOT/bin/omarchy-usb-authorization-boot" >"$case_dir/helper"
|
||||
for variable in scratch case_dir boot_path cache limine_conf limine_defaults drop_in image lock_inode; do
|
||||
printf '%s=%q\n' "$variable" "${!variable}" >>"$case_dir/helper"
|
||||
done
|
||||
declare -f sbctl usb_authorization_limine_settings usb_authorization_secure_boot_enabled >>"$case_dir/helper"
|
||||
cat >>"$case_dir/helper" <<'SCRIPT'
|
||||
usb_authorization_main() {
|
||||
[[ $(stat -Lc '%d:%i' /run/lock/boot-partition.lock) != "$lock_inode" ]] || return 1
|
||||
exec 9>/run/lock/boot-partition.lock
|
||||
flock --nonblock 9 || return 1
|
||||
printf 'changed menu\n' >"$limine_conf"
|
||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
||||
source /usr/lib/limine/limine-common-functions
|
||||
LIMINE_DIR_PATH="${image%/*}"
|
||||
LIMINE_EFI_FILE=limine_x64.efi
|
||||
BINARY_TARGET_PATH="$image"
|
||||
BINARY_BACKUP_PATH="$case_dir/limine.bak"
|
||||
LIMINE_CONFIG_PATH="$limine_conf"
|
||||
ENABLE_ENROLL_LIMINE_CONFIG=yes
|
||||
is_sb_installed() { return 0; }
|
||||
is_uefi() { return 0; }
|
||||
limine-enroll-config() { enroll_config; }
|
||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
||||
}
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
usb_authorization_boot_transaction "$1" "$cache"
|
||||
fi
|
||||
SCRIPT
|
||||
if [[ $outcome == failure ]]; then
|
||||
if SIGN_FAIL=1 bash "$case_dir/helper" enable >"$case_dir/namespace.log" 2>&1; then
|
||||
fail "actual namespace must reject installed wrapper's false success"
|
||||
fi
|
||||
grep -Fq 'Failed to sign' "$case_dir/namespace.log" || fail "failure must reach the installed enrollment function"
|
||||
cmp "$image" "$case_dir/original.efi" || fail "namespace rollback restores original signed bootloader"
|
||||
[[ $(<"$limine_conf") == 'initial menu' && $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "namespace rollback restores matching boot files"
|
||||
else
|
||||
bash "$case_dir/helper" disable >"$case_dir/namespace.log" 2>&1
|
||||
[[ $(<"$limine_conf") == 'changed menu' ]] || fail "namespace success commits"
|
||||
fi
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
flock --nonblock /run/lock/boot-partition.lock true || fail "outer lock must be released"
|
||||
done
|
||||
pass "USB real namespace isolates nested locks and recovers installed Limine signing failure"
|
||||
fi
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
# Exercise reset staging in a temporary root with the real offline systemctl.
|
||||
# Never invoke the reset command's main function or any actual disk operations.
|
||||
python - "$ROOT" <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
repo = Path(sys.argv[1])
|
||||
source = (repo / 'bin/omarchy-system-factory-reset').read_text()
|
||||
functions = []
|
||||
for name in ['install_provisioning_units', 'scrub_factory_accounts', 'sanitize_factory_baseline', 'stage_full_reset']:
|
||||
match = re.search(r'^' + name + r'\(\) \{\n.*?^\}', source, re.M | re.S)
|
||||
assert match, name
|
||||
functions.append(match.group())
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='omarchy-usb-reset-') as tmp:
|
||||
scratch = Path(tmp)
|
||||
for scenario in ['enabled', 'disabled', 'absent', 'disable-failure']:
|
||||
root = scratch / scenario
|
||||
top = root / 'top'
|
||||
factory = top / '@factory'
|
||||
for rel in ['etc/systemd/system/basic.target.wants', 'usr/lib/systemd/system',
|
||||
'etc/usbguard', 'usr/bin', 'usr/share/omarchy/install/provisioning']:
|
||||
(factory / rel).mkdir(parents=True, exist_ok=True)
|
||||
(top / '@').mkdir()
|
||||
(top / '@' / 'original-root').touch()
|
||||
(factory / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/bash\n')
|
||||
(factory / 'etc/machine-id').write_text('old-machine-id\n')
|
||||
policy = 'allow id 1234:0001 name "Original owner keyboard" hash "old-owner"\n'
|
||||
(factory / 'etc/usbguard/rules.conf').write_text(policy)
|
||||
unit = factory / 'usr/lib/systemd/system/usbguard.service'
|
||||
enabled_path = Path('etc/systemd/system/basic.target.wants/usbguard.service')
|
||||
if scenario != 'absent':
|
||||
unit.write_text('[Unit]\nDescription=Fixture USBGuard\n[Service]\nExecStart=/bin/true\n[Install]\nWantedBy=basic.target\n')
|
||||
if scenario in ['enabled', 'disable-failure']:
|
||||
(factory / enabled_path).symlink_to('/usr/lib/systemd/system/usbguard.service')
|
||||
owner = factory / 'usr/bin/omarchy-provision-owner'
|
||||
owner.touch()
|
||||
owner.chmod(0o755)
|
||||
for name in ['omarchy-provision-owner.service', 'omarchy-system-factory-reset-finish.service']:
|
||||
shutil.copyfile(repo / 'install/provisioning' / name,
|
||||
factory / 'usr/share/omarchy/install/provisioning' / name)
|
||||
harness = '''#!/bin/bash
|
||||
set -euo pipefail
|
||||
TOP_MNT="$1/top"
|
||||
NEXT_NAME=@next
|
||||
PROVISIONING_DIR=/var/lib/omarchy/provisioning
|
||||
LOG_FILE="$1/log"
|
||||
log() { :; }
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
btrfs() {
|
||||
if [[ $1 == subvolume && $2 == snapshot ]]; then
|
||||
cp -a "$3" "$4"
|
||||
elif [[ $1 == property ]]; then
|
||||
:
|
||||
else
|
||||
return 90
|
||||
fi
|
||||
}
|
||||
# Account cleanup has its own real-tool suite; keep this fixture focused on USB.
|
||||
usermod() {
|
||||
[[ $1 == --root && ( $2 == "$TOP_MNT/$NEXT_NAME" || $2 == "$TOP_MNT/@factory" ) &&
|
||||
$3 == --password && $4 == '!' && $5 == root ]]
|
||||
}
|
||||
systemd-id128() { echo new-machine-id; }
|
||||
encrypted_install() { return 1; }
|
||||
rebuild_next_boot() { :; }
|
||||
sync() { :; }
|
||||
systemctl() {
|
||||
# All service changes must address the staged root, never the live machine.
|
||||
[[ $1 == "--root=$TOP_MNT/$NEXT_NAME" && $2 == disable && $3 == usbguard.service ]] || return 91
|
||||
[[ $2 != "${FAIL_OPERATION:-}" ]] || return 92
|
||||
command systemctl "$@"
|
||||
}
|
||||
''' + '\n\n'.join(functions) + '\nstage_full_reset\n'
|
||||
runner = root / 'run'
|
||||
runner.write_text(harness)
|
||||
env = os.environ.copy()
|
||||
env['FAIL_OPERATION'] = 'disable' if scenario == 'disable-failure' else ''
|
||||
result = subprocess.run(['bash', str(runner), str(root)], env=env, capture_output=True, text=True)
|
||||
if scenario == 'disable-failure':
|
||||
assert result.returncode != 0, 'failed disable must abort reset staging'
|
||||
assert (top / '@' / 'original-root').exists(), 'failure must not activate the reset root'
|
||||
assert 'could not disable USBGuard' in result.stderr
|
||||
else:
|
||||
assert result.returncode == 0, result.stderr
|
||||
staged = top / '@'
|
||||
assert not (staged / enabled_path).is_symlink(), 'reset must not start USBGuard before enrollment'
|
||||
assert (staged / 'var/lib/omarchy/provisioning/pending').exists()
|
||||
assert (staged / 'etc/usbguard/rules.conf').read_text() == policy
|
||||
if scenario == 'enabled':
|
||||
assert (factory / enabled_path).is_symlink(), 'only the staged clone is disabled'
|
||||
# The owner-enrollment helper enables the daemon after policy and
|
||||
# ACL setup. Confirm an ordinary enable can reverse this disable.
|
||||
subprocess.run(['systemctl', '--root=' + str(staged), 'enable', 'usbguard.service'],
|
||||
check=True, capture_output=True, text=True)
|
||||
assert (staged / enabled_path).is_symlink()
|
||||
print('ok - factory USB enrollment staging: ' + scenario)
|
||||
PY
|
||||
Reference in new issue
Block a user