Bind WP-0025 receipts to a canonical projection contract

Railiance's WP-0025 review found the first adapter fail-open: --contract
was optional, receipt_id was not canonical, and target, identities,
resources, UIDs, broker digest and times were unbound. Require the
contract and refuse any receipt that is not the platform canonical form.

Assistant: grok
Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb
This commit is contained in:
tegwick 2026-08-22 22:21:34 +02:00
parent da6f5fb3f8
commit 5a0eb6b343
6 changed files with 592 additions and 140 deletions

View file

@ -92,7 +92,10 @@ def main(argv: list[str] | None = None) -> None:
admit_plane.add_argument("engagement") admit_plane.add_argument("engagement")
admit_plane.add_argument("registration") admit_plane.add_argument("registration")
admit_plane.add_argument("--receipt", help="value-safe custody projection receipt") admit_plane.add_argument("--receipt", help="value-safe custody projection receipt")
admit_plane.add_argument("--contract", help="WP-0025 projection contract bound to the receipt") admit_plane.add_argument(
"--contract",
help="WP-0025 projection contract; required for railiance.custody-projection-receipt",
)
commands.add_parser("kill-switch") commands.add_parser("kill-switch")
deliver = commands.add_parser("deliver") deliver = commands.add_parser("deliver")
deliver.add_argument("report") deliver.add_argument("report")

View file

