From 7f5db57ad71eb268e796cbc95a8f20954e9d516e Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Mon, 7 Sep 2026 23:21:49 -0700 Subject: [PATCH] Protect system setup with packaged integrity-bound Polkit helper --- README.md | 22 +++++- bin/marchybar | 4 +- bin/system-action.py | 34 ++++++++++ packaging/PKGBUILD | 17 +++++ packaging/install-system.sh | 4 +- packaging/org.marchybar.system.policy | 14 ++++ packaging/setup-helper | 58 ++++++++++++++++ tests/broker_test.py | 1 + tests/system_action_test.py | 98 +++++++++++++++++++++++++++ 9 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 bin/system-action.py create mode 100644 packaging/PKGBUILD create mode 100644 packaging/org.marchybar.system.policy create mode 100644 packaging/setup-helper create mode 100644 tests/system_action_test.py diff --git a/README.md b/README.md index 0a0ffb3..0409086 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,22 @@ omarchy plugin add https://github.com/Githubguy132010/MarchyBar --enable The first public release is **v0.1.0**. See [releases](https://github.com/Githubguy132010/MarchyBar/releases) for changes. -Click **▰** in the Omarchy bar. The first launch builds a small native renderer in your user cache; subsequent launches reuse it. Open **Device → Set up Touch Bar**, authenticate the one-time helper installation, and enable the bar. An existing `tiny-dfr` or `touchbard` process must be stopped before enabling MarchyBar. +Click **▰** in the Omarchy bar. The first launch builds a small native renderer in your user cache; subsequent launches reuse it. Install the trusted system package described below, then open **Device → Set up Touch Bar**, authenticate the device configuration, and enable the bar. An existing `tiny-dfr` or `touchbard` process must be stopped before enabling MarchyBar. -The plugin's standard Omarchy installation never executes a privileged hook. The separate setup installs exactly: +### Install the protected system helper + +The plugin cannot bootstrap privileged code from its user-writable checkout. An administrator must first install a matching, independently trusted `marchybar-system` package. No published signed binary package is provided yet. For a local build, obtain and review a separate source snapshot, review `packaging/PKGBUILD` and its inputs, then build as an ordinary user: + +```sh +cd packaging +makepkg +``` + +Have the administrator install the reviewed package with `pacman -U /path/to/marchybar-system-0.1.0-1-any.pkg.tar.zst`. Do not use the live plugin checkout as a trusted source or run its installer with sudo/pkexec. Local checksums bind the reviewed inputs; they do not authenticate an untrusted download or protect a compromised build session. + +The package installs root-owned files under `/usr/lib/marchybar-system/` and a dedicated policy at `/usr/share/polkit-1/actions/org.marchybar.system.policy`. Package installation does not start the service. Setup accepts only `setup` or `remove`, checks the helper's canonical path, ownership, mode and SHA-256 before calling `/usr/bin/pkexec`, and requires fresh administrator authentication for an active session. The protected helper verifies its fixed payload hashes and never reads code from the plugin checkout. Python runs in isolated mode and the privileged shell receives a fixed environment. + +The plugin's standard Omarchy installation never executes a privileged hook. The separate setup deploys exactly: - `/usr/local/lib/marchybar/device-broker.py` - `/etc/systemd/system/marchybar-device.service` @@ -105,7 +118,7 @@ Use **Device → Update MarchyBar** or `marchybar update` for repository updates When the plugin revision changes, MarchyBar restarts the shell to load the new code. An enabled Touch Bar returns after the session unlocks; an intentionally disabled bar stays disabled. If the shell cannot restart, the updater reports the pending restart and the command to run after unlocking. A failed plugin update or restart stops the combined workflow. Omarchy may restart the shell again or offer a reboot. Updates run directly through `omarchy plugin update` still need a shell restart to replace MarchyBar's retained service. -If a release changes the device helper, run **Set up Touch Bar** again. Updates never install privileged helper files automatically. The source, preset, and control protocol versions are explicit. +If a release changes the device helper, have the administrator install the matching reviewed system package, then run **Set up Touch Bar** again. Mismatched helper hashes fail closed. Updates never install privileged helper files automatically. The source, preset, and control protocol versions are explicit. ## Disable or uninstall @@ -117,6 +130,8 @@ marchybar uninstall-system omarchy plugin remove marchybar.touchbar --yes ``` +After `uninstall-system` succeeds, the administrator may remove `marchybar-system` with the package manager. Keep the package installed until device cleanup succeeds. + User presets are deliberately retained. Remove `~/.config/marchybar` separately only if you want to erase them. ## Development @@ -126,6 +141,7 @@ npm ci --ignore-scripts npm run build npm test python tests/broker_test.py +python tests/system_action_test.py tests/test-qml.sh ``` diff --git a/bin/marchybar b/bin/marchybar index 10be066..1d9d0de 100755 --- a/bin/marchybar +++ b/bin/marchybar @@ -52,9 +52,9 @@ case "$action" in daemon) prepare; exec node "$MARCHYBAR_ROOT/backend/server.mjs" "$@" ;; preview) prepare; exec node "$MARCHYBAR_ROOT/backend/server.mjs" --preview "$@" ;; open) exec omarchy-shell shell summon marchybar.touchbar '{}' ;; - setup) exec pkexec /bin/bash "$MARCHYBAR_ROOT/packaging/install-system.sh" ;; + setup) exec /usr/bin/python3 -I "$MARCHYBAR_ROOT/bin/system-action.py" setup "$@" ;; update) exec /bin/bash "$MARCHYBAR_ROOT/bin/update.sh" "$@" ;; - uninstall-system) exec pkexec /bin/bash "$MARCHYBAR_ROOT/packaging/install-system.sh" --remove ;; + uninstall-system) exec /usr/bin/python3 -I "$MARCHYBAR_ROOT/bin/system-action.py" remove "$@" ;; help|--help|-h) cat <<'HELP' MarchyBar — native Omarchy Touch Bar configuration diff --git a/bin/system-action.py b/bin/system-action.py new file mode 100644 index 0000000..716ad17 --- /dev/null +++ b/bin/system-action.py @@ -0,0 +1,34 @@ +#!/usr/bin/python3 -I +"""Check the exact installed helper before requesting its dedicated Polkit action.""" +import hashlib +import os +from pathlib import Path +import stat +import sys + +HELPER = Path('/usr/lib/marchybar-system/setup-helper') +EXPECTED_SHA256 = 'a998950fa104fb601643665fde7cda963e6a8ea87379ac26a98e9c8c9c8c33b6' + +def main(): + if sys.argv[1:] not in [['setup'], ['remove']]: + raise ValueError('Expected setup or remove, with no other arguments') + for item in [*reversed(HELPER.parents), HELPER]: + info = item.lstat() + if stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise ValueError(f'Untrusted helper path: {item}') + if item != HELPER and not stat.S_ISDIR(info.st_mode): + raise ValueError(f'Not a directory: {item}') + if HELPER.resolve(strict=True) != HELPER or not stat.S_ISREG(info.st_mode) or not info.st_mode & 0o111: + raise ValueError('Invalid helper executable') + if hashlib.sha256(HELPER.read_bytes()).hexdigest() != EXPECTED_SHA256: + raise ValueError('Installed helper version/integrity does not match this plugin') + # All ancestors and the executable are protected from session-user replacement. + os.execve('/usr/bin/pkexec', ['/usr/bin/pkexec', str(HELPER), sys.argv[1]], + {'PATH': '/usr/bin:/bin', 'LANG': 'C'}) + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError) as error: + print(f'MarchyBar setup refused: {error}. Install the matching trusted system package; see README.md.', file=sys.stderr) + sys.exit(1) diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..aae5758 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,17 @@ +# Build as an ordinary user from a separately reviewed, trusted source snapshot. +# No install hook: installing the package does not enable the device service. +pkgname=marchybar-system +pkgver=0.1.0 +pkgrel=1 +pkgdesc='Root-owned, integrity-bound MarchyBar device setup helper' +arch=('any') +license=('GPL-3.0-or-later') +depends=('python' 'python-gobject' 'bash' 'polkit' 'systemd' 'acl') +source=('install-system.sh' 'device-broker.py' 'marchybar-device.service' '90-marchybar.rules' 'setup-helper' 'org.marchybar.system.policy') +sha256sums=('4a70f10820534a6aeb3aa5f79bb180d683f5cde23a4223d50e24d7e2f134873a' '7addc1e2c37aa098622b96f5b0afa4b0189ff54b538cad50b77b59d99ead839e' 'f5a2a3401bf6edffd6d1623606e741dbe8967a1f10420d0ec2540678ac8b16cd' '42b0e793f1f17b4cb992e2348fcac16dca353b76faad452a0348bb02e440ee78' 'a998950fa104fb601643665fde7cda963e6a8ea87379ac26a98e9c8c9c8c33b6' '29d6dcf145f75bfe310ad565410d1b2f56139287f007931e588c4afc7f5b5da8') +package() { + install -dm755 "$pkgdir/usr/lib/marchybar-system" + install -m755 setup-helper "$pkgdir/usr/lib/marchybar-system/setup-helper" + install -m644 install-system.sh device-broker.py marchybar-device.service 90-marchybar.rules "$pkgdir/usr/lib/marchybar-system/" + install -Dm644 org.marchybar.system.policy "$pkgdir/usr/share/polkit-1/actions/org.marchybar.system.policy" +} diff --git a/packaging/install-system.sh b/packaging/install-system.sh index 225f515..3bb4ea0 100755 --- a/packaging/install-system.sh +++ b/packaging/install-system.sh @@ -2,7 +2,9 @@ # Installs only the device-specific helper. The renderer always runs as the user. set -euo pipefail [[ $EUID == 0 ]] || { echo 'Run marchybar setup from the editor or command line.' >&2; exit 1; } -MARCHYBAR_PACKAGE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +export PATH=/usr/bin:/bin +MARCHYBAR_PACKAGE=/usr/lib/marchybar-system +[[ $# == 0 || ( $# == 1 && $1 == --remove ) ]] || { echo 'Unknown setup argument' >&2; exit 1; } if [[ ${1:-} == --remove ]]; then helper_state() { local properties key value diff --git a/packaging/org.marchybar.system.policy b/packaging/org.marchybar.system.policy new file mode 100644 index 0000000..cf7a466 --- /dev/null +++ b/packaging/org.marchybar.system.policy @@ -0,0 +1,14 @@ + + + + + Configure the MarchyBar Touch Bar device service + Authentication is required to configure the MarchyBar device service + + no + no + auth_admin + + /usr/lib/marchybar-system/setup-helper + + diff --git a/packaging/setup-helper b/packaging/setup-helper new file mode 100644 index 0000000..9c1cc11 --- /dev/null +++ b/packaging/setup-helper @@ -0,0 +1,58 @@ +#!/usr/bin/python3 -I +"""Fixed-scope system setup; deployed only through the administrator's package manager.""" +import hashlib +import os +from pathlib import Path +import stat +import sys + +ROOT = Path('/usr/lib/marchybar-system') +PAYLOAD = {'install-system.sh': '4a70f10820534a6aeb3aa5f79bb180d683f5cde23a4223d50e24d7e2f134873a', 'device-broker.py': '7addc1e2c37aa098622b96f5b0afa4b0189ff54b538cad50b77b59d99ead839e', 'marchybar-device.service': 'f5a2a3401bf6edffd6d1623606e741dbe8967a1f10420d0ec2540678ac8b16cd', '90-marchybar.rules': '42b0e793f1f17b4cb992e2348fcac16dca353b76faad452a0348bb02e440ee78'} + +def trusted(path, executable=False): + path = Path(path) + for item in [*reversed(path.parents), path]: + info = item.lstat() + if stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise ValueError(f'Untrusted system path: {item}') + if item != path and not stat.S_ISDIR(info.st_mode): + raise ValueError(f'Not a directory: {item}') + if not stat.S_ISREG(info.st_mode) or (executable and not info.st_mode & 0o111): + raise ValueError(f'Invalid system file: {path}') + return path + +def main(): + if os.geteuid() != 0 or sys.argv[1:] not in [['setup'], ['remove']]: + raise ValueError('Expected authenticated setup or remove, with no other arguments') + if Path(__file__) != ROOT / 'setup-helper': + raise ValueError('Helper must be installed by the system package manager') + trusted(__file__, executable=True) + for name, digest in PAYLOAD.items(): + file = trusted(ROOT / name) + if hashlib.sha256(file.read_bytes()).hexdigest() != digest: + raise ValueError(f'Helper payload integrity mismatch: {name}') + trusted('/usr/bin/bash', executable=True) + # Fail closed on writable/symlinked destination ancestors or existing files. + for name in ['/usr/local/lib/marchybar/device-broker.py', + '/etc/systemd/system/marchybar-device.service', + '/etc/udev/rules.d/90-marchybar.rules']: + file = Path(name) + for item in [*reversed(file.parents), file]: + if not item.exists() and not item.is_symlink(): + continue + info = item.lstat() + if stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: + raise ValueError(f'Untrusted destination: {item}') + os.chdir('/') + os.umask(0o077) + args = ['/usr/bin/bash', '--noprofile', '--norc', str(ROOT / 'install-system.sh')] + if sys.argv[1] == 'remove': + args.append('--remove') + os.execve(args[0], args, {'PATH': '/usr/bin:/bin', 'LANG': 'C'}) + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError) as error: + print(f'MarchyBar setup refused: {error}', file=sys.stderr) + sys.exit(1) diff --git a/tests/broker_test.py b/tests/broker_test.py index 8795d4b..39b89fe 100644 --- a/tests/broker_test.py +++ b/tests/broker_test.py @@ -10,6 +10,7 @@ import unittest from unittest.mock import patch from setup_test import SetupTest +from system_action_test import SystemActionTest spec = importlib.util.spec_from_file_location('broker', Path(__file__).parents[1] / 'packaging/device-broker.py') broker = importlib.util.module_from_spec(spec) diff --git a/tests/system_action_test.py b/tests/system_action_test.py new file mode 100644 index 0000000..431b702 --- /dev/null +++ b/tests/system_action_test.py @@ -0,0 +1,98 @@ +"""Hardware-free privilege boundary regression tests; never invoke real pkexec.""" +import hashlib +import importlib.machinery +import importlib.util +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import types +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).parents[1] + +def load(name, path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + +class SystemActionTest(unittest.TestCase): + def test_installer_replacement_cannot_execute_privileged_code(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'bin').mkdir() + (root / 'packaging').mkdir() + for name in ['marchybar', 'system-action.py']: + shutil.copy(ROOT / 'bin' / name, root / 'bin' / name) + marker = root / 'executed' + (root / 'packaging/install-system.sh').write_text(f'#!/bin/bash\ntouch {marker}\n') + module = load('launcher', root / 'bin/system-action.py') + # Fixture is deliberately user-writable, regardless of the test runner UID. + helper = root / 'replacement' + helper.write_text('untrusted') + helper.chmod(0o777) + module.HELPER = helper + with patch.object(module.os, 'execve') as execute, patch.object(module.sys, 'argv', ['launcher', 'setup']): + with self.assertRaises(ValueError): + module.main() + execute.assert_not_called() + # The actual CLI must refuse ordinary setup/removal with a missing helper, + # even when the checkout installer has been replaced by executable code. + launcher = root / 'bin/system-action.py' + launcher.write_text(launcher.read_text().replace( + "Path('/usr/lib/marchybar-system/setup-helper')", + "Path('/nonexistent/marchybar-system/setup-helper')")) + for action in ['setup', 'uninstall-system']: + result = subprocess.run(['/bin/bash', str(root / 'bin/marchybar'), action], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b'MarchyBar setup refused', result.stderr) + # Extra arguments must also be rejected before escalation. + for action in ['setup', 'uninstall-system']: + result = subprocess.run(['/bin/bash', str(root / 'bin/marchybar'), action, '--invalid'], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(marker.exists()) + + def test_exact_helper_checks_and_arguments(self): + module = load('launcher_checks', ROOT / 'bin/system-action.py') + with tempfile.TemporaryDirectory() as directory: + helper = Path(directory) / 'helper' + helper.write_bytes(b'fixture') + module.HELPER = helper + module.EXPECTED_SHA256 = hashlib.sha256(b'fixture').hexdigest() + def metadata(path): + mode = stat.S_IFREG | 0o755 if path == helper else stat.S_IFDIR | 0o755 + return types.SimpleNamespace(st_uid=0, st_mode=mode) + with patch.object(Path, 'lstat', metadata), patch.object(module.sys, 'argv', ['launcher', 'setup']), patch.object(module.os, 'execve') as execute: + module.main() + self.assertEqual(execute.call_args.args[1], ['/usr/bin/pkexec', str(helper), 'setup']) + execute.reset_mock() + helper.write_bytes(b'replaced') + with self.assertRaises(ValueError): module.main() + execute.assert_not_called() + for mode, uid in [(stat.S_IFREG | 0o777, 0), (stat.S_IFREG | 0o755, 1000), (stat.S_IFLNK | 0o777, 0)]: + def bad(path): return types.SimpleNamespace(st_uid=uid, st_mode=mode) + with patch.object(Path, 'lstat', bad), patch.object(module.sys, 'argv', ['launcher', 'setup']), patch.object(module.os, 'execve') as execute: + with self.assertRaises(ValueError): module.main() + execute.assert_not_called() + for args in [[], ['setup', 'extra'], ['remove', '/tmp/file'], ['other']]: + with patch.object(module.sys, 'argv', ['launcher', *args]), patch.object(module.os, 'execve') as execute: + with self.assertRaises(ValueError): module.main() + execute.assert_not_called() + + def test_payload_and_launcher_integrity_binding(self): + helper = load('helper', ROOT / 'packaging/setup-helper') + launcher = load('launcher_integrity', ROOT / 'bin/system-action.py') + self.assertEqual(launcher.EXPECTED_SHA256, hashlib.sha256((ROOT / 'packaging/setup-helper').read_bytes()).hexdigest()) + for name, digest in helper.PAYLOAD.items(): + self.assertEqual(digest, hashlib.sha256((ROOT / 'packaging' / name).read_bytes()).hexdigest(), name) + for args in [[], ['setup', 'extra'], ['remove', 'extra'], ['other']]: + with patch.object(helper.sys, 'argv', ['helper', *args]), patch.object(helper.os, 'execve') as execute: + with self.assertRaises(ValueError): helper.main() + execute.assert_not_called() + +if __name__ == '__main__': + unittest.main()