445 lines
19 KiB
Python
445 lines
19 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Canonical, value-safe contracts for ephemeral credential projection."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
import urllib.error
|
||
|
|
import urllib.parse
|
||
|
|
import urllib.request
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any, Callable
|
||
|
|
|
||
|
|
|
||
|
|
CONTRACT_INTERFACE = "railiance.custody-projection-contract"
|
||
|
|
BROKER_INTERFACE = "railiance.custody-broker-readiness"
|
||
|
|
PROJECTION_INTERFACE = "railiance.custody-projection-receipt"
|
||
|
|
CLEANUP_INTERFACE = "railiance.custody-cleanup-receipt"
|
||
|
|
WORKPLAN_ID = "RAILIANCE-WP-0025"
|
||
|
|
BROKER_OWNER = "whitehat-security"
|
||
|
|
BROKER_SUBJECT_PREFIX = "WP0025-BROKER-READINESS"
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
INTERFACE_ARTIFACTS = (
|
||
|
|
"schemas/custody-projection-contract.schema.json",
|
||
|
|
"schemas/custody-broker-readiness.schema.json",
|
||
|
|
"schemas/custody-projection-receipt.schema.json",
|
||
|
|
"schemas/custody-cleanup-receipt.schema.json",
|
||
|
|
)
|
||
|
|
SHA256 = re.compile(r"(?:sha256:)?[0-9a-f]{64}\Z")
|
||
|
|
GIT_REVISION = re.compile(r"[0-9a-f]{40}\Z")
|
||
|
|
DNS_LABEL = re.compile(r"[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?\Z")
|
||
|
|
|
||
|
|
|
||
|
|
class ContractError(RuntimeError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def canonical_json(value: Any) -> str:
|
||
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||
|
|
|
||
|
|
|
||
|
|
def digest(value: Any) -> str:
|
||
|
|
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def file_digest(path: Path) -> str:
|
||
|
|
result = hashlib.sha256()
|
||
|
|
with path.open("rb") as handle:
|
||
|
|
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||
|
|
result.update(block)
|
||
|
|
return result.hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def parse_time(value: Any, field: str) -> datetime:
|
||
|
|
if not isinstance(value, str):
|
||
|
|
raise ContractError(f"{field} must be an RFC3339 timestamp")
|
||
|
|
try:
|
||
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||
|
|
except ValueError as exc:
|
||
|
|
raise ContractError(f"{field} must be an RFC3339 timestamp") from exc
|
||
|
|
if parsed.tzinfo is None:
|
||
|
|
raise ContractError(f"{field} must include a timezone")
|
||
|
|
return parsed.astimezone(UTC)
|
||
|
|
|
||
|
|
|
||
|
|
def load_json(path: Path) -> dict[str, Any]:
|
||
|
|
try:
|
||
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||
|
|
except (OSError, json.JSONDecodeError) as exc:
|
||
|
|
raise ContractError(f"JSON document is unavailable or invalid: {path}") from exc
|
||
|
|
if not isinstance(value, dict):
|
||
|
|
raise ContractError("JSON document must be an object")
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def _require_string(value: dict[str, Any], key: str) -> str:
|
||
|
|
observed = value.get(key)
|
||
|
|
if not isinstance(observed, str) or not observed:
|
||
|
|
raise ContractError(f"{key} must be a non-empty string")
|
||
|
|
return observed
|
||
|
|
|
||
|
|
|
||
|
|
def _require_sha(value: dict[str, Any], key: str) -> str:
|
||
|
|
observed = _require_string(value, key)
|
||
|
|
if not SHA256.fullmatch(observed):
|
||
|
|
raise ContractError(f"{key} must be a SHA-256 digest")
|
||
|
|
return observed.removeprefix("sha256:")
|
||
|
|
|
||
|
|
|
||
|
|
def resource_suffix(engagement_id: str) -> str:
|
||
|
|
return hashlib.sha256(engagement_id.encode("utf-8")).hexdigest()[:12]
|
||
|
|
|
||
|
|
|
||
|
|
def interface_artifacts(root: Path = REPO_ROOT) -> dict[str, str]:
|
||
|
|
return {path: file_digest(root / path) for path in INTERFACE_ARTIFACTS}
|
||
|
|
|
||
|
|
|
||
|
|
def resource_names(contract: dict[str, Any]) -> dict[str, str]:
|
||
|
|
suffix = resource_suffix(contract["engagement_id"])
|
||
|
|
return {
|
||
|
|
"policy": f"custody-{suffix}",
|
||
|
|
"role": f"custody-{suffix}",
|
||
|
|
"store": f"custody-{suffix}",
|
||
|
|
"external_secret": contract["runner"]["secret_name"],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def validate_projection_contract(value: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
if value.get("interface") != CONTRACT_INTERFACE or value.get("version") != 1:
|
||
|
|
raise ContractError("unsupported projection contract interface/version")
|
||
|
|
if value.get("workplan_id") != WORKPLAN_ID:
|
||
|
|
raise ContractError("projection contract has the wrong workplan")
|
||
|
|
engagement_id = _require_string(value, "engagement_id")
|
||
|
|
if not re.fullmatch(r"[A-Z0-9][A-Z0-9._-]{7,127}", engagement_id):
|
||
|
|
raise ContractError("engagement_id has an invalid shape")
|
||
|
|
if value.get("status") != "approved":
|
||
|
|
raise ContractError("engagement must be approved")
|
||
|
|
|
||
|
|
target = value.get("target")
|
||
|
|
runner = value.get("runner")
|
||
|
|
window = value.get("window")
|
||
|
|
authority = value.get("authority")
|
||
|
|
identities = value.get("identities")
|
||
|
|
if not all(isinstance(item, dict) for item in (target, runner, window, authority)):
|
||
|
|
raise ContractError("target, runner, window and authority must be objects")
|
||
|
|
if not isinstance(identities, list) or len(identities) != 2:
|
||
|
|
raise ContractError("exactly two identity handles are required")
|
||
|
|
|
||
|
|
target_id = _require_string(target, "id")
|
||
|
|
target_namespace = _require_string(target, "namespace")
|
||
|
|
target_deployment = _require_string(target, "deployment")
|
||
|
|
_require_string(target, "container")
|
||
|
|
_require_string(target, "sender_external_secret")
|
||
|
|
if not DNS_LABEL.fullmatch(target_namespace) or not DNS_LABEL.fullmatch(target_deployment):
|
||
|
|
raise ContractError("target namespace and deployment must be DNS labels")
|
||
|
|
revision = _require_string(target, "revision")
|
||
|
|
if not GIT_REVISION.fullmatch(revision):
|
||
|
|
raise ContractError("target.revision must be a full Git revision")
|
||
|
|
image = _require_string(target, "image_digest")
|
||
|
|
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image):
|
||
|
|
raise ContractError("target.image_digest must be a SHA-256 image digest")
|
||
|
|
_require_sha(target, "contract_sha256")
|
||
|
|
_require_sha(runner, "manifest_sha256")
|
||
|
|
_require_sha(value, "engagement_contract_sha256")
|
||
|
|
|
||
|
|
namespace = _require_string(runner, "namespace")
|
||
|
|
secret_name = _require_string(runner, "secret_name")
|
||
|
|
_require_string(runner, "service_account")
|
||
|
|
mount_root = _require_string(runner, "mount_root").rstrip("/")
|
||
|
|
if not DNS_LABEL.fullmatch(namespace) or not DNS_LABEL.fullmatch(secret_name):
|
||
|
|
raise ContractError("runner namespace and secret_name must be DNS labels")
|
||
|
|
if not mount_root.startswith("/") or ".." in Path(mount_root).parts:
|
||
|
|
raise ContractError("runner.mount_root must be an absolute safe path")
|
||
|
|
|
||
|
|
starts = parse_time(window.get("starts_at"), "window.starts_at")
|
||
|
|
cutoff = parse_time(window.get("projection_cutoff"), "window.projection_cutoff")
|
||
|
|
expires = parse_time(window.get("expires_at"), "window.expires_at")
|
||
|
|
if not starts < cutoff < expires:
|
||
|
|
raise ContractError("window must satisfy starts_at < projection_cutoff < expires_at")
|
||
|
|
if (expires - starts).total_seconds() > 900:
|
||
|
|
raise ContractError("ephemeral custody window may not exceed 900 seconds")
|
||
|
|
|
||
|
|
remote = _require_string(authority, "remote")
|
||
|
|
if any(character.isspace() for character in remote):
|
||
|
|
raise ContractError("authority.remote must be one SSH destination")
|
||
|
|
registry_path = _require_string(authority, "registry_path")
|
||
|
|
registry_field = _require_string(authority, "registry_field")
|
||
|
|
kv_mount = _require_string(authority, "kv_mount")
|
||
|
|
kv_prefix = _require_string(authority, "kv_prefix").strip("/")
|
||
|
|
_require_string(authority, "eso_service_account")
|
||
|
|
_require_string(authority, "eso_namespace")
|
||
|
|
if engagement_id not in kv_prefix or target_id not in kv_prefix:
|
||
|
|
raise ContractError("authority.kv_prefix must bind engagement_id and target id")
|
||
|
|
if "/" in kv_mount or not registry_path or "/" in registry_field:
|
||
|
|
raise ContractError("invalid KV mount or registry field")
|
||
|
|
|
||
|
|
expected_handles = {"token-a", "token-b"}
|
||
|
|
handles: set[str] = set()
|
||
|
|
roles: set[str] = set()
|
||
|
|
senders: set[str] = set()
|
||
|
|
tenants: set[str] = set()
|
||
|
|
for identity in identities:
|
||
|
|
if not isinstance(identity, dict):
|
||
|
|
raise ContractError("identity entries must be objects")
|
||
|
|
handle = _require_string(identity, "handle")
|
||
|
|
role = _require_string(identity, "role")
|
||
|
|
sender = _require_string(identity, "sender_name")
|
||
|
|
tenant = _require_string(identity, "tenant")
|
||
|
|
mount_path = _require_string(identity, "mount_path")
|
||
|
|
if identity.get("may_read") is not True or identity.get("may_write") is not True:
|
||
|
|
raise ContractError("temporary identities require may_read=true and may_write=true")
|
||
|
|
if mount_path != f"{mount_root}/{handle}":
|
||
|
|
raise ContractError("identity mount_path must be mount_root plus its handle")
|
||
|
|
handles.add(handle)
|
||
|
|
roles.add(role)
|
||
|
|
senders.add(sender)
|
||
|
|
tenants.add(tenant)
|
||
|
|
if handles != expected_handles or roles != {"attacker", "owner"}:
|
||
|
|
raise ContractError("identities must provide token-a/token-b and attacker/owner roles")
|
||
|
|
if len(senders) != 2 or len(tenants) != 2:
|
||
|
|
raise ContractError("sender names and tenants must be distinct")
|
||
|
|
|
||
|
|
names = resource_names(value)
|
||
|
|
if any(not DNS_LABEL.fullmatch(name) for name in names.values()):
|
||
|
|
raise ContractError("derived resource name is not a DNS label")
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def contract_digest(contract: dict[str, Any]) -> str:
|
||
|
|
validate_projection_contract(contract)
|
||
|
|
return digest(contract)
|
||
|
|
|
||
|
|
|
||
|
|
def assert_value_safe(value: Any) -> None:
|
||
|
|
forbidden_keys = {
|
||
|
|
"token",
|
||
|
|
"tokens",
|
||
|
|
"password",
|
||
|
|
"secret_value",
|
||
|
|
"secret_values",
|
||
|
|
"bearer",
|
||
|
|
"private_key",
|
||
|
|
}
|
||
|
|
if isinstance(value, dict):
|
||
|
|
for key, item in value.items():
|
||
|
|
if str(key).lower() in forbidden_keys:
|
||
|
|
raise ContractError(f"value-bearing field is forbidden: {key}")
|
||
|
|
assert_value_safe(item)
|
||
|
|
elif isinstance(value, list):
|
||
|
|
for item in value:
|
||
|
|
assert_value_safe(item)
|
||
|
|
|
||
|
|
|
||
|
|
def broker_subject(receipt: dict[str, Any]) -> str:
|
||
|
|
return "/".join(
|
||
|
|
(
|
||
|
|
BROKER_SUBJECT_PREFIX,
|
||
|
|
"v1",
|
||
|
|
receipt["engagement_id"],
|
||
|
|
receipt["decision"],
|
||
|
|
receipt["projection_contract_digest"],
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_broker_receipt(
|
||
|
|
receipt: dict[str, Any], contract: dict[str, Any], *, now: datetime | None = None
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
validate_projection_contract(contract)
|
||
|
|
assert_value_safe(receipt)
|
||
|
|
if receipt.get("interface") != BROKER_INTERFACE or receipt.get("version") != 1:
|
||
|
|
raise ContractError("unsupported broker receipt interface/version")
|
||
|
|
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("owner") != BROKER_OWNER:
|
||
|
|
raise ContractError("broker receipt has the wrong workplan or owner")
|
||
|
|
if receipt.get("decision") != "approve":
|
||
|
|
raise ContractError("broker receipt is not approved")
|
||
|
|
if receipt.get("engagement_id") != contract["engagement_id"]:
|
||
|
|
raise ContractError("broker receipt engagement does not match")
|
||
|
|
if receipt.get("target_id") != contract["target"]["id"]:
|
||
|
|
raise ContractError("broker receipt target does not match")
|
||
|
|
if receipt.get("projection_contract_digest") != contract_digest(contract):
|
||
|
|
raise ContractError("broker receipt projection contract is stale")
|
||
|
|
if receipt.get("projection_receipt_interface") != PROJECTION_INTERFACE:
|
||
|
|
raise ContractError("broker receipt cannot consume projection receipt v1")
|
||
|
|
if receipt.get("interface_artifacts") != interface_artifacts():
|
||
|
|
raise ContractError("broker receipt interface artifacts are stale")
|
||
|
|
if receipt.get("required_roles") != ["attacker", "owner"]:
|
||
|
|
raise ContractError("broker receipt has the wrong identity roles")
|
||
|
|
expected_mounts = sorted(identity["mount_path"] for identity in contract["identities"])
|
||
|
|
if receipt.get("mount_paths") != expected_mounts:
|
||
|
|
raise ContractError("broker receipt mount paths do not match")
|
||
|
|
adapter = receipt.get("adapter")
|
||
|
|
if not isinstance(adapter, dict):
|
||
|
|
raise ContractError("broker receipt requires adapter evidence")
|
||
|
|
adapter_path = adapter.get("path")
|
||
|
|
if (
|
||
|
|
adapter.get("repo") != "whitehat-security"
|
||
|
|
or not isinstance(adapter_path, str)
|
||
|
|
or not adapter_path
|
||
|
|
or Path(adapter_path).is_absolute()
|
||
|
|
or ".." in Path(adapter_path).parts
|
||
|
|
):
|
||
|
|
raise ContractError("broker adapter repo/path is invalid")
|
||
|
|
if not GIT_REVISION.fullmatch(str(adapter.get("revision", ""))):
|
||
|
|
raise ContractError("broker adapter revision must be a full Git revision")
|
||
|
|
if not SHA256.fullmatch(str(adapter.get("sha256", ""))):
|
||
|
|
raise ContractError("broker adapter requires a SHA-256 digest")
|
||
|
|
if adapter.get("tests_passed") is not True or receipt.get("cleanup_request_supported") is not True:
|
||
|
|
raise ContractError("broker adapter tests and cleanup support are required")
|
||
|
|
if receipt.get("secret_values_observed") is not False:
|
||
|
|
raise ContractError("broker receipt is not value-safe")
|
||
|
|
created = parse_time(receipt.get("created_at"), "broker.created_at")
|
||
|
|
current = (now or datetime.now(UTC)).astimezone(UTC)
|
||
|
|
starts = parse_time(contract["window"]["starts_at"], "window.starts_at")
|
||
|
|
expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
|
||
|
|
if created > current or created > expires or created < starts.replace(hour=0, minute=0, second=0, microsecond=0):
|
||
|
|
raise ContractError("broker receipt timestamp is outside the engagement day")
|
||
|
|
return receipt
|
||
|
|
|
||
|
|
|
||
|
|
def parse_broker_message(
|
||
|
|
message: dict[str, Any], contract: dict[str, Any], *, now: datetime | None = None
|
||
|
|
) -> dict[str, Any] | None:
|
||
|
|
if message.get("from_agent") != BROKER_OWNER or message.get("to_agent") != "railiance-platform":
|
||
|
|
return None
|
||
|
|
body = message.get("body")
|
||
|
|
if not isinstance(body, str):
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
receipt = json.loads(body)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
return None
|
||
|
|
if not isinstance(receipt, dict):
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
if message.get("subject") != broker_subject(receipt):
|
||
|
|
return None
|
||
|
|
return validate_broker_receipt(receipt, contract, now=now)
|
||
|
|
except (ContractError, KeyError, TypeError):
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def http_json(
|
||
|
|
method: str,
|
||
|
|
url: str,
|
||
|
|
payload: dict[str, Any] | None = None,
|
||
|
|
*,
|
||
|
|
opener: Callable[..., Any] = urllib.request.urlopen,
|
||
|
|
) -> Any:
|
||
|
|
data = canonical_json(payload).encode("utf-8") if payload is not None else None
|
||
|
|
request = urllib.request.Request(
|
||
|
|
url,
|
||
|
|
data=data,
|
||
|
|
method=method,
|
||
|
|
headers={"Content-Type": "application/json"} if data else {},
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
with opener(request, timeout=10) as response:
|
||
|
|
return json.loads(response.read().decode("utf-8"))
|
||
|
|
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||
|
|
raise ContractError(f"State Hub request failed: {method} {url}") from exc
|
||
|
|
|
||
|
|
|
||
|
|
def current_broker_receipt(
|
||
|
|
contract: dict[str, Any], api_base: str, *, now: datetime | None = None
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
query = urllib.parse.urlencode(
|
||
|
|
{"to_agent": "railiance-platform", "unread_only": "false"}
|
||
|
|
)
|
||
|
|
messages = http_json("GET", f"{api_base.rstrip('/')}/messages/?{query}")
|
||
|
|
if not isinstance(messages, list):
|
||
|
|
raise ContractError("State Hub returned an invalid message list")
|
||
|
|
receipts = [
|
||
|
|
parsed
|
||
|
|
for message in messages
|
||
|
|
if isinstance(message, dict)
|
||
|
|
and (parsed := parse_broker_message(message, contract, now=now)) is not None
|
||
|
|
]
|
||
|
|
if not receipts:
|
||
|
|
raise ContractError("no current Whitehat broker-readiness receipt exists")
|
||
|
|
return max(receipts, key=lambda item: item["created_at"])
|
||
|
|
|
||
|
|
|
||
|
|
def validate_projection_receipt(
|
||
|
|
receipt: dict[str, Any], contract: dict[str, Any]
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
validate_projection_contract(contract)
|
||
|
|
assert_value_safe(receipt)
|
||
|
|
if receipt.get("interface") != PROJECTION_INTERFACE or receipt.get("version") != 1:
|
||
|
|
raise ContractError("unsupported projection receipt interface/version")
|
||
|
|
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("state") != "projected":
|
||
|
|
raise ContractError("projection receipt has the wrong workplan or state")
|
||
|
|
if receipt.get("engagement_id") != contract["engagement_id"]:
|
||
|
|
raise ContractError("projection receipt engagement does not match")
|
||
|
|
if receipt.get("projection_contract_digest") != contract_digest(contract):
|
||
|
|
raise ContractError("projection receipt contract is stale")
|
||
|
|
expected_target = {
|
||
|
|
"id": contract["target"]["id"],
|
||
|
|
"revision": contract["target"]["revision"],
|
||
|
|
"image_digest": contract["target"]["image_digest"],
|
||
|
|
}
|
||
|
|
if receipt.get("target") != expected_target:
|
||
|
|
raise ContractError("projection receipt target does not match")
|
||
|
|
lease_id = receipt.get("lease_id")
|
||
|
|
if not isinstance(lease_id, str) or not re.fullmatch(r"custody:[0-9a-f]{32}", lease_id):
|
||
|
|
raise ContractError("projection receipt lease_id is invalid")
|
||
|
|
if receipt.get("secret_values_observed") is not False:
|
||
|
|
raise ContractError("projection receipt is not value-safe")
|
||
|
|
projected = parse_time(receipt.get("projected_at"), "projection.projected_at")
|
||
|
|
expires = parse_time(receipt.get("expires_at"), "projection.expires_at")
|
||
|
|
contract_expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
|
||
|
|
if expires != contract_expires or projected >= expires:
|
||
|
|
raise ContractError("projection receipt has invalid projection/expiry times")
|
||
|
|
identities = receipt.get("identities")
|
||
|
|
expected_identities = sorted(
|
||
|
|
(
|
||
|
|
{
|
||
|
|
"handle": item["handle"],
|
||
|
|
"role": item["role"],
|
||
|
|
"sender_name": item["sender_name"],
|
||
|
|
"mount_path": item["mount_path"],
|
||
|
|
}
|
||
|
|
for item in contract["identities"]
|
||
|
|
),
|
||
|
|
key=lambda item: item["handle"],
|
||
|
|
)
|
||
|
|
if identities != expected_identities:
|
||
|
|
raise ContractError("projection receipt identity handles do not match")
|
||
|
|
resources = receipt.get("resources")
|
||
|
|
if not isinstance(resources, dict) or resources.get("names") != resource_names(contract):
|
||
|
|
raise ContractError("projection receipt resources do not match")
|
||
|
|
uids = resources.get("uids")
|
||
|
|
if not isinstance(uids, dict) or set(uids) != {"store", "external_secret", "mounted_secret"}:
|
||
|
|
raise ContractError("projection receipt requires exact Kubernetes UIDs")
|
||
|
|
if any(not isinstance(uid, str) or not uid for uid in uids.values()):
|
||
|
|
raise ContractError("projection receipt contains an invalid Kubernetes UID")
|
||
|
|
base = dict(receipt)
|
||
|
|
observed_id = base.pop("receipt_id", None)
|
||
|
|
if observed_id != f"sha256:{digest(base)}":
|
||
|
|
raise ContractError("projection receipt id does not match its canonical content")
|
||
|
|
return receipt
|
||
|
|
|
||
|
|
|
||
|
|
def validate_cleanup_receipt(
|
||
|
|
receipt: dict[str, Any], projection: dict[str, Any], contract: dict[str, Any]
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
validate_projection_receipt(projection, contract)
|
||
|
|
assert_value_safe(receipt)
|
||
|
|
if receipt.get("interface") != CLEANUP_INTERFACE or receipt.get("version") != 1:
|
||
|
|
raise ContractError("unsupported cleanup receipt interface/version")
|
||
|
|
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("state") != "cleaned":
|
||
|
|
raise ContractError("cleanup receipt has the wrong workplan or state")
|
||
|
|
if receipt.get("engagement_id") != contract["engagement_id"]:
|
||
|
|
raise ContractError("cleanup receipt engagement does not match")
|
||
|
|
if receipt.get("lease_id") != projection["lease_id"]:
|
||
|
|
raise ContractError("cleanup receipt lease does not match")
|
||
|
|
if receipt.get("projection_receipt_id") != projection["receipt_id"]:
|
||
|
|
raise ContractError("cleanup receipt projection id does not match")
|
||
|
|
parse_time(receipt.get("cleaned_at"), "cleanup.cleaned_at")
|
||
|
|
if receipt.get("target_ready") is not True or receipt.get("secret_values_observed") is not False:
|
||
|
|
raise ContractError("cleanup receipt does not prove safe target recovery")
|
||
|
|
return receipt
|