Add signed runtime distribution pipeline
Dependency Audit / dependency-audit (push) Successful in 1m39s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m3s

This commit is contained in:
2026-08-03 00:54:06 +02:00
parent 29acb55b7c
commit 43380eb068
24 changed files with 3371 additions and 54 deletions
@@ -24,6 +24,8 @@ GARAGE_CONFIG_FILENAME = "garage.toml"
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
PLAN_FILENAME = "plan.json"
RECEIPT_FILENAME = "receipt.json"
MANIFEST_FILENAME = "distribution-manifest.json"
KEYRING_FILENAME = "distribution-keyring.json"
LOCK_FILENAME = ".deployment.lock"
RUNTIME_ENV_KEYS = (
"APP_ENV",
@@ -87,6 +89,8 @@ class BundlePaths:
load_balancer_config: Path
plan: Path
receipt: Path
manifest: Path
keyring: Path
lock: Path
@@ -104,6 +108,8 @@ def bundle_paths(root: Path) -> BundlePaths:
load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME,
plan=resolved / PLAN_FILENAME,
receipt=resolved / RECEIPT_FILENAME,
manifest=resolved / MANIFEST_FILENAME,
keyring=resolved / KEYRING_FILENAME,
lock=resolved / LOCK_FILENAME,
)
+261 -1
View File
@@ -8,6 +8,7 @@ from dataclasses import replace
from datetime import UTC, datetime
import fcntl
import getpass
import hashlib
import json
import os
from pathlib import Path
@@ -36,6 +37,20 @@ from .bundle import (
write_env,
)
from .cluster_evidence import collect_kubernetes_evidence
from .distribution import (
MAX_KEYRING_BYTES,
MAX_MANIFEST_BYTES,
MAX_OFFLINE_INDEX_BYTES,
DistributionError,
canonical_json as canonical_distribution_json,
decode_json_bytes,
fetch_bounded_https,
load_bounded_json,
read_bounded_bytes,
verify_offline_image_index,
verify_manifest,
verify_manifest_binding,
)
from .model import (
ComponentConfig,
DEFAULT_GARAGE_IMAGE,
@@ -127,6 +142,43 @@ def build_parser() -> argparse.ArgumentParser:
_directory_argument(status)
status.add_argument("--json", action="store_true", help="Print JSON.")
verify_release = subparsers.add_parser(
"verify-release",
help="Verify and optionally adopt a signed runtime distribution.",
)
_directory_argument(verify_release)
manifest_source = verify_release.add_mutually_exclusive_group(required=True)
manifest_source.add_argument("--manifest", type=Path)
manifest_source.add_argument("--manifest-url")
verify_release.add_argument(
"--manifest-sha256",
required=True,
help="Independently obtained SHA-256 digest of the signed manifest.",
)
verify_release.add_argument("--trusted-keyring", type=Path, required=True)
verify_release.add_argument(
"--allow-private-release-host",
action="store_true",
help="Allow an explicitly selected private HTTPS release mirror.",
)
verify_release.add_argument(
"--adopt",
action="store_true",
help="Store the verified trust material and select its pinned images.",
)
offline_images = subparsers.add_parser(
"verify-offline-images",
help="Verify prefetched OCI archives against the adopted distribution.",
)
_directory_argument(offline_images)
offline_images.add_argument("--index", type=Path, required=True)
offline_images.add_argument(
"--load",
action="store_true",
help="Load verified archives into Docker using fixed image-load commands.",
)
kubernetes = subparsers.add_parser(
"render-kubernetes",
help="Export the stateless multi-host runtime for Kubernetes.",
@@ -312,6 +364,10 @@ def main(argv: Sequence[str] | None = None) -> int:
return _apply(args)
if args.command == "status":
return _status(args)
if args.command == "verify-release":
return _verify_release(args)
if args.command == "verify-offline-images":
return _verify_offline_images(args)
if args.command == "render-kubernetes":
return _render_kubernetes(args)
if args.command == "verify-kubernetes":
@@ -320,7 +376,13 @@ def main(argv: Sequence[str] | None = None) -> int:
return _operations(args)
if args.command == "recover":
return _recover(args)
except (SpecError, ValueError, OSError, subprocess.SubprocessError) as exc:
except (
DistributionError,
SpecError,
ValueError,
OSError,
subprocess.SubprocessError,
) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
raise RuntimeError(f"unsupported command: {args.command}")
@@ -617,6 +679,198 @@ def _status(args: argparse.Namespace) -> int:
return 0
def _verify_release(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
expected_digest = str(args.manifest_sha256 or "").strip().lower()
if len(expected_digest) != 64 or any(
character not in "0123456789abcdef" for character in expected_digest
):
raise ValueError("--manifest-sha256 must be a lowercase SHA-256 digest")
manifest_url = str(args.manifest_url or "").strip()
if manifest_url:
encoded_manifest = fetch_bounded_https(
manifest_url,
maximum_bytes=MAX_MANIFEST_BYTES,
allow_private_host=args.allow_private_release_host,
)
manifest = decode_json_bytes(
encoded_manifest,
label="distribution manifest",
)
actual_digest = hashlib.sha256(encoded_manifest).hexdigest()
else:
manifest_path = args.manifest.expanduser().resolve()
encoded_manifest = read_bounded_bytes(
manifest_path,
maximum_bytes=MAX_MANIFEST_BYTES,
)
manifest = load_bounded_json(
manifest_path,
maximum_bytes=MAX_MANIFEST_BYTES,
)
actual_digest = hashlib.sha256(encoded_manifest).hexdigest()
if encoded_manifest != canonical_distribution_json(manifest):
raise DistributionError("distribution manifest is not canonical JSON")
if actual_digest != expected_digest:
raise DistributionError("distribution manifest SHA-256 does not match")
keyring_path = args.trusted_keyring.expanduser().resolve()
keyring = load_bounded_json(keyring_path, maximum_bytes=MAX_KEYRING_BYTES)
encoded_keyring = canonical_distribution_json(keyring)
keyring_digest = hashlib.sha256(encoded_keyring).hexdigest()
key_id = verify_manifest(
manifest,
keyring,
expected_channel=spec.release.channel,
)
dependencies = _selected_dependency_images(spec, manifest=manifest)
verify_manifest_binding(
manifest,
channel=str(manifest["channel"]),
version=str(manifest["version"]),
api_image=str(manifest["images"]["api"]["index"]),
web_image=str(manifest["images"]["web"]["index"]),
enabled_modules=spec.enabled_modules,
composition_sha256=str(manifest["composition"]["sha256"]),
dependencies=dependencies,
)
print(
f"Verified GovOPlaN {manifest['version']} ({manifest['channel']}) "
f"with trusted key {key_id}."
)
if not args.adopt:
return 0
images = manifest["images"]
dependency_images = manifest["dependencies"]
release = replace(
spec.release,
channel=str(manifest["channel"]),
version=str(manifest["version"]),
manifest_url=manifest_url,
manifest_sha256=expected_digest,
manifest_keyring_sha256=keyring_digest,
manifest_signature_key_id=key_id,
composition_sha256=str(manifest["composition"]["sha256"]),
api_image=str(images["api"]["index"]),
web_image=str(images["web"]["index"]),
)
components = replace(
spec.components,
postgres=replace(
spec.components.postgres,
image=(
str(dependency_images["postgres"])
if spec.components.postgres.mode == "managed"
else spec.components.postgres.image
),
),
redis=replace(
spec.components.redis,
image=(
str(dependency_images["redis"])
if spec.components.redis.mode == "managed"
else spec.components.redis.image
),
),
mail=replace(
spec.components.mail,
image=(
str(dependency_images["test_mail"])
if spec.components.mail.mode == "test-mail"
else spec.components.mail.image
),
),
storage=replace(
spec.components.storage,
image=(
str(dependency_images["garage"])
if spec.components.storage.mode == "garage"
else spec.components.storage.image
),
),
load_balancer=replace(
spec.components.load_balancer,
image=str(dependency_images["load_balancer"]),
),
)
adopted = parse_spec(replace(spec, release=release, components=components).to_dict())
ensure_private_directory(paths.root)
atomic_write(paths.manifest, encoded_manifest, mode=0o644)
atomic_write(
paths.keyring,
encoded_keyring,
mode=0o644,
)
secrets = reconcile_runtime_environment(adopted, read_env(paths.env))
_write_bundle(adopted, paths, secrets)
print(f"Adopted immutable runtime distribution in {paths.root}.")
return 0
def _verify_offline_images(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
manifest = load_bounded_json(
paths.manifest,
maximum_bytes=MAX_MANIFEST_BYTES,
)
index_path = args.index.expanduser().resolve()
index = load_bounded_json(
index_path,
maximum_bytes=MAX_OFFLINE_INDEX_BYTES,
)
selected_dependencies = _selected_dependency_images(spec, manifest=manifest)
expected = (
str(manifest["images"]["api"]["index"]),
str(manifest["images"]["web"]["index"]),
*tuple(selected_dependencies.values()),
)
archives = verify_offline_image_index(
index,
root=index_path.parent,
expected_references=expected,
)
print(f"Verified {len(archives)} prefetched OCI image archive(s).")
if not args.load:
return 0
docker = shutil.which("docker")
if docker is None:
raise ValueError("Docker CLI is required to load offline images")
for archive in archives:
_run([docker, "image", "load", "--input", str(archive)], cwd=paths.root)
for reference in expected:
_run([docker, "image", "inspect", reference], cwd=paths.root)
print("Loaded and inspected every adopted offline image identity.")
return 0
def _selected_dependency_images(
spec: InstallationSpec,
*,
manifest: Mapping[str, object],
) -> dict[str, str]:
available = manifest.get("dependencies")
if not isinstance(available, dict):
raise DistributionError("distribution dependencies are invalid")
names = ["load_balancer"]
if spec.components.postgres.mode == "managed":
names.append("postgres")
if spec.components.redis.mode == "managed":
names.append("redis")
if spec.components.mail.mode == "test-mail":
names.append("test_mail")
if spec.components.storage.mode == "garage":
names.append("garage")
missing = [name for name in names if not isinstance(available.get(name), str)]
if missing:
raise DistributionError(
"distribution is missing selected dependency images: "
+ ", ".join(missing)
)
return {name: str(available[name]) for name in names}
def _render_kubernetes(args: argparse.Namespace) -> int:
paths = bundle_paths(args.directory)
spec = load_spec(paths.spec)
@@ -727,6 +981,9 @@ def _deployment_receipt(
"channel": spec.release.channel,
"version": spec.release.version,
"manifest_sha256": spec.release.manifest_sha256,
"manifest_keyring_sha256": spec.release.manifest_keyring_sha256,
"manifest_signature_key_id": spec.release.manifest_signature_key_id,
"composition_sha256": spec.release.composition_sha256,
"api_image": spec.release.api_image,
"web_image": spec.release.web_image,
},
@@ -770,6 +1027,9 @@ def _updated_spec(
if args.manifest_sha256 is not None
else current.release.manifest_sha256
),
manifest_keyring_sha256=current.release.manifest_keyring_sha256,
manifest_signature_key_id=current.release.manifest_signature_key_id,
composition_sha256=current.release.composition_sha256,
api_image=args.api_image or current.release.api_image,
web_image=args.web_image or current.release.web_image,
)
@@ -0,0 +1,651 @@
"""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)
keys = _trusted_keys(keyring, now=current)
signed = canonical_signed_payload(payload)
failures: list[str] = []
for item in payload["signatures"]:
key_id = str(item["key_id"])
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"distribution 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,
) -> dict[str, str]:
_exact_keys(
keyring,
required={"schema_version", "purpose", "keys"},
label="distribution keyring",
)
if keyring.get("schema_version") != "1":
raise DistributionError("unsupported distribution keyring schema_version")
if keyring.get("purpose") != "govoplan-runtime-distribution":
raise DistributionError("distribution keyring has the wrong purpose")
values = keyring.get("keys")
if not isinstance(values, list) or not values:
raise DistributionError("distribution 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("distribution keyring contains duplicate key ids")
if key.get("algorithm") != "ed25519":
raise DistributionError("distribution key must use ed25519")
if key.get("status") not in {"active", "retired", "revoked"}:
raise DistributionError("distribution 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("distribution 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("distribution 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)
+33
View File
@@ -77,6 +77,9 @@ class ReleaseConfig:
version: str
manifest_url: str
manifest_sha256: str
manifest_keyring_sha256: str
manifest_signature_key_id: str
composition_sha256: str
api_image: str
web_image: str
@@ -178,6 +181,9 @@ def default_spec(
"version": version,
"manifest_url": manifest_url,
"manifest_sha256": manifest_sha256,
"manifest_keyring_sha256": "",
"manifest_signature_key_id": "",
"composition_sha256": "",
"api_image": api_image,
"web_image": web_image,
},
@@ -314,6 +320,9 @@ def _release(raw: object) -> ReleaseConfig:
"version",
"manifest_url",
"manifest_sha256",
"manifest_keyring_sha256",
"manifest_signature_key_id",
"composition_sha256",
"api_image",
"web_image",
},
@@ -335,6 +344,27 @@ def _release(raw: object) -> ReleaseConfig:
raise SpecError(
"release.manifest_sha256 must be a lowercase SHA-256 hex digest"
)
manifest_keyring_sha256 = _optional_string(
value, "manifest_keyring_sha256"
).lower()
if manifest_keyring_sha256 and not SHA256_PATTERN.fullmatch(
manifest_keyring_sha256
):
raise SpecError(
"release.manifest_keyring_sha256 must be a lowercase SHA-256 hex digest"
)
manifest_signature_key_id = _optional_string(
value, "manifest_signature_key_id"
)
if manifest_signature_key_id and not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", manifest_signature_key_id
):
raise SpecError("release.manifest_signature_key_id is invalid")
composition_sha256 = _optional_string(value, "composition_sha256").lower()
if composition_sha256 and not SHA256_PATTERN.fullmatch(composition_sha256):
raise SpecError(
"release.composition_sha256 must be a lowercase SHA-256 hex digest"
)
api_image = _image(_string(value, "api_image"), "release.api_image")
web_image = _image(_string(value, "web_image"), "release.web_image")
return ReleaseConfig(
@@ -342,6 +372,9 @@ def _release(raw: object) -> ReleaseConfig:
version=version,
manifest_url=manifest_url,
manifest_sha256=manifest_sha256,
manifest_keyring_sha256=manifest_keyring_sha256,
manifest_signature_key_id=manifest_signature_key_id,
composition_sha256=composition_sha256,
api_image=api_image,
web_image=web_image,
)
+134 -34
View File
@@ -22,6 +22,15 @@ from .bundle import (
render_compose,
service_names,
)
from .distribution import (
MAX_KEYRING_BYTES,
MAX_MANIFEST_BYTES,
DistributionError,
file_sha256,
load_bounded_json,
verify_manifest,
verify_manifest_binding,
)
from .model import (
InstallationSpec,
image_is_digest_pinned,
@@ -212,40 +221,7 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
)
)
if spec.release.manifest_url and spec.release.manifest_sha256:
checks.append(
Check(
"release.manifest",
"ok",
"A distribution manifest URL and expected digest are recorded.",
)
)
else:
checks.append(
Check(
"release.manifest",
"error" if spec.profile == "self-hosted" else "warning",
"No verified distribution manifest is recorded.",
"Use a published signed distribution manifest for self-hosted apply.",
)
)
if spec.profile == "self-hosted":
checks.append(
Check(
"release.signature_verification",
"error",
"Signed distribution-manifest verification is not implemented in the deployer yet.",
"Use the published verifier/bootstrap slice before a production apply.",
)
)
checks.append(
Check(
"modules.image_composition",
"error" if spec.enabled_modules else "warning",
"The selected module set is not yet verified against image package contents.",
"Use the signed distribution composition evidence before production apply.",
)
)
checks.extend(_distribution_checks(spec, paths))
values = read_env(paths.env)
required = {"MASTER_KEY_B64", "DATABASE_URL"}
@@ -364,6 +340,130 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
return tuple(checks)
def _distribution_checks(
spec: InstallationSpec,
paths: BundlePaths,
) -> tuple[Check, ...]:
blocking_level = "error" if spec.profile == "self-hosted" else "warning"
if not (
spec.release.manifest_sha256
and spec.release.manifest_keyring_sha256
and spec.release.manifest_signature_key_id
and spec.release.composition_sha256
and paths.manifest.exists()
and paths.keyring.exists()
):
return (
Check(
"release.manifest",
blocking_level,
"No locally verified runtime distribution is recorded.",
"Run govoplan-deploy verify-release --adopt with an independently trusted keyring.",
),
Check(
"release.signature_verification",
blocking_level,
"Runtime distribution signature evidence is unavailable.",
"Install and verify the signed distribution before apply.",
),
Check(
"modules.image_composition",
blocking_level,
"Enabled modules are not bound to image composition evidence.",
"Adopt a distribution whose composition contains every enabled module.",
),
)
try:
manifest_digest = file_sha256(
paths.manifest,
maximum_bytes=MAX_MANIFEST_BYTES,
)
if manifest_digest != spec.release.manifest_sha256:
raise DistributionError("stored manifest digest does not match installation")
keyring_digest = file_sha256(
paths.keyring,
maximum_bytes=MAX_KEYRING_BYTES,
)
if keyring_digest != spec.release.manifest_keyring_sha256:
raise DistributionError("stored keyring digest does not match installation")
manifest = load_bounded_json(
paths.manifest,
maximum_bytes=MAX_MANIFEST_BYTES,
)
keyring = load_bounded_json(
paths.keyring,
maximum_bytes=MAX_KEYRING_BYTES,
)
key_id = verify_manifest(
manifest,
keyring,
expected_channel=spec.release.channel,
)
if key_id != spec.release.manifest_signature_key_id:
raise DistributionError("verified signature key does not match installation")
verify_manifest_binding(
manifest,
channel=spec.release.channel,
version=spec.release.version,
api_image=spec.release.api_image,
web_image=spec.release.web_image,
enabled_modules=spec.enabled_modules,
composition_sha256=spec.release.composition_sha256,
dependencies=_selected_dependency_images(spec),
)
except (DistributionError, OSError) as exc:
return (
Check(
"release.manifest",
blocking_level,
f"Runtime distribution verification failed: {exc}",
"Re-adopt an unexpired, non-revoked manifest from a trusted release key.",
),
Check(
"release.signature_verification",
blocking_level,
"Runtime distribution signature is not trusted.",
"Correct the manifest/keyring binding before apply.",
),
Check(
"modules.image_composition",
blocking_level,
"Runtime image composition is not trusted.",
"Correct the signed composition binding before apply.",
),
)
return (
Check(
"release.manifest",
"ok",
"Stored runtime distribution matches its independently pinned digest.",
),
Check(
"release.signature_verification",
"ok",
f"Runtime distribution is signed by trusted key {key_id}.",
),
Check(
"modules.image_composition",
"ok",
"Every enabled module is present in signed image composition evidence.",
),
)
def _selected_dependency_images(spec: InstallationSpec) -> dict[str, str]:
values = {"load_balancer": spec.components.load_balancer.image}
if spec.components.postgres.mode == "managed":
values["postgres"] = spec.components.postgres.image
if spec.components.redis.mode == "managed":
values["redis"] = spec.components.redis.image
if spec.components.mail.mode == "test-mail":
values["test_mail"] = spec.components.mail.image
if spec.components.storage.mode == "garage":
values["garage"] = spec.components.storage.image
return values
def host_checks(
spec: InstallationSpec,
paths: BundlePaths,