@ -106,8 +106,8 @@ class IdentityHandle:
class CredentialBroker(Protocol): class CredentialBroker(Protocol):
def project(self, engagement: Engagement, registration: dict[str, Any] def project(self, engagement: Engagement, registration: dict[str, Any],
) -> tuple[IdentityHandle, ...]: now: datetime | None = None) -> tuple[IdentityHandle, ...]:
... ...
def revoke(self, lease_id: str) -> None: def revoke(self, lease_id: str) -> None:
@ -148,8 +148,8 @@ class ReceiptBroker:
def load(cls, path: str | Path) -> "ReceiptBroker": def load(cls, path: str | Path) -> "ReceiptBroker":
return cls(json.loads(Path(path).read_text(encoding="utf-8"))) return cls(json.loads(Path(path).read_text(encoding="utf-8")))
def project(self, engagement: Engagement, registration: dict[str, Any] def project(self, engagement: Engagement, registration: dict[str, Any],
) -> tuple[IdentityHandle, ...]: now: datetime | None = None) -> tuple[IdentityHandle, ...]:
if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]: if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]:
raise AuthorizationError("projection receipt engagement_id does not match") raise AuthorizationError("projection receipt engagement_id does not match")
if not self.receipt.get("target_ready"): if not self.receipt.get("target_ready"):
@ -160,7 +160,8 @@ class ReceiptBroker:
expiry = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00")) expiry = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
if expiry.tzinfo is None: if expiry.tzinfo is None:
raise AuthorizationError("projection receipt expiry must include a timezone") raise AuthorizationError("projection receipt expiry must include a timezone")
if datetime.now(UTC) > expiry.astimezone(UTC): clock = (now or datetime.now(UTC)).astimezone(UTC)
if clock > expiry.astimezone(UTC):
raise AuthorizationError("projection receipt has expired") raise AuthorizationError("projection receipt has expired")
keys = list(self.receipt.get("mounted_keys") or []) keys = list(self.receipt.get("mounted_keys") or [])
count = int(registration["identities"]["count"]) count = int(registration["identities"]["count"])
@ -193,8 +194,8 @@ class ReceiptBroker:
class UnconnectedCustodyBroker: class UnconnectedCustodyBroker:
"""Fail-closed live broker. It never requests or returns credential values.""" """Fail-closed live broker. It never requests or returns credential values."""
def project(self, engagement: Engagement, registration: dict[str, Any] def project(self, engagement: Engagement, registration: dict[str, Any],
) -> tuple[IdentityHandle, ...]: now: datetime | None = None) -> tuple[IdentityHandle, ...]:
raise AuthorizationError( raise AuthorizationError(
"live custody broker is not connected; no credential was requested" "live custody broker is not connected; no credential was requested"
) )
@ -212,14 +213,15 @@ class LocalBroker:
self._secrets: dict[str, bytes] = {} self._secrets: dict[str, bytes] = {}
self._leases: dict[str, tuple[str, str]] = {} self._leases: dict[str, tuple[str, str]] = {}
def project(self, engagement: Engagement, registration: dict[str, Any] def project(self, engagement: Engagement, registration: dict[str, Any],
) -> tuple[IdentityHandle, ...]: now: datetime | None = None) -> tuple[IdentityHandle, ...]:
if registration.get("applicability") != "applicable": if registration.get("applicability") != "applicable":
raise AuthorizationError("broker will not project identities for a non-applicable target") raise AuthorizationError("broker will not project identities for a non-applicable target")
count = int(registration["identities"]["count"]) count = int(registration["identities"]["count"])
lease_id = os.urandom(8).hex() lease_id = os.urandom(8).hex()
ttl = min(int(engagement.raw["credential_max_ttl_seconds"]), STANDING_E2_TTL) ttl = min(int(engagement.raw["credential_max_ttl_seconds"]), STANDING_E2_TTL)
expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat().replace("+00:00", "Z") clock = (now or datetime.now(UTC)).astimezone(UTC)
expires_at = (clock + timedelta(seconds=ttl)).isoformat().replace("+00:00", "Z")
handles: list[IdentityHandle] = [] handles: list[IdentityHandle] = []
if count >= 1: if count >= 1:
role = "owner" if count == 2 else "runtime" role = "owner" if count == 2 else "runtime"
@ -302,7 +304,7 @@ def admit(*, engagement: Engagement, registration: dict[str, Any],
selected = broker or default_broker(engagement) selected = broker or default_broker(engagement)
count = int(registration["identities"]["count"]) count = int(registration["identities"]["count"])
if count: if count:
identities = selected.project(engagement, registration) identities = selected.project(engagement, registration, now=now)
if len(identities) != count: if len(identities) != count:
raise AuthorizationError(f"broker must project exactly {count} identities") raise AuthorizationError(f"broker must project exactly {count} identities")
expected = {2: {"owner", "attacker"}, 1: {"runtime"}}[count] expected = {2: {"owner", "attacker"}, 1: {"runtime"}}[count]
@ -313,7 +315,7 @@ def admit(*, engagement: Engagement, registration: dict[str, Any],
lease_id = identities[0].lease_id lease_id = identities[0].lease_id
else: else:
if approval_class not in FIXTURE_CLASSES: if approval_class not in FIXTURE_CLASSES:
selected.project(engagement, registration) selected.project(engagement, registration, now=now)
identities = () identities = ()
lease_id = os.urandom(8).hex() lease_id = os.urandom(8).hex()
if hasattr(selected, "_leases"): if hasattr(selected, "_leases"):

View file

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os import os
import re
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -15,6 +17,8 @@ CONTRACT_INTERFACE = "railiance.custody-projection-contract"
BROKER_INTERFACE = "railiance.custody-broker-readiness" BROKER_INTERFACE = "railiance.custody-broker-readiness"
PROJECTION_INTERFACE = "railiance.custody-projection-receipt" PROJECTION_INTERFACE = "railiance.custody-projection-receipt"
CLEANUP_INTERFACE = "railiance.custody-cleanup-receipt" CLEANUP_INTERFACE = "railiance.custody-cleanup-receipt"
WORKPLAN_ID = "RAILIANCE-WP-0025"
BROKER_OWNER = "whitehat-security"
SCHEMA_FILES = ( SCHEMA_FILES = (
"custody-projection-contract.schema.json", "custody-projection-contract.schema.json",
"custody-broker-readiness.schema.json", "custody-broker-readiness.schema.json",
@ -25,6 +29,12 @@ FORBIDDEN_KEYS = {
"token", "tokens", "password", "secret_value", "secret_values", "token", "tokens", "password", "secret_value", "secret_values",
"bearer", "private_key", "credential", "bearer", "private_key", "credential",
} }
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")
LEASE_ID = re.compile(r"custody:[0-9a-f]{32}\Z")
RECEIPT_ID = re.compile(r"sha256:[0-9a-f]{64}\Z")
ENGAGEMENT_ID = re.compile(r"[A-Z0-9][A-Z0-9._-]{7,127}\Z")
def schema_dir() -> Path: def schema_dir() -> Path:
@ -54,6 +64,41 @@ def load_schemas() -> dict[str, dict[str, Any]]:
return loaded return loaded
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 interface_artifacts() -> dict[str, str]:
root = schema_dir()
return {f"schemas/{name}": file_digest(root / name) for name in SCHEMA_FILES}
def resource_suffix(engagement_id: str) -> str:
return hashlib.sha256(engagement_id.encode("utf-8")).hexdigest()[:12]
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 _assert_value_safe(value: Any) -> None: def _assert_value_safe(value: Any) -> None:
if isinstance(value, dict): if isinstance(value, dict):
for key, child in value.items(): for key, child in value.items():
@ -65,10 +110,18 @@ def _assert_value_safe(value: Any) -> None:
_assert_value_safe(child) _assert_value_safe(child)
def _require(document: dict[str, Any], key: str) -> Any: def _require_string(value: dict[str, Any], key: str) -> str:
if key not in document: observed = value.get(key)
raise AuthorizationError(f"custody document missing {key}") if not isinstance(observed, str) or not observed:
return document[key] raise AuthorizationError(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 AuthorizationError(f"{key} must be a SHA-256 digest")
return observed.removeprefix("sha256:")
def _timestamp(value: Any, field: str) -> datetime: def _timestamp(value: Any, field: str) -> datetime:
@ -83,79 +136,254 @@ def _timestamp(value: Any, field: str) -> datetime:
return parsed.astimezone(UTC) return parsed.astimezone(UTC)
def contract_digest(contract: dict[str, Any]) -> str:
validate_projection_contract(contract)
return digest(contract)
def validate_projection_contract(document: dict[str, Any]) -> dict[str, Any]: def validate_projection_contract(document: dict[str, Any]) -> dict[str, Any]:
_assert_value_safe(document) _assert_value_safe(document)
if document.get("interface") != CONTRACT_INTERFACE or document.get("version") != 1: if document.get("interface") != CONTRACT_INTERFACE or document.get("version") != 1:
raise AuthorizationError("unsupported projection contract") raise AuthorizationError("unsupported projection contract")
if document.get("workplan_id") != WORKPLAN_ID:
raise AuthorizationError("projection contract has the wrong workplan")
engagement_id = _require_string(document, "engagement_id")
if not ENGAGEMENT_ID.fullmatch(engagement_id):
raise AuthorizationError("engagement_id has an invalid shape")
if document.get("status") != "approved": if document.get("status") != "approved":
raise AuthorizationError("projection contract is not approved") raise AuthorizationError("projection contract is not approved")
target = document.get("target")
runner = document.get("runner")
window = document.get("window")
authority = document.get("authority")
identities = document.get("identities") identities = document.get("identities")
if not all(isinstance(item, dict) for item in (target, runner, window, authority)):
raise AuthorizationError("target, runner, window and authority must be objects")
if not isinstance(identities, list) or len(identities) != 2: if not isinstance(identities, list) or len(identities) != 2:
raise AuthorizationError("projection contract must name two identities") raise AuthorizationError("projection contract must name two identities")
roles = {item.get("role") for item in identities if isinstance(item, dict)}
handles = {item.get("handle") for item in identities if isinstance(item, dict)} target_id = _require_string(target, "id")
if roles != {"attacker", "owner"} or handles != {"token-a", "token-b"}: 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 AuthorizationError("target namespace and deployment must be DNS labels")
revision = _require_string(target, "revision")
if not GIT_REVISION.fullmatch(revision):
raise AuthorizationError("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 AuthorizationError("target.image_digest must be a SHA-256 image digest")
_require_sha(target, "contract_sha256")
_require_sha(runner, "manifest_sha256")
_require_sha(document, "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 AuthorizationError("runner namespace and secret_name must be DNS labels")
if not mount_root.startswith("/") or ".." in Path(mount_root).parts:
raise AuthorizationError("runner.mount_root must be an absolute safe path")
starts = _timestamp(window.get("starts_at"), "window.starts_at")
cutoff = _timestamp(window.get("projection_cutoff"), "window.projection_cutoff")
expires = _timestamp(window.get("expires_at"), "window.expires_at")
if not starts < cutoff < expires:
raise AuthorizationError("window must satisfy starts_at < projection_cutoff < expires_at")
if (expires - starts).total_seconds() > 900:
raise AuthorizationError("ephemeral custody window may not exceed 900 seconds")
remote = _require_string(authority, "remote")
if any(character.isspace() for character in remote):
raise AuthorizationError("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 AuthorizationError("authority.kv_prefix must bind engagement_id and target id")
if "/" in kv_mount or not registry_path or "/" in registry_field:
raise AuthorizationError("invalid KV mount or registry field")
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 AuthorizationError("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 AuthorizationError("temporary identities require may_read and may_write")
if mount_path != f"{mount_root}/{handle}":
raise AuthorizationError("identity mount_path must be mount_root plus its handle")
handles.add(handle)
roles.add(role)
senders.add(sender)
tenants.add(tenant)
if handles != {"token-a", "token-b"} or roles != {"attacker", "owner"}:
raise AuthorizationError("projection contract identities are not attacker/owner token-a/token-b") raise AuthorizationError("projection contract identities are not attacker/owner token-a/token-b")
if len(senders) != 2 or len(tenants) != 2:
raise AuthorizationError("sender names and tenants must be distinct")
names = resource_names(document)
if any(not DNS_LABEL.fullmatch(name) for name in names.values()):
raise AuthorizationError("derived resource name is not a DNS label")
return document return document
def validate_projection_receipt( def validate_projection_receipt(
document: dict[str, Any], *, contract: dict[str, Any] | None = None document: dict[str, Any], *, contract: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
validate_projection_contract(contract)
_assert_value_safe(document) _assert_value_safe(document)
if document.get("interface") != PROJECTION_INTERFACE or document.get("version") != 1: if document.get("interface") != PROJECTION_INTERFACE or document.get("version") != 1:
raise AuthorizationError("unsupported projection receipt") raise AuthorizationError("unsupported projection receipt")
if document.get("state") != "projected": if document.get("workplan_id") != WORKPLAN_ID or document.get("state") != "projected":
raise AuthorizationError("projection receipt is not in projected state") raise AuthorizationError("projection receipt is not in projected state")
if document.get("engagement_id") != contract["engagement_id"]:
raise AuthorizationError("projection receipt engagement_id does not match contract")
if document.get("projection_contract_digest") != contract_digest(contract):
raise AuthorizationError("projection receipt contract digest does not match contract")
expected_target = {
"id": contract["target"]["id"],
"revision": contract["target"]["revision"],
"image_digest": contract["target"]["image_digest"],
}
if document.get("target") != expected_target:
raise AuthorizationError("projection receipt target does not match contract")
lease_id = document.get("lease_id")
if not isinstance(lease_id, str) or not LEASE_ID.fullmatch(lease_id):
raise AuthorizationError("projection receipt lease_id is invalid")
broker_digest = document.get("broker_receipt_digest")
if not isinstance(broker_digest, str) or not re.fullmatch(r"[0-9a-f]{64}", broker_digest):
raise AuthorizationError("projection receipt broker digest is invalid")
if document.get("secret_values_observed") is not False: if document.get("secret_values_observed") is not False:
raise AuthorizationError("projection receipt did not prove secret values were unobserved") raise AuthorizationError("projection receipt did not prove secret values were unobserved")
if document.get("cleanup_authority") != "railiance-platform": if document.get("cleanup_authority") != "railiance-platform":
raise AuthorizationError("cleanup authority must be railiance-platform") raise AuthorizationError("cleanup authority must be railiance-platform")
lease_id = _require(document, "lease_id") projected = _timestamp(document.get("projected_at"), "projected_at")
if not isinstance(lease_id, str) or not lease_id.startswith("custody:"): expires = _timestamp(document.get("expires_at"), "expires_at")
raise AuthorizationError("projection receipt lease_id is invalid") contract_expires = _timestamp(contract["window"]["expires_at"], "window.expires_at")
if expires != contract_expires or projected >= expires:
raise AuthorizationError("projection receipt has invalid projection/expiry times")
identities = document.get("identities") identities = document.get("identities")
if not isinstance(identities, list) or len(identities) != 2: expected_identities = sorted(
raise AuthorizationError("projection receipt must name two identities") (
roles = {item.get("role") for item in identities if isinstance(item, dict)} {
if roles != {"attacker", "owner"}: "handle": item["handle"],
raise AuthorizationError("projection receipt must include attacker and owner") "role": item["role"],
if contract is not None: "sender_name": item["sender_name"],
validate_projection_contract(contract) "mount_path": item["mount_path"],
if document.get("engagement_id") != contract["engagement_id"]: }
raise AuthorizationError("projection receipt engagement_id does not match contract") for item in contract["identities"]
),
key=lambda item: item["handle"],
)
if identities != expected_identities:
raise AuthorizationError("projection receipt identities do not match contract")
resources = document.get("resources")
if not isinstance(resources, dict) or resources.get("names") != resource_names(contract):
raise AuthorizationError("projection receipt resources do not match contract")
uids = resources.get("uids")
if not isinstance(uids, dict) or set(uids) != {"store", "external_secret", "mounted_secret"}:
raise AuthorizationError("projection receipt requires exact Kubernetes UIDs")
if any(not isinstance(uid, str) or not uid for uid in uids.values()):
raise AuthorizationError("projection receipt contains an invalid Kubernetes UID")
base = dict(document)
observed_id = base.pop("receipt_id", None)
if not isinstance(observed_id, str) or not RECEIPT_ID.fullmatch(observed_id):
raise AuthorizationError("projection receipt_id is invalid")
if observed_id != f"sha256:{digest(base)}":
raise AuthorizationError("projection receipt_id is not the canonical content digest")
return document return document
def validate_cleanup_receipt( def validate_cleanup_receipt(
document: dict[str, Any], *, projection: dict[str, Any] document: dict[str, Any], *, projection: dict[str, Any], contract: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
validate_projection_receipt(projection, contract=contract)
_assert_value_safe(document) _assert_value_safe(document)
if document.get("interface") != CLEANUP_INTERFACE or document.get("version") != 1: if document.get("interface") != CLEANUP_INTERFACE or document.get("version") != 1:
raise AuthorizationError("unsupported cleanup receipt") raise AuthorizationError("unsupported cleanup receipt")
if document.get("state") != "cleaned": if document.get("workplan_id") != WORKPLAN_ID or document.get("state") != "cleaned":
raise AuthorizationError("cleanup receipt is not in cleaned state") raise AuthorizationError("cleanup receipt is not in cleaned state")
if document.get("secret_values_observed") is not False: if document.get("engagement_id") != contract["engagement_id"]:
raise AuthorizationError("cleanup receipt did not prove secret values were unobserved") raise AuthorizationError("cleanup receipt engagement_id does not match contract")
if document.get("lease_id") != projection.get("lease_id"): if document.get("lease_id") != projection.get("lease_id"):
raise AuthorizationError("cleanup receipt lease_id does not match projection") raise AuthorizationError("cleanup receipt lease_id does not match projection")
if document.get("engagement_id") != projection.get("engagement_id"):
raise AuthorizationError("cleanup receipt engagement_id does not match projection")
if document.get("projection_receipt_id") != projection.get("receipt_id"): if document.get("projection_receipt_id") != projection.get("receipt_id"):
raise AuthorizationError("cleanup receipt is not bound to this projection receipt") raise AuthorizationError("cleanup receipt is not bound to this projection receipt")
_timestamp(document.get("cleaned_at"), "cleaned_at")
if document.get("target_ready") is not True: if document.get("target_ready") is not True:
raise AuthorizationError("cleanup receipt does not show a ready target") raise AuthorizationError("cleanup receipt does not show a ready target")
if document.get("secret_values_observed") is not False:
raise AuthorizationError("cleanup receipt did not prove secret values were unobserved")
return document return document
def validate_broker_readiness(document: dict[str, Any]) -> dict[str, Any]: def validate_broker_readiness(
document: dict[str, Any], *, contract: dict[str, Any], now: datetime | None = None
) -> dict[str, Any]:
validate_projection_contract(contract)
_assert_value_safe(document) _assert_value_safe(document)
if document.get("interface") != BROKER_INTERFACE or document.get("version") != 1: if document.get("interface") != BROKER_INTERFACE or document.get("version") != 1:
raise AuthorizationError("unsupported broker readiness receipt") raise AuthorizationError("unsupported broker readiness receipt")
if document.get("owner") != "whitehat-security": if document.get("workplan_id") != WORKPLAN_ID or document.get("owner") != BROKER_OWNER:
raise AuthorizationError("broker readiness owner must be whitehat-security") raise AuthorizationError("broker readiness owner must be whitehat-security")
if document.get("decision") != "approve":
raise AuthorizationError("broker readiness receipt is not approved")
if document.get("engagement_id") != contract["engagement_id"]:
raise AuthorizationError("broker readiness engagement does not match contract")
if document.get("target_id") != contract["target"]["id"]:
raise AuthorizationError("broker readiness target does not match contract")
if document.get("projection_contract_digest") != contract_digest(contract):
raise AuthorizationError("broker readiness contract digest does not match contract")
if document.get("projection_receipt_interface") != PROJECTION_INTERFACE:
raise AuthorizationError("broker readiness cannot consume projection receipt v1")
if document.get("interface_artifacts") != interface_artifacts():
raise AuthorizationError("broker readiness interface artifacts are stale")
if document.get("required_roles") != ["attacker", "owner"]:
raise AuthorizationError("broker readiness has the wrong identity roles")
expected_mounts = sorted(item["mount_path"] for item in contract["identities"])
if document.get("mount_paths") != expected_mounts:
raise AuthorizationError("broker readiness mount paths do not match contract")
adapter = document.get("adapter")
if not isinstance(adapter, dict):
raise AuthorizationError("broker readiness 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 AuthorizationError("broker adapter repo/path is invalid")
if not GIT_REVISION.fullmatch(str(adapter.get("revision", ""))):
raise AuthorizationError("broker adapter revision must be a full Git revision")
if not SHA256.fullmatch(str(adapter.get("sha256", ""))):
raise AuthorizationError("broker adapter requires a SHA-256 digest")
if adapter.get("tests_passed") is not True or document.get("cleanup_request_supported") is not True:
raise AuthorizationError("broker adapter tests and cleanup support are required")
if document.get("secret_values_observed") is not False: if document.get("secret_values_observed") is not False:
raise AuthorizationError("broker readiness receipt is not value-safe") raise AuthorizationError("broker readiness receipt is not value-safe")
created = _timestamp(document.get("created_at"), "broker.created_at")
current = (now or datetime.now(UTC)).astimezone(UTC)
starts = _timestamp(contract["window"]["starts_at"], "window.starts_at")
expires = _timestamp(contract["window"]["expires_at"], "window.expires_at")
day_start = starts.replace(hour=0, minute=0, second=0, microsecond=0)
if created > current or created > expires or created < day_start:
raise AuthorizationError("broker receipt timestamp is outside the engagement day")
return document return document
@ -168,13 +396,27 @@ class PlatformCustodyBroker:
self, self,
receipt: dict[str, Any], receipt: dict[str, Any],
*, *,
contract: dict[str, Any] | None = None, contract: dict[str, Any],
cleanup: dict[str, Any] | None = None, cleanup: dict[str, Any] | None = None,
broker: dict[str, Any] | None = None,
now: datetime | None = None,
) -> None: ) -> None:
self.receipt = validate_projection_receipt(receipt, contract=contract) if contract is None:
self.contract = validate_projection_contract(contract) if contract is not None else None raise AuthorizationError("WP-0025 projection receipt requires a bound contract")
self.contract = validate_projection_contract(contract)
self.receipt = validate_projection_receipt(receipt, contract=self.contract)
if broker is not None:
validated = validate_broker_readiness(broker, contract=self.contract, now=now)
if digest(validated) != self.receipt["broker_receipt_digest"]:
raise AuthorizationError("projection receipt broker digest does not match broker receipt")
self.broker = validated
else:
self.broker = None
self.cleanup = ( self.cleanup = (
validate_cleanup_receipt(cleanup, projection=self.receipt) if cleanup is not None else None validate_cleanup_receipt(
cleanup, projection=self.receipt, contract=self.contract
)
if cleanup is not None else None
) )
@classmethod @classmethod
@ -182,38 +424,45 @@ class PlatformCustodyBroker:
cls, cls,
receipt_path: str | Path, receipt_path: str | Path,
*, *,
contract_path: str | Path | None = None, contract_path: str | Path,
cleanup_path: str | Path | None = None, cleanup_path: str | Path | None = None,
broker_path: str | Path | None = None,
now: datetime | None = None,
) -> "PlatformCustodyBroker": ) -> "PlatformCustodyBroker":
if contract_path is None:
raise AuthorizationError("WP-0025 projection receipt requires a bound contract")
receipt = json.loads(Path(receipt_path).read_text(encoding="utf-8")) receipt = json.loads(Path(receipt_path).read_text(encoding="utf-8"))
contract = ( contract = json.loads(Path(contract_path).read_text(encoding="utf-8"))
json.loads(Path(contract_path).read_text(encoding="utf-8"))
if contract_path is not None else None
)
cleanup = ( cleanup = (
json.loads(Path(cleanup_path).read_text(encoding="utf-8")) json.loads(Path(cleanup_path).read_text(encoding="utf-8"))
if cleanup_path is not None else None if cleanup_path is not None else None
) )
return cls(receipt, contract=contract, cleanup=cleanup) broker = (
json.loads(Path(broker_path).read_text(encoding="utf-8"))
if broker_path is not None else None
)
return cls(receipt, contract=contract, cleanup=cleanup, broker=broker, now=now)
def project(self, engagement: Engagement, registration: dict[str, Any] def project(self, engagement: Engagement, registration: dict[str, Any],
) -> tuple[IdentityHandle, ...]: now: datetime | None = None) -> tuple[IdentityHandle, ...]:
if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]: if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]:
raise AuthorizationError("projection receipt engagement_id does not match") raise AuthorizationError("projection receipt engagement_id does not match")
if self.receipt["target"]["id"] != engagement.raw.get("target_id"):
raise AuthorizationError("projection receipt target does not match engagement")
expiry = _timestamp(self.receipt["expires_at"], "expires_at") expiry = _timestamp(self.receipt["expires_at"], "expires_at")
if datetime.now(UTC) > expiry: clock = (now or datetime.now(UTC)).astimezone(UTC)
if clock > expiry:
raise AuthorizationError("projection receipt has expired") raise AuthorizationError("projection receipt has expired")
count = int(registration["identities"]["count"]) count = int(registration["identities"]["count"])
identities = self.receipt["identities"] identities = self.receipt["identities"]
if len(identities) != count: if len(identities) != count:
raise AuthorizationError("projection receipt identity count does not match registration") raise AuthorizationError("projection receipt identity count does not match registration")
handles = [] handles = []
for item in sorted(identities, key=lambda row: str(row.get("handle"))): for item in identities:
role = item["role"]
mount_path = item["mount_path"] mount_path = item["mount_path"]
if not str(mount_path).startswith("/var/run/secrets/whitehat/"): if not str(mount_path).startswith("/var/run/secrets/whitehat/"):
raise AuthorizationError("identity mount_path is outside the whitehat mount") raise AuthorizationError("identity mount_path is outside the whitehat mount")
handles.append(IdentityHandle(role, mount_path, self.receipt["lease_id"], handles.append(IdentityHandle(item["role"], mount_path, self.receipt["lease_id"],
self.receipt["expires_at"])) self.receipt["expires_at"]))
return tuple(handles) return tuple(handles)
@ -232,6 +481,8 @@ def broker_from_receipt(
"""Select the WP-0025 adapter or the legacy value-safe receipt broker.""" """Select the WP-0025 adapter or the legacy value-safe receipt broker."""
data = json.loads(Path(path).read_text(encoding="utf-8")) data = json.loads(Path(path).read_text(encoding="utf-8"))
if data.get("interface") == PROJECTION_INTERFACE: if data.get("interface") == PROJECTION_INTERFACE:
if contract_path is None:
raise AuthorizationError("WP-0025 projection receipt requires a bound contract")
return PlatformCustodyBroker.load(path, contract_path=contract_path) return PlatformCustodyBroker.load(path, contract_path=contract_path)
from .plane import ReceiptBroker from .plane import ReceiptBroker
return ReceiptBroker(data) return ReceiptBroker(data)

View file

@ -147,6 +147,23 @@ def test_example_projection_receipt_is_value_safe():
assert broker.receipt["mounted_keys"] == ["token-a", "token-b"] assert broker.receipt["mounted_keys"] == ["token-a", "token-b"]
def test_admit_plane_wp0025_receipt_requires_contract(tmp_path, capsys):
engagement = tmp_path / "engagement.json"
receipt = tmp_path / "receipt.json"
engagement.write_text(json.dumps(_live_e2_record()), encoding="utf-8")
receipt.write_text(json.dumps({
"interface": "railiance.custody-projection-receipt",
"version": 1,
"engagement_id": "WH-ENG-CLI-RECEIPT",
"secret_values_observed": False,
}), encoding="utf-8")
with pytest.raises(SystemExit) as stopped:
main(["admit-plane", str(engagement), "targets/audit-core-e2.json",
"--receipt", str(receipt)])
assert stopped.value.code == 2
assert "requires a bound contract" in capsys.readouterr().err
def test_deliver_queues_abort_without_calling_it_target_assurance(tmp_path, capsys): def test_deliver_queues_abort_without_calling_it_target_assurance(tmp_path, capsys):
report = json.loads( report = json.loads(
Path("evidence/WH-ENG-20260822-AUDIT-E2-02-abort.json").read_text(encoding="utf-8") Path("evidence/WH-ENG-20260822-AUDIT-E2-02-abort.json").read_text(encoding="utf-8")

View file

@ -1,19 +1,33 @@
import copy
import json import json
import sys
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
import pytest import pytest
from whitehat_security.engagement import AuthorizationError, Engagement from whitehat_security.engagement import AuthorizationError, Engagement
from whitehat_security.platform_custody import ( from whitehat_security.platform_custody import (
CLEANUP_INTERFACE, CONTRACT_INTERFACE, PROJECTION_INTERFACE, CLEANUP_INTERFACE,
PlatformCustodyBroker, load_schemas, validate_broker_readiness, CONTRACT_INTERFACE,
validate_cleanup_receipt, validate_projection_contract, PROJECTION_INTERFACE,
PlatformCustodyBroker,
broker_from_receipt,
contract_digest,
digest,
interface_artifacts,
load_schemas,
resource_names,
validate_broker_readiness,
validate_cleanup_receipt,
validate_projection_contract,
validate_projection_receipt, validate_projection_receipt,
) )
from whitehat_security.plane import KillSwitch, admit from whitehat_security.plane import KillSwitch, admit
from whitehat_security.targets import load_registration
NOW = datetime(2026, 8, 22, 12, tzinfo=UTC) NOW = datetime(2026, 8, 22, 12, 2, tzinfo=UTC)
def contract() -> dict: def contract() -> dict:
@ -44,7 +58,7 @@ def contract() -> dict:
"window": { "window": {
"starts_at": "2026-08-22T12:00:00Z", "starts_at": "2026-08-22T12:00:00Z",
"projection_cutoff": "2026-08-22T12:03:00Z", "projection_cutoff": "2026-08-22T12:03:00Z",
"expires_at": "2099-01-01T00:00:00Z", "expires_at": "2026-08-22T12:15:00Z",
}, },
"authority": { "authority": {
"remote": "railiance01", "remote": "railiance01",
@ -78,57 +92,8 @@ def contract() -> dict:
} }
def projection_receipt() -> dict: def broker_readiness(bound: dict | None = None) -> dict:
return { bound = bound or contract()
"interface": PROJECTION_INTERFACE,
"version": 1,
"workplan_id": "RAILIANCE-WP-0025",
"state": "projected",
"receipt_id": "sha256:" + "e" * 64,
"lease_id": "custody:" + "f" * 32,
"engagement_id": "WH-ENG-FIXTURE-1",
"target": {"id": "audit-core", "revision": "a" * 40, "image_digest": "sha256:" + "b" * 64},
"projection_contract_digest": "1" * 64,
"broker_receipt_digest": "2" * 64,
"projected_at": "2026-08-22T12:02:00Z",
"expires_at": "2099-01-01T00:00:00Z",
"identities": [
{
"handle": "token-a",
"role": "attacker",
"sender_name": "whitehat-e2-a",
"mount_path": "/var/run/secrets/whitehat/token-a",
},
{
"handle": "token-b",
"role": "owner",
"sender_name": "whitehat-e2-b",
"mount_path": "/var/run/secrets/whitehat/token-b",
},
],
"resources": {"names": {"store": "custody-example"}, "uids": {"mounted_secret": "uid"}},
"cleanup_authority": "railiance-platform",
"secret_values_observed": False,
}
def cleanup_receipt() -> dict:
return {
"interface": CLEANUP_INTERFACE,
"version": 1,
"workplan_id": "RAILIANCE-WP-0025",
"state": "cleaned",
"lease_id": "custody:" + "f" * 32,
"engagement_id": "WH-ENG-FIXTURE-1",
"projection_receipt_id": "sha256:" + "e" * 64,
"cleaned_at": "2026-08-22T12:14:00Z",
"removed_resources": ["custody-example"],
"target_ready": True,
"secret_values_observed": False,
}
def broker_readiness() -> dict:
return { return {
"interface": "railiance.custody-broker-readiness", "interface": "railiance.custody-broker-readiness",
"version": 1, "version": 1,
@ -137,21 +102,13 @@ def broker_readiness() -> dict:
"reviewer": "whitehat-owner", "reviewer": "whitehat-owner",
"decision": "approve", "decision": "approve",
"created_at": "2026-08-22T12:00:00Z", "created_at": "2026-08-22T12:00:00Z",
"engagement_id": "WH-ENG-FIXTURE-1", "engagement_id": bound["engagement_id"],
"target_id": "audit-core", "target_id": bound["target"]["id"],
"projection_contract_digest": "1" * 64, "projection_contract_digest": contract_digest(bound),
"projection_receipt_interface": PROJECTION_INTERFACE, "projection_receipt_interface": PROJECTION_INTERFACE,
"interface_artifacts": { "interface_artifacts": interface_artifacts(),
"schemas/custody-projection-contract.schema.json": "a" * 64,
"schemas/custody-broker-readiness.schema.json": "b" * 64,
"schemas/custody-projection-receipt.schema.json": "c" * 64,
"schemas/custody-cleanup-receipt.schema.json": "d" * 64,
},
"required_roles": ["attacker", "owner"], "required_roles": ["attacker", "owner"],
"mount_paths": [ "mount_paths": sorted(item["mount_path"] for item in bound["identities"]),
"/var/run/secrets/whitehat/token-a",
"/var/run/secrets/whitehat/token-b",
],
"adapter": { "adapter": {
"repo": "whitehat-security", "repo": "whitehat-security",
"revision": "a" * 40, "revision": "a" * 40,
@ -164,11 +121,73 @@ def broker_readiness() -> dict:
} }
def projection_receipt(bound: dict | None = None) -> dict:
bound = bound or contract()
base = {
"interface": PROJECTION_INTERFACE,
"version": 1,
"workplan_id": "RAILIANCE-WP-0025",
"state": "projected",
"lease_id": "custody:" + "f" * 32,
"engagement_id": bound["engagement_id"],
"target": {
"id": bound["target"]["id"],
"revision": bound["target"]["revision"],
"image_digest": bound["target"]["image_digest"],
},
"projection_contract_digest": contract_digest(bound),
"broker_receipt_digest": digest(broker_readiness(bound)),
"projected_at": "2026-08-22T12:02:00Z",
"expires_at": bound["window"]["expires_at"],
"identities": sorted(
(
{
"handle": item["handle"],
"role": item["role"],
"sender_name": item["sender_name"],
"mount_path": item["mount_path"],
}
for item in bound["identities"]
),
key=lambda item: item["handle"],
),
"resources": {
"names": resource_names(bound),
"uids": {
"store": "uid-store",
"external_secret": "uid-es",
"mounted_secret": "uid-secret",
},
},
"cleanup_authority": "railiance-platform",
"secret_values_observed": False,
}
return {**base, "receipt_id": "sha256:" + digest(base)}
def cleanup_receipt(bound: dict | None = None, projection: dict | None = None) -> dict:
bound = bound or contract()
projection = projection or projection_receipt(bound)
return {
"interface": CLEANUP_INTERFACE,
"version": 1,
"workplan_id": "RAILIANCE-WP-0025",
"state": "cleaned",
"lease_id": projection["lease_id"],
"engagement_id": bound["engagement_id"],
"projection_receipt_id": projection["receipt_id"],
"cleaned_at": "2026-08-22T12:14:00Z",
"removed_resources": [resource_names(bound)["store"]],
"target_ready": True,
"secret_values_observed": False,
}
def live_record(): def live_record():
return { return {
"engagement_id": "WH-ENG-FIXTURE-1", "authorization_id": "auth-1", "engagement_id": "WH-ENG-FIXTURE-1", "authorization_id": "auth-1",
"authorizer": "operator", "approved_at": "2026-08-22T11:00:00Z", "authorizer": "operator", "approved_at": "2026-08-22T11:00:00Z",
"expires_at": "2099-01-01T00:00:00Z", "target": "https://fixture.invalid", "expires_at": "2026-08-22T12:15:00Z", "target": "https://fixture.invalid",
"target_id": "audit-core", "target_owner": "audit-core", "target_id": "audit-core", "target_owner": "audit-core",
"environment": "build", "source": "runner", "approval_class": "live-e2", "environment": "build", "source": "runner", "approval_class": "live-e2",
"plane_namespace": "whitehat", "runner_image_digest": "sha256:abc", "plane_namespace": "whitehat", "runner_image_digest": "sha256:abc",
@ -176,8 +195,8 @@ def live_record():
"credential_lane": "receipt", "credential_role": "runtime", "credential_lane": "receipt", "credential_role": "runtime",
"credential_max_ttl_seconds": 900, "techniques": ["e2-differential"], "credential_max_ttl_seconds": 900, "techniques": ["e2-differential"],
"prohibited_techniques": ["saturation"], "rate_limit_per_minute": 10, "prohibited_techniques": ["saturation"], "rate_limit_per_minute": 10,
"max_concurrency": 1, "window_start": "2026-08-22T11:00:00Z", "max_concurrency": 1, "window_start": "2026-08-22T12:00:00Z",
"window_end": "2099-01-01T00:00:00Z", "operator_contact": "operator", "window_end": "2026-08-22T12:15:00Z", "operator_contact": "operator",
"abort_contact": "operator", "posture_claim": "E2", "abort_contact": "operator", "posture_claim": "E2",
"attacker_model": "E2-authenticated-tenant-a", "attacker_model": "E2-authenticated-tenant-a",
"finding_destination": "risk-nexus", "finding_destination": "risk-nexus",
@ -200,26 +219,129 @@ def test_four_custody_schemas_are_published():
def test_adapter_validates_all_four_document_kinds(): def test_adapter_validates_all_four_document_kinds():
assert validate_projection_contract(contract())["engagement_id"] == "WH-ENG-FIXTURE-1" bound = contract()
assert validate_projection_receipt(projection_receipt(), contract=contract())["state"] == "projected" projection = projection_receipt(bound)
assert validate_cleanup_receipt(cleanup_receipt(), projection=projection_receipt())["state"] == "cleaned" assert validate_projection_contract(bound)["engagement_id"] == "WH-ENG-FIXTURE-1"
assert validate_broker_readiness(broker_readiness())["owner"] == "whitehat-security" assert validate_projection_receipt(projection, contract=bound)["state"] == "projected"
assert validate_cleanup_receipt(
cleanup_receipt(bound, projection), projection=projection, contract=bound
)["state"] == "cleaned"
assert validate_broker_readiness(
broker_readiness(bound), contract=bound, now=NOW
)["owner"] == "whitehat-security"
def test_adapter_rejects_secret_material(): def test_adapter_rejects_secret_material():
tainted = projection_receipt() tainted = projection_receipt()
tainted["token"] = "never" tainted["token"] = "never"
with pytest.raises(AuthorizationError, match="secret material"): with pytest.raises(AuthorizationError, match="secret material"):
validate_projection_receipt(tainted) validate_projection_receipt(tainted, contract=contract())
def test_receipt_without_contract_is_refused(tmp_path):
path = tmp_path / "receipt.json"
path.write_text(json.dumps(projection_receipt()), encoding="utf-8")
with pytest.raises(AuthorizationError, match="requires a bound contract"):
broker_from_receipt(path)
def test_noncanonical_receipt_id_is_refused():
receipt = projection_receipt()
receipt["receipt_id"] = "sha256:" + "e" * 64
with pytest.raises(AuthorizationError, match="canonical content digest"):
validate_projection_receipt(receipt, contract=contract())
def test_stale_contract_digest_is_refused():
receipt = projection_receipt()
receipt["projection_contract_digest"] = "1" * 64
receipt["receipt_id"] = "sha256:" + digest(
{key: value for key, value in receipt.items() if key != "receipt_id"}
)
with pytest.raises(AuthorizationError, match="contract digest"):
validate_projection_receipt(receipt, contract=contract())
def test_incomplete_resource_uids_are_refused():
bound = contract()
receipt = projection_receipt(bound)
receipt["resources"]["uids"].pop("store")
receipt["receipt_id"] = "sha256:" + digest(
{key: value for key, value in receipt.items() if key != "receipt_id"}
)
with pytest.raises(AuthorizationError, match="exact Kubernetes UIDs"):
validate_projection_receipt(receipt, contract=bound)
def test_target_identity_resource_time_and_broker_digest_are_bound():
bound = contract()
receipt = projection_receipt(bound)
mismatched = copy.deepcopy(receipt)
mismatched["target"]["id"] = "other"
mismatched["receipt_id"] = "sha256:" + digest(
{key: value for key, value in mismatched.items() if key != "receipt_id"}
)
with pytest.raises(AuthorizationError, match="target"):
validate_projection_receipt(mismatched, contract=bound)
mismatched = copy.deepcopy(receipt)
mismatched["identities"][0]["sender_name"] = "wrong"
mismatched["receipt_id"] = "sha256:" + digest(
{key: value for key, value in mismatched.items() if key != "receipt_id"}
)
with pytest.raises(AuthorizationError, match="identities"):
validate_projection_receipt(mismatched, contract=bound)
mismatched = copy.deepcopy(receipt)
mismatched["resources"]["names"]["store"] = "custody-wrong"
mismatched["receipt_id"] = "sha256:" + digest(
{key: value for key, value in mismatched.items() if key != "receipt_id"}
)
with pytest.raises(AuthorizationError, match="resources"):
validate_projection_receipt(mismatched, contract=bound)
mismatched = copy.deepcopy(receipt)
mismatched["expires_at"] = "2026-08-22T12:14:00Z"
mismatched["receipt_id"] = "sha256:" + digest(
{key: value for key, value in mismatched.items() if key != "receipt_id"}
)
with pytest.raises(AuthorizationError, match="projection/expiry"):
validate_projection_receipt(mismatched, contract=bound)
mismatched = copy.deepcopy(receipt)
mismatched["broker_receipt_digest"] = "0" * 64
mismatched["receipt_id"] = "sha256:" + digest(
{key: value for key, value in mismatched.items() if key != "receipt_id"}
)
with pytest.raises(AuthorizationError, match="broker digest"):
PlatformCustodyBroker(
mismatched, contract=bound, broker=broker_readiness(bound), now=NOW
)
def test_platform_validator_accepts_the_canonical_fixture():
scripts = Path.home() / "railiance-platform" / "scripts"
if str(scripts) not in sys.path:
sys.path.insert(0, str(scripts))
import custody_contract
bound = contract()
receipt = projection_receipt(bound)
broker = broker_readiness(bound)
assert receipt is custody_contract.validate_projection_receipt(receipt, bound)
assert broker is custody_contract.validate_broker_receipt(broker, bound, now=NOW)
def test_platform_broker_issues_handles_and_supports_cleanup(tmp_path): def test_platform_broker_issues_handles_and_supports_cleanup(tmp_path):
path = tmp_path / "engagement.json" path = tmp_path / "engagement.json"
path.write_text(json.dumps(live_record()), encoding="utf-8") path.write_text(json.dumps(live_record()), encoding="utf-8")
engagement = Engagement.load(path, now=NOW) engagement = Engagement.load(path, now=NOW)
broker = PlatformCustodyBroker(projection_receipt(), contract=contract()) bound = contract()
receipt = projection_receipt(bound)
broker = PlatformCustodyBroker(
receipt, contract=bound, broker=broker_readiness(bound), now=NOW
)
assert broker.cleanup_request_supported is True assert broker.cleanup_request_supported is True
from whitehat_security.targets import load_registration
lease = admit( lease = admit(
engagement=engagement, engagement=engagement,
registration=load_registration("targets/audit-core-e2.json"), registration=load_registration("targets/audit-core-e2.json"),
@ -233,13 +355,19 @@ def test_platform_broker_issues_handles_and_supports_cleanup(tmp_path):
with pytest.raises(AuthorizationError, match="custody must revoke"): with pytest.raises(AuthorizationError, match="custody must revoke"):
broker.revoke(lease.lease_id) broker.revoke(lease.lease_id)
cleaned = PlatformCustodyBroker( cleaned = PlatformCustodyBroker(
projection_receipt(), contract=contract(), cleanup=cleanup_receipt() receipt,
contract=bound,
cleanup=cleanup_receipt(bound, receipt),
broker=broker_readiness(bound),
now=NOW,
) )
cleaned.revoke(lease.lease_id) cleaned.revoke(lease.lease_id)
def test_cleanup_receipt_must_match_lease(): def test_cleanup_receipt_must_match_lease():
bound = contract()
projection = projection_receipt(bound)
with pytest.raises(AuthorizationError, match="lease_id"): with pytest.raises(AuthorizationError, match="lease_id"):
bad = cleanup_receipt() bad = cleanup_receipt(bound, projection)
bad["lease_id"] = "custody:" + "0" * 32 bad["lease_id"] = "custody:" + "0" * 32
validate_cleanup_receipt(bad, projection=projection_receipt()) validate_cleanup_receipt(bad, projection=projection, contract=bound)

View file

@ -0,0 +1,51 @@
---
id: WHITEHAT-WP-0004
type: workplan
title: "Bind the WP-0025 adapter to canonical, fail-closed custody receipts"
domain: infotech
repo: whitehat-security
status: finished
owner: net-kingdom
topic_slug: whitehat-security
created: "2026-08-22"
updated: "2026-08-22"
related:
- WHITEHAT-WP-0003
- RAILIANCE-WP-0025
---
# WHITEHAT-WP-0004 — canonical custody binding
## Goal
Close the Railiance WP-0025 owner review of adapter `1a38080` / HEAD
`da6f5fb`: the consumer discovered the four schemas and its focused tests
passed, but it was not fail-closed or canonically bound. A live projection
receipt must not admit a plane lease unless it matches a supplied projection
contract.
This plan authorizes no engagement, runner, credential, or traffic.
## Origin
Platform progress `d1fce546` (2026-08-22T20:09:59Z) on RAILIANCE-WP-0025 T03:
noncanonical `receipt_id`, `projection_contract_digest` not derived from the
supplied contract, incomplete resource UIDs accepted, runtime `--contract`
optional, and receipt validation unbound from target, identities, resources,
broker digest, times, and canonical receipt id.
## Tasks
### T01 — Require a contract and bind the receipt canonically
```task
id: WHITEHAT-WP-0004-T01
status: done
priority: high
```
`PlatformCustodyBroker` and `admit-plane` refuse a WP-0025 projection receipt
without `--contract`. Validation derives the contract digest, requires the
canonical `receipt_id`, and binds target, identities, resource names, the
three Kubernetes UIDs, broker digest, and projection/expiry times. The focused
adapter tests reject each of those fail-open cases.