Refresh agent signing keys from repository (Refs #64)

This commit is contained in:
2026-10-09 21:39:03 -04:00
parent 6f71822d41
commit a93ddb5ef9
14 changed files with 420 additions and 214 deletions
+52 -38
View File
@@ -27,7 +27,8 @@ import uuid
UPDATER_PROTOCOL_VERSION = 2
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
RELEASE_KEYRING = Path("/etc/electric-cherry/release-signing.gpg")
# This repository is the trust authority, independent of controller configuration.
RELEASE_KEY_URL = "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg"
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
UNINSTALL_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-uninstall.py")
UNINSTALL_REQUEST = Path("/etc/electric-cherry/decommission.json")
@@ -39,6 +40,7 @@ AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
AGENT_SERVICE = "electric-cherry-agent.service"
MAX_MANIFEST = 16 * 1024
MAX_SIGNATURE = 16 * 1024
MAX_KEYRING = 256 * 1024
MAX_SOURCE = 8 * 1024 * 1024
HEALTH_TIMEOUT = 90
VERSION_RE = re.compile(r"^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$")
@@ -153,13 +155,51 @@ def source_version(source):
return declarations[0]
def verify_release_signature(manifest, signature):
# Trust is provisioned by the host operator, never fetched from the
# controller. Check every path component before exposing it to gpgv.
def fetch_url(url, limit, source):
request = urllib.request.Request(url, headers={"User-Agent": "electric-cherry-updater/2", "Cache-Control": "no-cache"})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try:
keyring = trusted(RELEASE_KEYRING)
except (UpdateError, OSError):
raise IntegrityError("The host release-signing keyring is missing or unsafe; provision a trusted root-owned keyring") from None
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError(f"The {source} did not return a complete download")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError(f"The {source} download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError(f"The {source} exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError(f"The {source} download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError(f"The {source} is unavailable; the running agent was left unchanged") from None
def fetch_release_key():
# Fetch afresh before every release check, including checks with no new
# version. There is deliberately no persistent keyring or offline fallback.
key = fetch_url(RELEASE_KEY_URL, MAX_KEYRING, "release-signing repository")
if not key:
raise IntegrityError("The release-signing repository returned an empty keyring")
return key
def verify_release_signature(manifest, signature, public_key):
if not public_key or len(public_key) > MAX_KEYRING:
raise IntegrityError("The repository release-signing keyring is empty or too large")
if not signature:
raise IntegrityError("The controller release requires a detached manifest signature")
trusted(UPDATE_DIR, directory=True)
@@ -167,6 +207,8 @@ def verify_release_signature(manifest, signature):
# The exact downloaded bytes are verified before manifest JSON is parsed.
with tempfile.TemporaryDirectory(prefix="verify-", dir=UPDATE_DIR) as temporary:
home = Path(temporary)
keyring = home / "release-signing.gpg"
keyring.write_bytes(public_key)
manifest_path = home / "manifest.json"
signature_path = home / "manifest.json.sig"
manifest_path.write_bytes(manifest)
@@ -271,45 +313,17 @@ class Updater:
return state
def fetch(self, path, limit):
request = urllib.request.Request(self.installation["controller"] + path, headers={"User-Agent": "electric-cherry-updater/2"})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ContactError("Agent release redirects are not allowed")
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=25) as response:
if response.status != 200:
raise ContactError("The controller did not return a complete agent release")
length = response.headers.get("Content-Length")
if length is not None and (not length.isdecimal() or int(length) > limit):
raise IntegrityError("The controller release exceeds its download size limit")
chunks, total = [], 0
deadline = time.monotonic() + 120
while True:
if time.monotonic() >= deadline:
raise ContactError("The controller release download timed out")
# HTTPResponse.read1 performs one socket read. read(n) can
# wait indefinitely for n bytes from a trickling server.
block = response.read1(min(64 * 1024, limit + 1 - total))
if not block:
break
total += len(block)
if total > limit:
raise IntegrityError("The controller release exceeds its download size limit")
chunks.append(block)
if length is not None and total != int(length):
raise ContactError("The controller release download was interrupted")
return b"".join(chunks)
except (OSError, ValueError, http.client.HTTPException):
raise ContactError("The controller release is unavailable; the running agent was left unchanged") from None
return fetch_url(self.installation["controller"] + path, limit, "controller release")
def manifest(self):
public_key = fetch_release_key()
manifest = self.fetch("/api/agent/release/manifest.json", MAX_MANIFEST)
try:
signature = self.fetch("/api/agent/release/manifest.json.sig", MAX_SIGNATURE)
except ContactError:
raise IntegrityError("The controller release signature is unavailable; a signed release is required") from None
self.ensure_directory()
verify_release_signature(manifest, signature)
verify_release_signature(manifest, signature, public_key)
try:
value = json.loads(manifest)
except (ValueError, UnicodeError):
+19 -8
View File
@@ -96,11 +96,6 @@ def trusted_json(path):
trusted_directories(root)
trusted_directories(config.parent)
keyring = config.with_name('release-signing.gpg')
try:
trusted_file(keyring)
except FileNotFoundError:
raise SystemExit('Provision the publisher public key at /etc/electric-cherry/release-signing.gpg through a trusted channel before installing. Never obtain the trust key from the controller.') from None
mountinfo = pathlib.Path('/proc/1/mountinfo')
if mountinfo.exists():
for line in mountinfo.read_text().splitlines():
@@ -147,7 +142,7 @@ PY
if [ -f /etc/electric-cherry/agent.json ]; then existing_enrollment=true; fi
if [ -d "$data_dir" ]; then existing_data_root=true; fi
installation_preflight
temporary_dir=$(mktemp -d)
temporary_dir=$(mktemp -d /tmp/electric-cherry-install.XXXXXXXXXX)
updater_timer_was_active=false
update_lock_held=false
activity_lock_held=false
@@ -172,23 +167,39 @@ installation_cleanup() {
exit "$ec_install_status"
}
trap installation_cleanup EXIT
# INSTALLATION_TRUST: always fetch directly from the repository, never the controller.
key_status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
--connect-timeout 25 --max-time 120 --max-filesize 262144 \
--header 'Cache-Control: no-cache' --write-out '%{http_code}' \
'https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg' \
-o "$temporary_dir/release-signing.gpg")
[ "$key_status" = 200 ] && [ -s "$temporary_dir/release-signing.gpg" ] || {
echo 'The release-signing repository did not return a public key. Installation stopped.' >&2
exit 1
}
# INSTALLATION_TRUST_END
controller="${controller%/}"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json" -o "$temporary_dir/manifest.json"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/manifest.json.sig" -o "$temporary_dir/manifest.json.sig"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent.py" -o "$temporary_dir/ec-agent.py"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error "$controller/api/agent/release/ec-agent-update.py" -o "$temporary_dir/ec-agent-update.py"
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" /etc/electric-cherry/release-signing.gpg <<'PY'
python3 - "$temporary_dir/manifest.json" "$temporary_dir/ec-agent.py" "$temporary_dir/ec-agent-update.py" "$temporary_dir/release-signing.gpg" <<'PY'
# INSTALLATION_RELEASE: check both downloads before installing or executing either.
import ast, hashlib, json, pathlib, re, stat, subprocess, sys, tempfile
manifest_path = pathlib.Path(sys.argv[1])
manifest_bytes = manifest_path.read_bytes()
signature = manifest_path.with_name('manifest.json.sig')
keyring = pathlib.Path(sys.argv[4])
for path in (*reversed(keyring.parents), keyring):
# The key lives in our private root-owned mktemp directory under sticky /tmp.
# /tmp itself is intentionally writable; require protection of the directory
# and file we created, rather than accepting a pre-existing host keyring.
for path in (keyring.parent, keyring):
info = path.lstat()
expected_type = stat.S_ISREG if path == keyring else stat.S_ISDIR
if not expected_type(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SystemExit('Release signing key must be a regular root-owned file in protected directories.')
if not 0 < keyring.stat().st_size <= 256 * 1024:
raise SystemExit('Repository public key is empty or exceeds its size limit.')
if len(manifest_bytes) > 16 * 1024 or signature.stat().st_size > 16 * 1024:
raise SystemExit('Release signing metadata exceeds its size limit.')
try:
+50 -10
View File
@@ -82,16 +82,9 @@ 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):
def test_no_preprovisioned_signing_key_is_required(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()
self.preflight()
def test_known_legacy_layout_can_upgrade_and_marker_binds_exact_enrollment(self):
self.legacy_installation()
@@ -347,6 +340,46 @@ docker() {
self.assertNotIn("Docker is installed but unavailable", result.stderr)
class RepositoryTrustTests(unittest.TestCase):
def fetch(self, status="200", body="public key", curl_exit=0):
block = SOURCE[SOURCE.index("# INSTALLATION_TRUST:"):SOURCE.index("# INSTALLATION_TRUST_END")]
with tempfile.TemporaryDirectory() as temporary:
script = r'''set -euo pipefail
curl() {
printf '%s\n' "$@" > "$temporary_dir/curl-args"
printf '%s' "$EC_TEST_BODY" > "$temporary_dir/release-signing.gpg"
printf '%s' "$EC_TEST_STATUS"
return "$EC_TEST_EXIT"
}
''' + block + "\nprintf 'verified-fetch'"
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
env={**os.environ, "temporary_dir": temporary, "EC_TEST_BODY": body,
"EC_TEST_STATUS": status, "EC_TEST_EXIT": str(curl_exit)})
args = (Path(temporary) / "curl-args").read_text().splitlines()
return result, args
def test_installer_fetches_fixed_repository_key_with_bounds_and_tls(self):
result, args = self.fetch()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "verified-fetch")
self.assertIn("https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg", args)
for option, value in (("--proto", "=https"), ("--max-filesize", "262144"),
("--max-time", "120"), ("--connect-timeout", "25"),
("--header", "Cache-Control: no-cache")):
self.assertEqual(args[args.index(option) + 1], value)
self.assertNotIn("--location", args)
self.assertNotIn("--insecure", args)
self.assertLess(SOURCE.index("# INSTALLATION_TRUST:"), SOURCE.index('$controller/api/agent/release/manifest.json'))
def test_repository_failure_redirect_or_empty_key_stops_installation(self):
for status, body, code in (("302", "redirect", 0), ("404", "missing", 22),
("200", "", 0), ("200", "partial", 28), ("200", "oversized", 63)):
with self.subTest(status=status, body=body):
result, _ = self.fetch(status, body, code)
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("verified-fetch", result.stdout)
class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
@@ -409,7 +442,14 @@ class ReleaseTests(unittest.TestCase):
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):
def test_empty_or_oversized_downloaded_key_cannot_authorize_installation(self):
for value in (b"", b"x" * (256 * 1024 + 1)):
self.keyring.write_bytes(value)
with self.subTest(size=len(value)), self.assertRaisesRegex(SystemExit, "public key is empty or exceeds"):
self.verify()
self.gpgv.assert_not_called()
def test_signature_is_verified_against_downloaded_key_before_manifest_parsing(self):
self.verify()
args, kwargs = self.gpgv.call_args
self.assertEqual(args[0][0], 'gpgv')
+2 -3
View File
@@ -176,11 +176,10 @@ class OfflineSigningTests(ReleaseFixture, unittest.TestCase):
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), \
with 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())
(self.target / "manifest.json.sig").read_bytes(), self.public_key)
def verify(self):
return subprocess.run([
+117 -29
View File
@@ -43,7 +43,6 @@ 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",
@@ -65,6 +64,10 @@ 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.public_key = b"repository public key"
key_patch = patch.object(module, "fetch_release_key", return_value=self.public_key)
self.fetch_key = key_patch.start()
self.addCleanup(key_patch.stop)
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:
@@ -139,7 +142,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.signature_verifier.assert_called_once_with(json.dumps(self.release).encode(), self.signature, self.public_key)
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)
@@ -168,7 +171,7 @@ class UpdateTests(unittest.TestCase):
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)
self.signature_verifier.assert_called_once_with(b"untrusted invalid JSON", self.signature, self.public_key)
def test_missing_signature_never_downloads_code_or_stops_agent(self):
self.signature = module.ContactError("No signature is published")
@@ -188,6 +191,32 @@ class UpdateTests(unittest.TestCase):
self.assertFalse(any(path.endswith("ec-agent.py") for path in self.downloads))
self.assertEqual(self.commands, [])
def test_every_check_fetches_key_before_controller_even_without_a_new_version(self):
self.release["version"] = "1.0.0"
keys = [b"first public key", b"replacement public key"]
self.fetch_key.side_effect = keys
updater = self.updater()
def controller_fetch(path, limit):
self.assertGreater(self.fetch_key.call_count, self.signature_verifier.call_count)
return self.fetch(path, limit)
updater.fetch = controller_fetch
for _ in keys:
self.assertEqual(updater.run(), "current")
self.assertEqual(self.fetch_key.call_count, 2)
self.assertEqual([call.args[2] for call in self.signature_verifier.call_args_list], keys)
self.assert_unchanged()
self.assertEqual(self.commands, [])
def test_repository_failure_never_uses_old_host_key_or_contacts_controller(self):
(self.config_dir / "release-signing.gpg").write_bytes(b"old trusted key")
self.fetch_key.side_effect = module.ContactError("Repository unavailable")
self.assertEqual(self.updater().run(), "deferred")
self.assert_unchanged()
self.assertEqual(self.downloads, [])
self.assertEqual(self.commands, [])
self.assertTrue(self.active)
self.signature_verifier.assert_not_called()
def test_busy_operation_defers_update_without_stopping_agent(self):
with module.exclusive_lock(self.root / "agent" / "activity.lock"):
self.assertEqual(self.updater().run(), "deferred")
@@ -309,11 +338,14 @@ class UpdateTests(unittest.TestCase):
self.crash_during_second_replacement()
self.downloads.clear()
self.signature_verifier.reset_mock()
self.fetch_key.reset_mock()
self.fetch_key.side_effect = module.ContactError("Repository offline during recovery")
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.fetch_key.assert_not_called()
self.assertTrue(self.active)
self.assertFalse(module.UPDATE_JOURNAL.exists())
@@ -458,15 +490,48 @@ 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 RepositoryKeyDownloadTests(unittest.TestCase):
def test_fixed_https_url_no_credentials_no_cache_and_no_redirects(self):
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"public keys")
self.assertEqual(module.fetch_release_key(), b"public keys")
request = opener.return_value.open.call_args.args[0]
self.assertEqual(request.full_url, "https://gitea.thewrightserver.net/josh/electric-cherry/raw/branch/main/agent/release-signing.gpg")
self.assertIsNone(request.get_header("Authorization"))
self.assertIsNone(request.get_header("Cookie"))
self.assertEqual(request.get_header("Cache-control"), "no-cache")
self.assertEqual(opener.return_value.open.call_args.kwargs["timeout"], 25)
redirect = opener.call_args.args[0]
for destination in ("https://attacker.example/key.gpg", "http://gitea.thewrightserver.net/key.gpg"):
with self.subTest(destination=destination), self.assertRaises(module.ContactError):
redirect.redirect_request(None, None, 302, "redirect", {}, destination)
def test_missing_empty_oversized_and_truncated_keys_fail_closed(self):
cases = [Response(b""), Response(b"x", {"Content-Length": str(module.MAX_KEYRING + 1)}),
Response(b"x" * (module.MAX_KEYRING + 1)),
Response(b"partial", {"Content-Length": "100"}),
Response(b"key", {"Content-Length": "invalid"})]
for status in (301, 304, 404, 500):
response = Response(b"public key")
response.status = status
cases.append(response)
for response in cases:
with self.subTest(status=response.status, headers=response.headers), patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = response
with self.assertRaises(module.UpdateError):
module.fetch_release_key()
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.side_effect = OSError("TLS or network failure")
with self.assertRaisesRegex(module.ContactError, "release-signing repository is unavailable"):
module.fetch_release_key()
def test_trickling_repository_download_has_a_deadline(self):
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.return_value = Response(b"public key")
with patch.object(module.time, "monotonic", side_effect=[0, 0, 121]):
with self.assertRaisesRegex(module.ContactError, "timed out"):
module.fetch_release_key()
class SignatureVerificationFixture:
@@ -478,10 +543,9 @@ class SignatureVerificationFixture:
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)
entry = patch.object(module, "UPDATE_DIR", self.update_dir)
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))
@@ -490,13 +554,14 @@ class SignatureVerificationFixture:
class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase):
def test_gpgv_uses_only_host_keyring_and_exact_bytes_with_an_isolated_home(self):
def test_gpgv_uses_only_downloaded_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(command[3:5], ["--keyring", str(home / "release-signing.gpg")])
self.assertEqual(Path(command[4]).read_bytes(), self.keyring.read_bytes())
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)
@@ -505,19 +570,18 @@ class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase
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)
module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
run.assert_called_once()
self.assertEqual(self.trusted.call_args_list[0].args, (self.keyring,))
self.assertEqual(self.trusted.call_args_list[0].args, (self.update_dir,))
self.assertEqual(list(self.update_dir.iterdir()), [])
def test_missing_keyring_or_signature_never_runs_a_verifier(self):
def test_empty_or_oversized_keyring_or_missing_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)
for key in (b"", b"x" * (module.MAX_KEYRING + 1)):
with self.subTest(size=len(key)), self.assertRaisesRegex(module.IntegrityError, "keyring"):
module.verify_release_signature(b"manifest", b"signature", key)
with self.assertRaisesRegex(module.IntegrityError, "detached manifest signature"):
module.verify_release_signature(b"manifest", b"")
module.verify_release_signature(b"manifest", b"", self.keyring.read_bytes())
run.assert_not_called()
def test_bad_unknown_or_unavailable_verifier_fails_closed_and_cleans_temporary_files(self):
@@ -526,7 +590,7 @@ class SignatureVerificationTests(SignatureVerificationFixture, unittest.TestCase
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")
module.verify_release_signature(b"manifest", b"signature", self.keyring.read_bytes())
self.assertEqual(list(self.update_dir.iterdir()), [])
@@ -549,15 +613,39 @@ class RealSignatureVerificationTests(SignatureVerificationFixture, unittest.Test
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_repository_key_rotation_removes_old_signer_on_the_next_check(self):
replacement = subprocess.run(["gpg", "--homedir", str(self.homes[1]), "--batch", "--export"],
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
manifest = json.dumps({"version": "1.1.0", "sha256": module.sha256(CANDIDATE),
"protocolVersion": 1, "updaterProtocolVersion": 2,
"platform": "debian-amd64",
"keyUrl": "https://controller.example/attacker-key"}).encode()
old_signature, new_signature = self.sign(manifest), self.sign(manifest, 1)
updater = module.Updater()
updater.installation = {"controller": "https://controller.example"}
with patch.object(module.urllib.request, "build_opener") as opener:
opener.return_value.open.side_effect = [Response(value) for value in (
self.public_key, manifest, old_signature,
replacement, manifest, old_signature,
replacement, manifest, new_signature)]
self.assertEqual(updater.manifest()["version"], "1.1.0")
with self.assertRaisesRegex(module.IntegrityError, "not signed by a trusted release key"):
updater.manifest()
self.assertEqual(updater.manifest()["version"], "1.1.0")
urls = [call.args[0].full_url for call in opener.return_value.open.call_args_list]
self.assertEqual(urls[::3], [module.RELEASE_KEY_URL] * 3)
self.assertNotIn("https://controller.example/attacker-key", urls)
self.assertEqual(list(self.update_dir.iterdir()), [])
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)
module.verify_release_signature(manifest, signature, self.keyring.read_bytes())
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)
module.verify_release_signature(changed_manifest, changed_signature, self.keyring.read_bytes())
self.assertEqual(list(self.update_dir.iterdir()), [])