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"):
+244
View File
@@ -0,0 +1,244 @@
"""Release packaging is unsigned; only the explicit offline command may sign."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
AGENT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("package_release", AGENT / "package_release.py")
PACKAGER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKAGER)
SPEC = importlib.util.spec_from_file_location("sign_release", AGENT / "sign_release.py")
SIGNER = importlib.util.module_from_spec(SPEC)
with patch.dict(sys.modules, {"package_release": PACKAGER}):
SPEC.loader.exec_module(SIGNER)
SPEC = importlib.util.spec_from_file_location("signed_pipeline_updater", AGENT / "ec_agent_update.py")
UPDATER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(UPDATER)
class ReleaseFixture:
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.base = Path(self.temporary.name).resolve()
self.source = self.base / "source"
self.source.mkdir()
self.target = self.base / "release"
self.gnupghome = self.base / "offline-keyring"
self.gnupghome.mkdir(mode=0o700)
self.fingerprint = "A" * 40
(self.source / "ec_agent.py").write_text('VERSION = "4.2.0"\nraise RuntimeError("Do not run")\n')
(self.source / "ec_agent_update.py").write_text('raise RuntimeError("Do not run")\n')
(self.source / "install.sh").write_text("#!/bin/bash\nexit 1\n")
PACKAGER.package(self.source, self.target)
def tearDown(self):
self.temporary.cleanup()
class PackagedReleaseTests(ReleaseFixture, unittest.TestCase):
def test_manifest_is_deterministic_and_covers_every_executable(self):
first = (self.target / "manifest.json").read_bytes()
PACKAGER.package(self.source, self.target)
self.assertEqual(first, (self.target / "manifest.json").read_bytes())
manifest = json.loads(first)
self.assertEqual(manifest["updaterProtocolVersion"], 2)
for field, filename in (("sha256", "ec-agent.py"),
("updaterSha256", "ec-agent-update.py"),
("installerSha256", "install.sh")):
self.assertEqual(manifest[field], hashlib.sha256((self.target / filename).read_bytes()).hexdigest())
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_packaging_preserves_signature_bytes_only_for_identical_manifest(self):
signature = self.target / "manifest.json.sig"
for filename in ("ec_agent.py", "ec_agent_update.py", "install.sh"):
with self.subTest(filename=filename):
signature.write_bytes(b"offline signature")
with patch.object(subprocess, "run", side_effect=AssertionError("Builds must never sign")):
PACKAGER.package(self.source, self.target)
self.assertEqual(signature.read_bytes(), b"offline signature")
source = self.source / filename
source.write_bytes(source.read_bytes() + b"# reviewed change\n")
PACKAGER.package(self.source, self.target)
self.assertFalse(signature.exists())
def test_orphaned_signature_is_removed_when_manifest_was_missing(self):
(self.target / "manifest.json").unlink()
(self.target / "manifest.json.sig").write_bytes(b"orphaned signature")
PACKAGER.package(self.source, self.target)
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_signer_requires_full_fingerprint_before_invoking_gpg(self):
for fingerprint in ("", "release@example.com", "A" * 16, "Z" * 40, "A" * 41):
with self.subTest(fingerprint=fingerprint), patch.object(subprocess, "run") as run:
with self.assertRaisesRegex(ValueError, "full OpenPGP key fingerprint"):
SIGNER.sign_release(self.target, self.gnupghome, fingerprint)
run.assert_not_called()
def test_signer_rejects_private_key_homes_in_repository_or_release(self):
for home in (AGENT, self.target, self.target / "private"):
with self.subTest(home=home), patch.object(subprocess, "run") as run:
with self.assertRaisesRegex(ValueError, "outside the repository and release"):
SIGNER.sign_release(self.target, home, self.fingerprint)
run.assert_not_called()
def test_signer_refuses_unreviewed_or_inconsistent_release_bytes(self):
for filename in ("ec-agent.py", "ec-agent-update.py", "install.sh", "manifest.json"):
with self.subTest(filename=filename), patch.object(subprocess, "run") as run:
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"\n")
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
run.assert_not_called()
path.write_bytes(original)
def test_failed_signing_does_not_replace_existing_signature(self):
signature = self.target / "manifest.json.sig"
signature.write_bytes(b"previous valid signature")
with patch.object(subprocess, "run", side_effect=subprocess.CalledProcessError(2, "gpg")):
with self.assertRaises(subprocess.CalledProcessError):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
self.assertEqual(signature.read_bytes(), b"previous valid signature")
self.assertEqual(list(self.target.glob(".sign-release-*")), [])
def test_release_mutation_during_signing_never_publishes_a_signature(self):
def mutate(command, **kwargs):
Path(command[command.index("--output") + 1]).write_bytes(b"-----BEGIN PGP SIGNATURE-----\nstaged\n")
(self.target / "ec-agent.py").write_bytes(b'VERSION = "9.0.0"\n')
with patch.object(subprocess, "run", side_effect=mutate):
with self.assertRaisesRegex(ValueError, "does not match packaged artifacts"):
SIGNER.sign_release(self.target, self.gnupghome, self.fingerprint)
self.assertFalse((self.target / "manifest.json.sig").exists())
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG required for real offline signing tests")
class OfflineSigningTests(ReleaseFixture, unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.keys = tempfile.TemporaryDirectory(prefix="ec-sign-")
cls.keyhome = Path(cls.keys.name)
cls.keyhome.chmod(0o700)
command = ["gpg", "--no-options", "--homedir", str(cls.keyhome), "--batch"]
subprocess.run(command + ["--pinentry-mode", "loopback", "--passphrase", "",
"--quick-generate-key", "Ephemeral release test <release@example.invalid>",
"ed25519", "sign", "0"], check=True, capture_output=True)
listing = subprocess.run(command + ["--with-colons", "--list-secret-keys"],
check=True, capture_output=True, text=True).stdout
cls.key_fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:"))
cls.public_key = subprocess.run(command + ["--export", cls.key_fingerprint],
check=True, capture_output=True).stdout
@classmethod
def tearDownClass(cls):
if shutil.which("gpgconf"):
subprocess.run(["gpgconf", "--homedir", str(cls.keyhome), "--kill", "gpg-agent"],
check=False, capture_output=True)
cls.keys.cleanup()
def setUp(self):
super().setUp()
self.keyring = self.base / "release-signing.gpg"
self.keyring.write_bytes(self.public_key)
self.update_dir = self.base / "update"
self.update_dir.mkdir(mode=0o700)
original_lstat = Path.lstat
protected_paths = {self.keyring, *self.keyring.parents, self.update_dir}
def root_owned(path):
info = original_lstat(path)
if path not in protected_paths:
return info
fields = list(info)
fields[4] = 0
if stat.S_ISDIR(fields[0]):
fields[0] &= ~0o022
return os.stat_result(fields)
self.root_owned = root_owned
def verify_installer(self):
blocks = re.findall(r"<<'PY'\n(.*?)\nPY", (AGENT / "install.sh").read_text(), re.DOTALL)
release = next(block for block in blocks if "# INSTALLATION_RELEASE:" in block)
argv = ["-", str(self.target / "manifest.json"), str(self.target / "ec-agent.py"),
str(self.target / "ec-agent-update.py"), str(self.keyring)]
# Only filesystem ownership is simulated; real GnuPG verifies the
# separately exported public key and the offline signer's exact bytes.
with patch.object(sys, "argv", argv), patch.object(Path, "lstat", self.root_owned):
exec(compile(release, "installer-release", "exec"), {})
def verify_updater(self):
with patch.object(UPDATER, "RELEASE_KEYRING", self.keyring), \
patch.object(UPDATER, "UPDATE_DIR", self.update_dir), \
patch.object(Path, "lstat", self.root_owned):
UPDATER.verify_release_signature((self.target / "manifest.json").read_bytes(),
(self.target / "manifest.json.sig").read_bytes())
def verify(self):
return subprocess.run([
"gpg", "--no-options", "--homedir", str(self.keyhome), "--batch", "--verify",
str(self.target / "manifest.json.sig"), str(self.target / "manifest.json"),
], capture_output=True)
def test_offline_signature_verifies_exact_bytes_and_survives_identical_build(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
signature = (self.target / "manifest.json.sig").read_bytes()
self.assertTrue(signature.startswith(b"-----BEGIN PGP SIGNATURE-----\n"))
self.assertEqual(self.verify().returncode, 0)
PACKAGER.package(self.source, self.target)
self.assertEqual((self.target / "manifest.json.sig").read_bytes(), signature)
self.assertEqual(self.verify().returncode, 0)
with (self.target / "manifest.json").open("ab") as manifest:
manifest.write(b" ")
self.assertNotEqual(self.verify().returncode, 0)
def test_unknown_signing_key_fails_without_publishing_a_signature(self):
with self.assertRaises(subprocess.CalledProcessError):
SIGNER.sign_release(self.target, self.keyhome, "F" * 40)
self.assertFalse((self.target / "manifest.json.sig").exists())
def test_offline_signed_release_is_accepted_by_installer_and_updater(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
self.verify_installer()
self.verify_updater()
def test_controller_cannot_authorize_changed_code_by_rehashing_manifest(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
for filename in ("ec-agent.py", "ec-agent-update.py"):
with self.subTest(filename=filename):
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"# controller's unauthorized change\n")
(self.target / "manifest.json").write_bytes(PACKAGER.manifest_bytes(
(self.target / "ec-agent.py").read_bytes(),
(self.target / "ec-agent-update.py").read_bytes(),
(self.target / "install.sh").read_bytes()))
with self.assertRaisesRegex(SystemExit, "signature verification failed"):
self.verify_installer()
with self.assertRaisesRegex(UPDATER.IntegrityError, "signature is invalid"):
self.verify_updater()
path.write_bytes(original)
def test_signed_manifest_rejects_changed_agent_and_updater_downloads(self):
SIGNER.sign_release(self.target, self.keyhome, self.key_fingerprint)
for filename, message in (("ec-agent.py", "Agent release checksum"),
("ec-agent-update.py", "Updater release checksum")):
with self.subTest(filename=filename):
path = self.target / filename
original = path.read_bytes()
path.write_bytes(original + b"# controller's unauthorized change\n")
with self.assertRaisesRegex(SystemExit, message):
self.verify_installer()
path.write_bytes(original)
if __name__ == "__main__":
unittest.main()
+153 -6
View File
@@ -5,7 +5,9 @@ import io
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import tempfile
import time
import unittest
@@ -41,6 +43,7 @@ class UpdateTests(unittest.TestCase):
directory.mkdir(parents=True)
paths = {
"AGENT_CONFIG": self.config_dir / "agent.json",
"RELEASE_KEYRING": self.config_dir / "release-signing.gpg",
"AGENT_EXECUTABLE": self.bin_dir / "electric-cherry-agent.py",
"UNINSTALL_EXECUTABLE": self.bin_dir / "electric-cherry-uninstall.py",
"UNINSTALL_REQUEST": self.config_dir / "decommission.json",
@@ -62,9 +65,12 @@ class UpdateTests(unittest.TestCase):
if directory and not path.is_dir() or not directory and not path.is_file():
raise module.UpdateError("Wrong file type")
return path
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl)])
self.signature_patch = patch.object(module, "verify_release_signature")
self.patches.extend([patch.object(module, "trusted", side_effect=trusted_for_test), patch.object(module, "sync_parent"), patch.object(module, "systemctl", side_effect=self.systemctl), self.signature_patch])
for item in self.patches:
item.start()
mocked = item.start()
if item is self.signature_patch:
self.signature_verifier = mocked
self.config = {"hostId": "host-one", "root": str(self.root), "controller": "https://controller.example", "credential": "secret-enrollment-credential"}
self.config_bytes = json.dumps(self.config).encode()
module.AGENT_CONFIG.write_bytes(self.config_bytes)
@@ -78,7 +84,8 @@ class UpdateTests(unittest.TestCase):
self.write_health = True
self.downloads = []
self.source = CANDIDATE
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 1, "platform": "debian-amd64"}
self.signature = b"detached signature"
self.release = {"version": "1.1.0", "sha256": module.sha256(CANDIDATE), "protocolVersion": 1, "updaterProtocolVersion": 2, "platform": "debian-amd64"}
def tearDown(self):
for item in reversed(self.patches):
@@ -108,6 +115,10 @@ class UpdateTests(unittest.TestCase):
self.downloads.append(path)
if path.endswith("manifest.json"):
return json.dumps(self.release).encode()
if path.endswith("manifest.json.sig"):
if isinstance(self.signature, Exception):
raise self.signature
return self.signature
if path.endswith("ec-agent.py"):
return self.source
self.fail("Unexpected download " + path)
@@ -128,6 +139,7 @@ class UpdateTests(unittest.TestCase):
def test_verified_upgrade_waits_for_authenticated_candidate_health(self):
self.assertEqual(self.updater().run(), "updated")
self.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature)
self.assertEqual(module.AGENT_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.UNINSTALL_EXECUTABLE.read_bytes(), CANDIDATE)
self.assertEqual(module.AGENT_CONFIG.read_bytes(), self.config_bytes)
@@ -137,6 +149,36 @@ class UpdateTests(unittest.TestCase):
self.assertFalse((module.UPDATE_DIR / "agent.backup.py").exists())
self.assertEqual(next(command for command in self.commands if command[0] != "show"), ("stop", module.AGENT_SERVICE))
def test_invalid_signature_rejects_attacker_source_and_matching_manifest_checksum(self):
self.source = b'VERSION = "1.1.0"\nraise RuntimeError("attacker code")\n'
self.release["sha256"] = module.sha256(self.source)
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
self.assertEqual(self.commands, [])
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
self.assertFalse(module.UPDATE_JOURNAL.exists())
self.assertFalse((module.UPDATE_DIR / "failed-release.json").exists())
self.assertNotIn("targetVersion", self.status())
def test_signature_is_verified_before_manifest_json_is_parsed(self):
updater = self.updater()
updater.fetch = lambda path, limit: b"untrusted invalid JSON" if path.endswith("manifest.json") else self.signature
self.signature_verifier.side_effect = module.IntegrityError("Invalid release signature")
with patch.object(module.json, "loads", side_effect=AssertionError("Parsed unsigned metadata")):
with self.assertRaisesRegex(module.IntegrityError, "Invalid release signature"):
updater.manifest()
self.signature_verifier.assert_called_once_with(b"untrusted invalid JSON", self.signature)
def test_missing_signature_never_downloads_code_or_stops_agent(self):
self.signature = module.ContactError("No signature is published")
self.assertEqual(self.updater().run(), "failed")
self.assertIn("signature", self.status()["error"])
self.assert_unchanged()
self.assertEqual(self.commands, [])
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
self.signature_verifier.assert_not_called()
def test_equal_or_older_versions_do_not_download_or_restart(self):
for target in ("1.0.0", "0.9.9"):
with self.subTest(target=target):
@@ -207,7 +249,7 @@ class UpdateTests(unittest.TestCase):
self.downloads.clear()
self.source = CANDIDATE
self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"])
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
def test_invalid_or_mismatched_python_version_is_never_installed(self):
for source in (b'VERSION = "1.1.0"\ninvalid syntax ?\n', b'VERSION = "5.0.0"\n'):
@@ -220,7 +262,7 @@ class UpdateTests(unittest.TestCase):
def test_incompatible_protocol_platform_or_version_never_downloads_code(self):
original = dict(self.release)
for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 2)):
for key, value in (("protocolVersion", 2), ("protocolVersion", True), ("platform", "linux-arm64"), ("version", "1.1.0-rc1"), ("version", "01.1.0"), ("updaterProtocolVersion", 1), ("updaterProtocolVersion", 3)):
with self.subTest(key=key, value=value):
self.release = {**original, key: value}
self.assertIn(self.updater().run(), ("failed", "deferred"))
@@ -239,7 +281,7 @@ class UpdateTests(unittest.TestCase):
self.assertEqual(sum(command == ("start", module.AGENT_SERVICE) for command in self.commands), 2)
self.downloads.clear()
self.assertEqual(self.updater().run(), "failed")
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json"])
self.assertEqual(self.downloads, ["/api/agent/release/manifest.json", "/api/agent/release/manifest.json.sig"])
def test_changed_artifact_of_failed_target_can_be_retried(self):
updater = self.updater()
@@ -266,9 +308,12 @@ class UpdateTests(unittest.TestCase):
def test_crash_between_executable_replacements_recovers_from_backups(self):
self.crash_during_second_replacement()
self.downloads.clear()
self.signature_verifier.reset_mock()
self.signature_verifier.side_effect = module.IntegrityError("Signing keyring unavailable during recovery")
self.assertEqual(self.updater().run(), "failed")
self.assert_unchanged()
self.assertEqual(self.downloads, [])
self.signature_verifier.assert_not_called()
self.assertTrue(self.active)
self.assertFalse(module.UPDATE_JOURNAL.exists())
@@ -413,6 +458,108 @@ class UpdateSecurityTests(unittest.TestCase):
with self.subTest(value=value), self.assertRaises(module.IntegrityError):
module.version(value)
def test_release_keyring_rejects_untrusted_ownership_permissions_and_file_types(self):
for mode, uid in ((stat.S_IFREG | 0o644, 1000), (stat.S_IFREG | 0o664, 0), (stat.S_IFREG | 0o646, 0), (stat.S_IFLNK | 0o777, 0), (stat.S_IFDIR | 0o755, 0)):
def file_info(path):
selected_mode, selected_uid = (mode, uid) if path == module.RELEASE_KEYRING else (stat.S_IFDIR | 0o755, 0)
return os.stat_result((selected_mode, 1, 1, 1, selected_uid, 0, 0, 0, 0, 0))
with self.subTest(mode=mode, uid=uid), patch.object(Path, "lstat", autospec=True, side_effect=file_info), patch.object(module.subprocess, "run") as run:
with self.assertRaisesRegex(module.IntegrityError, "keyring is missing or unsafe"):
module.verify_release_signature(b"manifest", b"signature")
run.assert_not_called()
class SignatureVerificationFixture:
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.base = Path(self.temporary.name)
self.keyring = self.base / "release-signing.gpg"
self.keyring.write_bytes(b"trusted host public keys")
self.update_dir = self.base / "update"
self.update_dir.mkdir(mode=0o700)
for name, value in (("RELEASE_KEYRING", self.keyring), ("UPDATE_DIR", self.update_dir)):
entry = patch.object(module, name, value)
entry.start()
self.addCleanup(entry.stop)
# Test fixtures are owned by the test runner rather than system root.
# Root ownership and unsafe modes are checked independently above.
entry = patch.object(module, "trusted", side_effect=lambda path, **_: Path(path))
self.trusted = entry.start()
self.addCleanup(entry.stop)
class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
def test_gpgv_uses_only_host_keyring_and_exact_bytes_with_an_isolated_home(self):
manifest = b'{"version": "1.2.3"}\n'
signature = b"binary detached signature\x00\xff"
def verify(command, **kwargs):
home = Path(command[2])
self.assertEqual(command[:2], ["gpgv", "--homedir"])
self.assertEqual(command[3:5], ["--keyring", str(self.keyring)])
self.assertEqual(home.parent, self.update_dir)
self.assertEqual(stat.S_IMODE(home.stat().st_mode), 0o700)
self.assertEqual(Path(command[5]).read_bytes(), signature)
self.assertEqual(Path(command[6]).read_bytes(), manifest)
self.assertEqual(kwargs["timeout"], 30)
self.assertEqual(kwargs["stdin"], subprocess.DEVNULL)
return subprocess.CompletedProcess(command, 0)
with patch.object(module.subprocess, "run", side_effect=verify) as run:
module.verify_release_signature(manifest, signature)
run.assert_called_once()
self.assertEqual(self.trusted.call_args_list[0].args, (self.keyring,))
self.assertEqual(list(self.update_dir.iterdir()), [])
def test_missing_keyring_or_signature_never_runs_a_verifier(self):
with patch.object(module.subprocess, "run") as run:
self.trusted.side_effect = module.UpdateError("Missing keyring")
with self.assertRaisesRegex(module.IntegrityError, "keyring"):
module.verify_release_signature(b"manifest", b"signature")
self.trusted.side_effect = lambda path, **_: Path(path)
with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"):
module.verify_release_signature(b"manifest", b"")
run.assert_not_called()
def test_bad_unknown_or_unavailable_verifier_fails_closed_and_cleans_temporary_files(self):
for result in (subprocess.CompletedProcess([], 1), subprocess.CompletedProcess([], 2), FileNotFoundError("gpgv missing"), subprocess.TimeoutExpired("gpgv", 30)):
with self.subTest(result=result):
kwargs = {"side_effect": result} if isinstance(result, Exception) else {"return_value": result}
with patch.object(module.subprocess, "run", **kwargs):
with self.assertRaises(module.IntegrityError):
module.verify_release_signature(b"manifest", b"signature")
self.assertEqual(list(self.update_dir.iterdir()), [])
@unittest.skipUnless(shutil.which("gpg") and shutil.which("gpgv"), "GnuPG signing and verification tools are required")
class RealSignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.keys = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.keys.cleanup)
cls.homes = []
for index in range(2):
home = Path(cls.keys.name) / str(index)
home.mkdir(mode=0o700)
cls.homes.append(home)
subprocess.run(["gpg", "--homedir", str(home), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", f"Release Test {index} <release-{index}@example.test>", "ed25519", "sign", "0"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if shutil.which("gpgconf"):
cls.addClassCleanup(subprocess.run, ["gpgconf", "--homedir", str(home), "--kill", "gpg-agent"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
cls.public_key = subprocess.run(["gpg", "--homedir", str(cls.homes[0]), "--batch", "--export"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
def sign(self, manifest, index=0):
return subprocess.run(["gpg", "--homedir", str(self.homes[index]), "--batch", "--pinentry-mode", "loopback", "--passphrase", "", "--digest-algo", "SHA512", "--detach-sign"], input=manifest, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True).stdout
def test_real_signatures_authenticate_exact_manifest_and_reject_unknown_keys(self):
self.keyring.write_bytes(self.public_key)
manifest = b'{"version":"1.1.0","sha256":"trusted-code-checksum"}\n'
signature = self.sign(manifest)
module.verify_release_signature(manifest, signature)
for changed_manifest, changed_signature in ((manifest + b" ", signature), (manifest.replace(b"trusted-code-checksum", b"attacker-code-checksum"), signature), (manifest, signature[:-1]), (manifest, self.sign(manifest, 1))):
with self.subTest(manifest=changed_manifest, signature=changed_signature):
with self.assertRaises(module.IntegrityError):
module.verify_release_signature(changed_manifest, changed_signature)
self.assertEqual(list(self.update_dir.iterdir()), [])
if __name__ == "__main__":
unittest.main()