"""Bounded verification for signed GovOPlaN runtime distributions.""" from __future__ import annotations import base64 from datetime import UTC, datetime import hashlib import ipaddress import json import os from pathlib import Path import re import socket import stat import subprocess import tempfile from typing import Any, Mapping from urllib.parse import urlsplit from urllib.request import Request, urlopen MAX_MANIFEST_BYTES = 4 * 1024 * 1024 MAX_KEYRING_BYTES = 1024 * 1024 MAX_OFFLINE_INDEX_BYTES = 4 * 1024 * 1024 MAX_OFFLINE_IMAGE_BYTES = 16 * 1024 * 1024 * 1024 SHA256 = re.compile(r"^[0-9a-f]{64}$") DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$") TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$") MODULE_ID = re.compile(r"^[a-z][a-z0-9_]{1,63}$") KEY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") PLATFORMS = ("linux/amd64", "linux/arm64") MANIFEST_FILENAME = "distribution-manifest.json" KEYRING_FILENAME = "distribution-keyring.json" class DistributionError(ValueError): """Distribution evidence is absent, malformed, or untrusted.""" def canonical_signed_payload(payload: Mapping[str, Any]) -> bytes: unsigned = dict(payload) unsigned.pop("signatures", None) return json.dumps( unsigned, ensure_ascii=False, separators=(",", ":"), sort_keys=True, ).encode("utf-8") def canonical_json(payload: Mapping[str, Any]) -> bytes: return ( json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" ).encode("utf-8") def load_bounded_json(path: Path, *, maximum_bytes: int) -> dict[str, Any]: encoded = read_bounded_bytes(path, maximum_bytes=maximum_bytes) try: value = json.loads(encoded) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise DistributionError(f"trusted JSON file is malformed: {path}") from exc if not isinstance(value, dict): raise DistributionError(f"trusted JSON root must be an object: {path}") return value def read_bounded_bytes(path: Path, *, maximum_bytes: int) -> bytes: flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) try: descriptor = os.open(path, flags) except OSError as exc: raise DistributionError(f"cannot open trusted JSON file: {path}") from exc try: opened = os.fstat(descriptor) if not stat.S_ISREG(opened.st_mode) or opened.st_size > maximum_bytes: raise DistributionError( f"trusted JSON file is invalid or too large: {path}" ) chunks: list[bytes] = [] total = 0 while True: chunk = os.read(descriptor, min(64 * 1024, maximum_bytes + 1 - total)) if not chunk: break chunks.append(chunk) total += len(chunk) if total > maximum_bytes: raise DistributionError(f"trusted JSON file is too large: {path}") final = os.fstat(descriptor) if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != ( final.st_dev, final.st_ino, final.st_size, final.st_mtime_ns, ): raise DistributionError(f"trusted JSON file changed while read: {path}") finally: os.close(descriptor) return b"".join(chunks) def fetch_bounded_https( url: str, *, maximum_bytes: int, timeout_seconds: float = 15.0, allow_private_host: bool = False, ) -> bytes: parsed = urlsplit(url) if parsed.scheme != "https" or not parsed.hostname: raise DistributionError("distribution downloads require an absolute HTTPS URL") if parsed.username or parsed.password or parsed.fragment: raise DistributionError( "distribution URL must not contain credentials or a fragment" ) if not allow_private_host: _require_public_host(parsed.hostname) request = Request(url, headers={"Accept": "application/json"}) try: with urlopen(request, timeout=timeout_seconds) as response: # noqa: S310 final = urlsplit(response.geturl()) if final.scheme != "https": raise DistributionError("distribution redirect left HTTPS") declared = response.headers.get("Content-Length") if declared and int(declared) > maximum_bytes: raise DistributionError("distribution download exceeds its size limit") value = response.read(maximum_bytes + 1) except DistributionError: raise except (OSError, ValueError) as exc: raise DistributionError(f"distribution download failed: {exc}") from exc if len(value) > maximum_bytes: raise DistributionError("distribution download exceeds its size limit") return value def decode_json_bytes(value: bytes, *, label: str) -> dict[str, Any]: try: payload = json.loads(value) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise DistributionError(f"{label} is not valid JSON") from exc if not isinstance(payload, dict): raise DistributionError(f"{label} root must be an object") return payload def validate_manifest( payload: Mapping[str, Any], *, expected_channel: str | None = None, now: datetime | None = None, ) -> None: _exact_keys( payload, required={ "schema_version", "channel", "sequence", "version", "issued_at", "expires_at", "revoked", "deployer", "images", "dependencies", "composition", "signatures", }, label="distribution manifest", ) if payload.get("schema_version") != "1": raise DistributionError("unsupported distribution manifest schema_version") channel = _token(payload.get("channel"), "channel", maximum=32, pattern=MODULE_ID) if expected_channel is not None and channel != expected_channel: raise DistributionError( f"distribution channel is {channel!r}, expected {expected_channel!r}" ) if ( isinstance(payload.get("sequence"), bool) or not isinstance(payload.get("sequence"), int) or int(payload["sequence"]) < 1 ): raise DistributionError("distribution sequence must be a positive integer") _token(payload.get("version"), "version", maximum=128, pattern=TOKEN) issued = _datetime(payload.get("issued_at"), "issued_at") expires = _datetime(payload.get("expires_at"), "expires_at") current = (now or datetime.now(UTC)).astimezone(UTC) if expires <= issued: raise DistributionError("distribution expiry must be after issuance") if issued > current: raise DistributionError("distribution is not valid yet") if expires <= current: raise DistributionError("distribution manifest has expired") if payload.get("revoked") is not False: raise DistributionError("distribution manifest is revoked") deployer = _object(payload.get("deployer"), "deployer") _exact_keys(deployer, required={"url", "sha256"}, label="deployer") _https_url(deployer.get("url"), "deployer.url") _sha256(deployer.get("sha256"), "deployer.sha256") images = _object(payload.get("images"), "images") if set(images) != {"api", "web"}: raise DistributionError("images must contain exactly api and web") for name in ("api", "web"): _validate_image(_object(images[name], f"images.{name}"), f"images.{name}") dependencies = _object(payload.get("dependencies"), "dependencies") if not dependencies: raise DistributionError("dependencies must not be empty") for name, reference in dependencies.items(): if MODULE_ID.fullmatch(str(name)) is None: raise DistributionError(f"invalid dependency name: {name!r}") _digest_image(reference, f"dependencies.{name}") composition = _object(payload.get("composition"), "composition") _exact_keys( composition, required={"sha256", "module_ids", "packages"}, label="composition", ) _sha256(composition.get("sha256"), "composition.sha256") module_ids = _string_array(composition.get("module_ids"), "module_ids") if any(MODULE_ID.fullmatch(item) is None for item in module_ids): raise DistributionError("composition.module_ids contains an invalid id") packages = composition.get("packages") if not isinstance(packages, list) or not packages: raise DistributionError("composition.packages must be a non-empty array") seen_packages: set[str] = set() for index, item in enumerate(packages): package = _object(item, f"composition.packages[{index}]") _exact_keys( package, required={"name", "version", "wheel_sha256"}, label=f"composition.packages[{index}]", ) name = _token( package.get("name"), f"composition.packages[{index}].name", maximum=128, pattern=re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$"), ) if name in seen_packages: raise DistributionError("composition contains duplicate packages") seen_packages.add(name) _token( package.get("version"), f"composition.packages[{index}].version", maximum=128, pattern=TOKEN, ) _sha256( package.get("wheel_sha256"), f"composition.packages[{index}].wheel_sha256", ) signatures = payload.get("signatures") if not isinstance(signatures, list) or not signatures: raise DistributionError("distribution manifest has no signatures") seen_signatures: set[str] = set() for index, item in enumerate(signatures): signature = _object(item, f"signatures[{index}]") _exact_keys( signature, required={"key_id", "algorithm", "value"}, label=f"signatures[{index}]", ) key_id = _token( signature.get("key_id"), f"signatures[{index}].key_id", maximum=128, pattern=KEY_ID, ) if key_id in seen_signatures: raise DistributionError("distribution contains duplicate signatures") seen_signatures.add(key_id) if signature.get("algorithm") != "ed25519": raise DistributionError("distribution signature algorithm must be ed25519") _signature_bytes(signature.get("value"), f"signatures[{index}].value") def verify_manifest( payload: Mapping[str, Any], keyring: Mapping[str, Any], *, expected_channel: str | None = None, now: datetime | None = None, openssl: str = "openssl", ) -> str: current = (now or datetime.now(UTC)).astimezone(UTC) validate_manifest(payload, expected_channel=expected_channel, now=current) return verify_signed_document( payload, keyring, purpose="govoplan-runtime-distribution", label="distribution", now=current, openssl=openssl, ) def verify_signed_document( payload: Mapping[str, Any], keyring: Mapping[str, Any], *, purpose: str, label: str, now: datetime, openssl: str = "openssl", ) -> str: keys = _trusted_keys(keyring, now=now, purpose=purpose, label=label) signed = canonical_signed_payload(payload) failures: list[str] = [] signatures = payload.get("signatures") if not isinstance(signatures, list) or not signatures: raise DistributionError(f"{label} has no signatures") for index, raw in enumerate(signatures): item = _object(raw, f"{label}.signatures[{index}]") _exact_keys( item, required={"key_id", "algorithm", "value"}, label=f"{label}.signatures[{index}]", ) key_id = str(item["key_id"]) if KEY_ID.fullmatch(key_id) is None or item.get("algorithm") != "ed25519": raise DistributionError(f"{label} signature is invalid") public_key = keys.get(key_id) if public_key is None: continue signature = _signature_bytes(item["value"], "signature.value") try: _openssl_verify( signed, signature, public_key, openssl=openssl, ) except DistributionError as exc: failures.append(f"{key_id}: {exc}") continue return key_id detail = ( "; ".join(failures) if failures else "no signature used an active trusted key" ) raise DistributionError(f"{label} signature verification failed: {detail}") def verify_manifest_binding( payload: Mapping[str, Any], *, channel: str, version: str, api_image: str, web_image: str, enabled_modules: tuple[str, ...], composition_sha256: str, dependencies: Mapping[str, str], ) -> None: if payload.get("channel") != channel or payload.get("version") != version: raise DistributionError( "stored manifest does not match release channel/version" ) images = _object(payload.get("images"), "images") if _object(images.get("api"), "images.api").get("index") != api_image: raise DistributionError("stored manifest does not match API image") if _object(images.get("web"), "images.web").get("index") != web_image: raise DistributionError("stored manifest does not match Web image") composition = _object(payload.get("composition"), "composition") if composition.get("sha256") != composition_sha256: raise DistributionError("stored manifest composition digest does not match") available_modules = set(_string_array(composition.get("module_ids"), "module_ids")) missing = sorted(set(enabled_modules) - available_modules) if missing: raise DistributionError( "enabled modules are absent from runtime composition: " + ", ".join(missing) ) manifest_dependencies = _object(payload.get("dependencies"), "dependencies") for name, reference in dependencies.items(): if manifest_dependencies.get(name) != reference: raise DistributionError( f"stored manifest does not match dependency image {name!r}" ) def verify_offline_image_index( index: Mapping[str, Any], *, root: Path, expected_references: tuple[str, ...], ) -> tuple[Path, ...]: _exact_keys(index, required={"schema_version", "images"}, label="offline index") if index.get("schema_version") != "1": raise DistributionError("unsupported offline image index schema") images = index.get("images") if not isinstance(images, list): raise DistributionError("offline image index images must be an array") references: dict[str, Path] = {} for item in images: value = _object(item, "offline image") _exact_keys( value, required={"reference", "archive", "sha256"}, label="offline image", ) reference = _digest_image(value.get("reference"), "offline image reference") archive_value = value.get("archive") if not isinstance(archive_value, str) or not archive_value: raise DistributionError("offline image archive must be a relative path") archive_relative = Path(archive_value) if archive_relative.is_absolute() or ".." in archive_relative.parts: raise DistributionError("offline image archive must stay inside its bundle") archive = root / archive_relative if reference in references: raise DistributionError("offline image index contains duplicate references") if _sha256_regular_file( archive, maximum_bytes=MAX_OFFLINE_IMAGE_BYTES ) != _sha256(value.get("sha256"), "offline image sha256"): raise DistributionError(f"offline image archive digest mismatch: {archive}") references[reference] = archive missing = sorted(set(expected_references) - set(references)) if missing: raise DistributionError( "offline image bundle is incomplete: " + ", ".join(missing) ) return tuple(references[item] for item in expected_references) def file_sha256(path: Path, *, maximum_bytes: int = MAX_MANIFEST_BYTES) -> str: return _sha256_regular_file(path, maximum_bytes=maximum_bytes) def _validate_image(value: Mapping[str, Any], label: str) -> None: _exact_keys( value, required={"index", "platforms", "sbom", "provenance"}, label=label, ) _digest_image(value.get("index"), f"{label}.index") platforms = _object(value.get("platforms"), f"{label}.platforms") if set(platforms) != set(PLATFORMS): raise DistributionError(f"{label}.platforms must cover amd64 and arm64") for platform, reference in platforms.items(): _digest_image(reference, f"{label}.platforms.{platform}") _validate_artifact(_object(value.get("sbom"), f"{label}.sbom"), f"{label}.sbom") _validate_artifact( _object(value.get("provenance"), f"{label}.provenance"), f"{label}.provenance", ) def _validate_artifact(value: Mapping[str, Any], label: str) -> None: _exact_keys(value, required={"url", "sha256"}, label=label) _https_url(value.get("url"), f"{label}.url") _sha256(value.get("sha256"), f"{label}.sha256") def _trusted_keys( keyring: Mapping[str, Any], *, now: datetime, purpose: str, label: str, ) -> dict[str, str]: _exact_keys( keyring, required={"schema_version", "purpose", "keys"}, label=f"{label} keyring", ) if keyring.get("schema_version") != "1": raise DistributionError(f"unsupported {label} keyring schema_version") if keyring.get("purpose") != purpose: raise DistributionError(f"{label} keyring has the wrong purpose") values = keyring.get("keys") if not isinstance(values, list) or not values: raise DistributionError(f"{label} keyring contains no keys") trusted: dict[str, str] = {} for index, item in enumerate(values): key = _object(item, f"keyring.keys[{index}]") _exact_keys( key, required={ "key_id", "algorithm", "status", "public_key_pem", "not_before", "expires_at", }, label=f"keyring.keys[{index}]", ) key_id = _token( key.get("key_id"), f"keyring.keys[{index}].key_id", maximum=128, pattern=KEY_ID, ) if key_id in trusted: raise DistributionError(f"{label} keyring contains duplicate key ids") if key.get("algorithm") != "ed25519": raise DistributionError(f"{label} key must use ed25519") if key.get("status") not in {"active", "retired", "revoked"}: raise DistributionError(f"{label} key has an invalid status") not_before = _datetime(key.get("not_before"), "key.not_before") expires = _datetime(key.get("expires_at"), "key.expires_at") public_key = key.get("public_key_pem") if ( not isinstance(public_key, str) or len(public_key.encode("utf-8")) > 8192 or "BEGIN PUBLIC KEY" not in public_key ): raise DistributionError(f"{label} key has an invalid public key") if key.get("status") == "active" and not_before <= now < expires: trusted[key_id] = public_key if not trusted: raise DistributionError(f"{label} keyring has no currently active keys") return trusted def _openssl_verify( payload: bytes, signature: bytes, public_key: str, *, openssl: str, ) -> None: with tempfile.TemporaryDirectory(prefix="govoplan-distribution-verify-") as value: root = Path(value) payload_path = root / "payload.json" signature_path = root / "signature.bin" key_path = root / "public.pem" payload_path.write_bytes(payload) signature_path.write_bytes(signature) key_path.write_text(public_key, encoding="utf-8") try: completed = subprocess.run( [ openssl, "pkeyutl", "-verify", "-pubin", "-inkey", str(key_path), "-rawin", "-in", str(payload_path), "-sigfile", str(signature_path), ], check=False, capture_output=True, text=True, timeout=10, ) except (OSError, subprocess.TimeoutExpired) as exc: raise DistributionError("OpenSSL Ed25519 verifier is unavailable") from exc if completed.returncode != 0: raise DistributionError("Ed25519 signature is invalid") def _signature_bytes(value: object, label: str) -> bytes: if not isinstance(value, str) or len(value) > 256: raise DistributionError(f"{label} is invalid") try: decoded = base64.b64decode(value, validate=True) except (ValueError, base64.binascii.Error) as exc: raise DistributionError(f"{label} is not valid base64") from exc if len(decoded) != 64: raise DistributionError(f"{label} is not an Ed25519 signature") return decoded def _require_public_host(hostname: str) -> None: try: addresses = { value[4][0] for value in socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM) } except OSError as exc: raise DistributionError( f"distribution host cannot be resolved: {hostname}" ) from exc if not addresses: raise DistributionError("distribution host resolved to no addresses") for value in addresses: address = ipaddress.ip_address(value) if not address.is_global: raise DistributionError( "distribution host resolves to a non-public address" ) def _sha256_regular_file(path: Path, *, maximum_bytes: int) -> str: flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) try: descriptor = os.open(path, flags) except OSError as exc: raise DistributionError(f"cannot open immutable artifact: {path}") from exc digest = hashlib.sha256() try: opened = os.fstat(descriptor) if not stat.S_ISREG(opened.st_mode) or opened.st_size > maximum_bytes: raise DistributionError( f"immutable artifact is invalid or too large: {path}" ) while True: chunk = os.read(descriptor, 1024 * 1024) if not chunk: break digest.update(chunk) final = os.fstat(descriptor) if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != ( final.st_dev, final.st_ino, final.st_size, final.st_mtime_ns, ): raise DistributionError(f"immutable artifact changed while read: {path}") finally: os.close(descriptor) return digest.hexdigest() def _object(value: object, label: str) -> dict[str, Any]: if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): raise DistributionError(f"{label} must be an object") return value def _exact_keys( value: Mapping[str, Any], *, required: set[str], label: str, ) -> None: missing = sorted(required - set(value)) extra = sorted(set(value) - required) if missing or extra: detail = [] if missing: detail.append("missing " + ", ".join(missing)) if extra: detail.append("unknown " + ", ".join(extra)) raise DistributionError(f"{label} has invalid fields: {'; '.join(detail)}") def _token( value: object, label: str, *, maximum: int, pattern: re.Pattern[str], ) -> str: if ( not isinstance(value, str) or len(value) > maximum or pattern.fullmatch(value) is None ): raise DistributionError(f"{label} is invalid") return value def _datetime(value: object, label: str) -> datetime: if not isinstance(value, str) or len(value) > 64: raise DistributionError(f"{label} must be an RFC3339 timestamp") try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as exc: raise DistributionError(f"{label} must be an RFC3339 timestamp") from exc if parsed.tzinfo is None: raise DistributionError(f"{label} must include a timezone") return parsed.astimezone(UTC) def _sha256(value: object, label: str) -> str: if not isinstance(value, str) or SHA256.fullmatch(value) is None: raise DistributionError(f"{label} must be a lowercase SHA-256 digest") return value def _digest_image(value: object, label: str) -> str: if ( not isinstance(value, str) or len(value) > 300 or DIGEST_IMAGE.fullmatch(value) is None ): raise DistributionError(f"{label} must be an OCI image pinned by sha256") return value def _https_url(value: object, label: str) -> str: if not isinstance(value, str) or len(value) > 2048: raise DistributionError(f"{label} must be an HTTPS URL") parsed = urlsplit(value) if ( parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password ): raise DistributionError(f"{label} must be an HTTPS URL without credentials") return value def _string_array(value: object, label: str) -> tuple[str, ...]: if ( not isinstance(value, list) or len(value) > 1024 or any(not isinstance(item, str) for item in value) or len(set(value)) != len(value) ): raise DistributionError(f"{label} must be an array of unique strings") return tuple(value)