Require signed agent releases (Refs #64)

This commit is contained in:
2026-10-09 20:36:18 -04:00
parent df0d395138
commit 6f71822d41
24 changed files with 984 additions and 107 deletions
+58 -7
View File
@@ -11,7 +11,7 @@ import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from unittest.mock import Mock, patch
SOURCE = (Path(__file__).resolve().parents[1] / "install.sh").read_text()
@@ -33,6 +33,8 @@ class InstallerTests(unittest.TestCase):
self.root = self.base / "managed"
self.config = self.base / "configuration" / "agent.json"
self.config.parent.mkdir()
self.keyring = self.config.with_name('release-signing.gpg')
self.keyring.write_bytes(b'publisher public key')
self.controller = "https://controller.example"
self.saved = {"root": str(self.root), "controller": self.controller,
"hostId": "host-one", "credential": "private"}
@@ -80,6 +82,17 @@ class InstallerTests(unittest.TestCase):
self.preflight()
self.assertEqual((self.root / "unrelated.txt").read_text(), "keep")
def test_trust_key_must_be_preprovisioned_and_protected(self):
self.keyring.unlink()
with self.assertRaisesRegex(SystemExit, 'Provision the publisher public key'):
self.preflight()
self.keyring.write_bytes(b'publisher public key')
for override in ({'uid': 1000}, {'mode': stat.S_IFREG | 0o666}, {'mode': stat.S_IFLNK | 0o777}):
with self.subTest(override=override):
self.overrides[str(self.keyring)] = override
with self.assertRaisesRegex(SystemExit, 'regular, root-owned'):
self.preflight()
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
self.legacy_installation()
original_config = self.config.read_bytes()
@@ -337,20 +350,31 @@ docker() {
class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.source = Path(self.temporary.name) / "source"
self.target = Path(self.temporary.name) / "release"
self.source = Path(self.temporary.name).resolve() / "source"
self.target = Path(self.temporary.name).resolve() / "release"
self.source.mkdir()
(self.source / "ec_agent.py").write_text('VERSION = "3.12.7"\nraise RuntimeError("must never execute during packaging")\n')
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("must never execute during verification")\n')
(self.source / "install.sh").write_text("#!/bin/bash\n")
PACKAGER.package(self.source, self.target)
self.keyring = self.source.parent / 'release-signing.gpg'
self.keyring.write_bytes(b'publisher public key')
(self.target / 'manifest.json.sig').write_bytes(b'detached signature')
self.gpgv = Mock(return_value=subprocess.CompletedProcess([], 0))
def tearDown(self):
self.temporary.cleanup()
def verify(self):
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py")]
with patch.object(sys, "argv", argv):
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"), str(self.target / "ec-agent-update.py"), str(self.keyring)]
original_lstat = Path.lstat
def root_owned(path):
fields = list(original_lstat(path))
fields[4] = 0
if stat.S_ISDIR(fields[0]):
fields[0] &= ~0o022
return os.stat_result(fields)
with patch.object(sys, "argv", argv), patch.object(Path, 'lstat', root_owned), patch.object(subprocess, 'run', self.gpgv):
exec(compile(RELEASE, "installer-release", "exec"), {})
def manifest(self, **changes):
@@ -363,7 +387,7 @@ class ReleaseTests(unittest.TestCase):
def test_package_derives_version_and_hashes_both_sources_without_executing_them(self):
manifest = self.manifest()
self.assertEqual(manifest["version"], "3.12.7")
self.assertEqual(manifest["updaterProtocolVersion"], 1)
self.assertEqual(manifest["updaterProtocolVersion"], 2)
self.assertEqual(manifest["sha256"], hashlib.sha256((self.source / "ec_agent.py").read_bytes()).hexdigest())
self.assertEqual(manifest["updaterSha256"], hashlib.sha256((self.source / "ec_agent_update.py").read_bytes()).hexdigest())
self.assertEqual((self.target / "ec-agent-update.py").read_bytes(), (self.source / "ec_agent_update.py").read_bytes())
@@ -380,11 +404,38 @@ class ReleaseTests(unittest.TestCase):
path.write_bytes(original)
def test_installer_rejects_missing_or_unsupported_updater_protocol(self):
for protocol in (None, 0, 2, "1"):
for protocol in (None, 0, 1, True, "2"):
self.manifest(updaterProtocolVersion=protocol)
with self.subTest(protocol=protocol), self.assertRaisesRegex(SystemExit, "Unsupported agent or updater"):
self.verify()
def test_signature_is_verified_against_pinned_key_before_manifest_parsing(self):
self.verify()
args, kwargs = self.gpgv.call_args
self.assertEqual(args[0][0], 'gpgv')
self.assertEqual(args[0][-4:], ['--keyring', str(self.keyring), str(self.target / 'manifest.json.sig'), '-'])
self.assertEqual(kwargs['input'], (self.target / 'manifest.json').read_bytes())
self.assertEqual(kwargs['timeout'], 30)
self.assertFalse(Path(args[0][2]).exists())
(self.target / 'manifest.json').write_bytes(b'not even JSON')
self.gpgv.return_value = subprocess.CompletedProcess([], 1)
with self.assertRaisesRegex(SystemExit, 'signature verification failed'):
self.verify()
def test_missing_signature_key_or_gpgv_prevents_installation(self):
for path in (self.keyring, self.target / 'manifest.json.sig'):
original = path.read_bytes()
path.unlink()
self.gpgv.reset_mock()
with self.subTest(path=path), self.assertRaises(FileNotFoundError):
self.verify()
self.gpgv.assert_not_called()
path.write_bytes(original)
for failure in (FileNotFoundError(), subprocess.TimeoutExpired('gpgv', 30)):
self.gpgv.side_effect = failure
with self.subTest(failure=failure), self.assertRaisesRegex(SystemExit, 'working gpgv'):
self.verify()
def test_installer_rejects_manifest_version_mismatch_and_invalid_code(self):
self.manifest(version="3.12.8")
with self.assertRaisesRegex(SystemExit, "version does not match"):