Add signed runtime distribution pipeline
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user