Enforce signed backup evidence before migrations
This commit is contained in:
@@ -74,7 +74,9 @@ def read_bounded_bytes(path: Path, *, maximum_bytes: int) -> bytes:
|
||||
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}")
|
||||
raise DistributionError(
|
||||
f"trusted JSON file is invalid or too large: {path}"
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
@@ -109,7 +111,9 @@ def fetch_bounded_https(
|
||||
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")
|
||||
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"})
|
||||
@@ -172,9 +176,11 @@ def validate_manifest(
|
||||
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:
|
||||
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")
|
||||
@@ -283,11 +289,41 @@ def verify_manifest(
|
||||
) -> str:
|
||||
current = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
validate_manifest(payload, expected_channel=expected_channel, now=current)
|
||||
keys = _trusted_keys(keyring, 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] = []
|
||||
for item in payload["signatures"]:
|
||||
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
|
||||
@@ -303,8 +339,10 @@ def verify_manifest(
|
||||
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}")
|
||||
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(
|
||||
@@ -319,7 +357,9 @@ def verify_manifest_binding(
|
||||
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")
|
||||
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")
|
||||
@@ -372,9 +412,9 @@ def verify_offline_image_index(
|
||||
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"
|
||||
):
|
||||
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))
|
||||
@@ -418,19 +458,21 @@ 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="distribution keyring",
|
||||
label=f"{label} 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")
|
||||
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("distribution keyring contains no keys")
|
||||
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}]")
|
||||
@@ -453,11 +495,11 @@ def _trusted_keys(
|
||||
pattern=KEY_ID,
|
||||
)
|
||||
if key_id in trusted:
|
||||
raise DistributionError("distribution keyring contains duplicate key ids")
|
||||
raise DistributionError(f"{label} keyring contains duplicate key ids")
|
||||
if key.get("algorithm") != "ed25519":
|
||||
raise DistributionError("distribution key must use ed25519")
|
||||
raise DistributionError(f"{label} key must use ed25519")
|
||||
if key.get("status") not in {"active", "retired", "revoked"}:
|
||||
raise DistributionError("distribution key has an invalid status")
|
||||
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")
|
||||
@@ -466,11 +508,11 @@ def _trusted_keys(
|
||||
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")
|
||||
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("distribution keyring has no currently active keys")
|
||||
raise DistributionError(f"{label} keyring has no currently active keys")
|
||||
return trusted
|
||||
|
||||
|
||||
@@ -534,13 +576,17 @@ def _require_public_host(hostname: str) -> None:
|
||||
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
|
||||
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")
|
||||
raise DistributionError(
|
||||
"distribution host resolves to a non-public address"
|
||||
)
|
||||
|
||||
|
||||
def _sha256_regular_file(path: Path, *, maximum_bytes: int) -> str:
|
||||
@@ -553,7 +599,9 @@ def _sha256_regular_file(path: Path, *, maximum_bytes: int) -> str:
|
||||
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}")
|
||||
raise DistributionError(
|
||||
f"immutable artifact is invalid or too large: {path}"
|
||||
)
|
||||
while True:
|
||||
chunk = os.read(descriptor, 1024 * 1024)
|
||||
if not chunk:
|
||||
@@ -602,7 +650,11 @@ def _token(
|
||||
maximum: int,
|
||||
pattern: re.Pattern[str],
|
||||
) -> str:
|
||||
if not isinstance(value, str) or len(value) > maximum or pattern.fullmatch(value) is None:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) > maximum
|
||||
or pattern.fullmatch(value) is None
|
||||
):
|
||||
raise DistributionError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
@@ -626,7 +678,11 @@ def _sha256(value: object, label: str) -> str:
|
||||
|
||||
|
||||
def _digest_image(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or len(value) > 300 or DIGEST_IMAGE.fullmatch(value) is None:
|
||||
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
|
||||
|
||||
@@ -635,7 +691,12 @@ 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:
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user