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("registration")
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")
deliver = commands.add_parser("deliver")
deliver.add_argument("report")

View file

@ -106,8 +106,8 @@ class IdentityHandle:
class CredentialBroker(Protocol):
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
def project(self, engagement: Engagement, registration: dict[str, Any],
now: datetime | None = None) -> tuple[IdentityHandle, ...]:
...
def revoke(self, lease_id: str) -> None:
@ -148,8 +148,8 @@ class ReceiptBroker:
def load(cls, path: str | Path) -> "ReceiptBroker":
return cls(json.loads(Path(path).read_text(encoding="utf-8")))
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
def project(self, engagement: Engagement, registration: dict[str, Any],
now: datetime | None = None) -> tuple[IdentityHandle, ...]:
if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]:
raise AuthorizationError("projection receipt engagement_id does not match")
if not self.receipt.get("target_ready"):
@ -160,7 +160,8 @@ class ReceiptBroker:
expiry = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
if expiry.tzinfo is None:
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")
keys = list(self.receipt.get("mounted_keys") or [])
count = int(registration["identities"]["count"])
@ -193,8 +194,8 @@ class ReceiptBroker:
class UnconnectedCustodyBroker:
"""Fail-closed live broker. It never requests or returns credential values."""
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
def project(self, engagement: Engagement, registration: dict[str, Any],
now: datetime | None = None) -> tuple[IdentityHandle, ...]:
raise AuthorizationError(
"live custody broker is not connected; no credential was requested"
)
@ -212,14 +213,15 @@ class LocalBroker:
self._secrets: dict[str, bytes] = {}
self._leases: dict[str, tuple[str, str]] = {}
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
def project(self, engagement: Engagement, registration: dict[str, Any],
now: datetime | None = None) -> tuple[IdentityHandle, ...]:
if registration.get("applicability") != "applicable":
raise AuthorizationError("broker will not project identities for a non-applicable target")
count = int(registration["identities"]["count"])
lease_id = os.urandom(8).hex()
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] = []
if count >= 1:
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)
count = int(registration["identities"]["count"])
if count:
identities = selected.project(engagement, registration)
identities = selected.project(engagement, registration, now=now)
if len(identities) != count:
raise AuthorizationError(f"broker must project exactly {count} identities")
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
else:
if approval_class not in FIXTURE_CLASSES:
selected.project(engagement, registration)
selected.project(engagement, registration, now=now)
identities = ()
lease_id = os.urandom(8).hex()
if hasattr(selected, "_leases"):

View file

@ -2,8 +2,10 @@
from __future__ import annotations
import hashlib
import json
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@ -15,6 +17,8 @@ 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"
SCHEMA_FILES = (
"custody-projection-contract.schema.json",
"custody-broker-readiness.schema.json",
@ -25,6 +29,12 @@ FORBIDDEN_KEYS = {
"token", "tokens", "password", "secret_value", "secret_values",
"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:
@ -54,6 +64,41 @@ def load_schemas() -> dict[str, dict[str, Any]]:
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:
if isinstance(value, dict):
for key, child in value.items():
@ -65,10 +110,18 @@ def _assert_value_safe(value: Any) -> None:
_assert_value_safe(child)
def _require(document: dict[str, Any], key: str) -> Any:
if key not in document:
raise AuthorizationError(f"custody document missing {key}")
return document[key]
def _require_string(value: dict[str, Any], key: str) -> str:
observed = value.get(key)
if not isinstance(observed, str) or not observed:
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:
@ -83,79 +136,254 @@ def _timestamp(value: Any, field: str) -> datetime:
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]:
_assert_value_safe(document)
if document.get("interface") != CONTRACT_INTERFACE or document.get("version") != 1:
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":
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")
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:
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)}
if roles != {"attacker", "owner"} or handles != {"token-a", "token-b"}:
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 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")
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
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]:
validate_projection_contract(contract)
_assert_value_safe(document)
if document.get("interface") != PROJECTION_INTERFACE or document.get("version") != 1:
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")
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:
raise AuthorizationError("projection receipt did not prove secret values were unobserved")
if document.get("cleanup_authority") != "railiance-platform":
raise AuthorizationError("cleanup authority must be railiance-platform")
lease_id = _require(document, "lease_id")
if not isinstance(lease_id, str) or not lease_id.startswith("custody:"):
raise AuthorizationError("projection receipt lease_id is invalid")
projected = _timestamp(document.get("projected_at"), "projected_at")
expires = _timestamp(document.get("expires_at"), "expires_at")
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")
if not isinstance(identities, list) or len(identities) != 2:
raise AuthorizationError("projection receipt must name two identities")
roles = {item.get("role") for item in identities if isinstance(item, dict)}
if roles != {"attacker", "owner"}:
raise AuthorizationError("projection receipt must include attacker and owner")
if contract is not None:
validate_projection_contract(contract)
if document.get("engagement_id") != contract["engagement_id"]:
raise AuthorizationError("projection receipt engagement_id does not match contract")
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 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
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]:
validate_projection_receipt(projection, contract=contract)
_assert_value_safe(document)
if document.get("interface") != CLEANUP_INTERFACE or document.get("version") != 1:
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")
if document.get("secret_values_observed") is not False:
raise AuthorizationError("cleanup receipt did not prove secret values were unobserved")
if document.get("engagement_id") != contract["engagement_id"]:
raise AuthorizationError("cleanup receipt engagement_id does not match contract")
if document.get("lease_id") != projection.get("lease_id"):
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"):
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:
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
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)
if document.get("interface") != BROKER_INTERFACE or document.get("version") != 1:
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")
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:
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
@ -168,13 +396,27 @@ class PlatformCustodyBroker:
self,
receipt: dict[str, Any],
*,
contract: dict[str, Any] | None = None,
contract: dict[str, Any],
cleanup: dict[str, Any] | None = None,
broker: dict[str, Any] | None = None,
now: datetime | None = None,
) -> None:
self.receipt = validate_projection_receipt(receipt, contract=contract)
self.contract = validate_projection_contract(contract) if contract is not None else None
if contract is 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 = (
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
@ -182,38 +424,45 @@ class PlatformCustodyBroker:
cls,
receipt_path: str | Path,
*,
contract_path: str | Path | None = None,
contract_path: str | Path,
cleanup_path: str | Path | None = None,
broker_path: str | Path | None = None,
now: datetime | None = None,
) -> "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"))
contract = (
json.loads(Path(contract_path).read_text(encoding="utf-8"))
if contract_path is not None else None
)
contract = json.loads(Path(contract_path).read_text(encoding="utf-8"))
cleanup = (
json.loads(Path(cleanup_path).read_text(encoding="utf-8"))
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]
) -> tuple[IdentityHandle, ...]:
def project(self, engagement: Engagement, registration: dict[str, Any],
now: datetime | None = None) -> tuple[IdentityHandle, ...]:
if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]:
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")
if datetime.now(UTC) > expiry:
clock = (now or datetime.now(UTC)).astimezone(UTC)
if clock > expiry:
raise AuthorizationError("projection receipt has expired")
count = int(registration["identities"]["count"])
identities = self.receipt["identities"]
if len(identities) != count:
raise AuthorizationError("projection receipt identity count does not match registration")
handles = []
for item in sorted(identities, key=lambda row: str(row.get("handle"))):
role = item["role"]
for item in identities:
mount_path = item["mount_path"]
if not str(mount_path).startswith("/var/run/secrets/whitehat/"):
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"]))
return tuple(handles)
@ -232,6 +481,8 @@ def broker_from_receipt(
"""Select the WP-0025 adapter or the legacy value-safe receipt broker."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
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)
from .plane import ReceiptBroker
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"]
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):
report = json.loads(
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 sys
from datetime import UTC, datetime
from pathlib import Path
import pytest
from whitehat_security.engagement import AuthorizationError, Engagement
from whitehat_security.platform_custody import (
CLEANUP_INTERFACE, CONTRACT_INTERFACE, PROJECTION_INTERFACE,
PlatformCustodyBroker, load_schemas, validate_broker_readiness,
validate_cleanup_receipt, validate_projection_contract,
CLEANUP_INTERFACE,
CONTRACT_INTERFACE,
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,
)
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:
@ -44,7 +58,7 @@ def contract() -> dict:
"window": {
"starts_at": "2026-08-22T12:00:00Z",
"projection_cutoff": "2026-08-22T12:03:00Z",
"expires_at": "2099-01-01T00:00:00Z",
"expires_at": "2026-08-22T12:15:00Z",
},
"authority": {
"remote": "railiance01",
@ -78,57 +92,8 @@ def contract() -> dict:
}
def projection_receipt() -> dict:
return {
"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:
def broker_readiness(bound: dict | None = None) -> dict:
bound = bound or contract()
return {
"interface": "railiance.custody-broker-readiness",
"version": 1,
@ -137,21 +102,13 @@ def broker_readiness() -> dict:
"reviewer": "whitehat-owner",
"decision": "approve",
"created_at": "2026-08-22T12:00:00Z",
"engagement_id": "WH-ENG-FIXTURE-1",
"target_id": "audit-core",
"projection_contract_digest": "1" * 64,
"engagement_id": bound["engagement_id"],
"target_id": bound["target"]["id"],
"projection_contract_digest": contract_digest(bound),
"projection_receipt_interface": PROJECTION_INTERFACE,
"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,
},
"interface_artifacts": interface_artifacts(),
"required_roles": ["attacker", "owner"],
"mount_paths": [
"/var/run/secrets/whitehat/token-a",
"/var/run/secrets/whitehat/token-b",
],
"mount_paths": sorted(item["mount_path"] for item in bound["identities"]),
"adapter": {
"repo": "whitehat-security",
"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():
return {
"engagement_id": "WH-ENG-FIXTURE-1", "authorization_id": "auth-1",
"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",
"environment": "build", "source": "runner", "approval_class": "live-e2",
"plane_namespace": "whitehat", "runner_image_digest": "sha256:abc",
@ -176,8 +195,8 @@ def live_record():
"credential_lane": "receipt", "credential_role": "runtime",
"credential_max_ttl_seconds": 900, "techniques": ["e2-differential"],
"prohibited_techniques": ["saturation"], "rate_limit_per_minute": 10,
"max_concurrency": 1, "window_start": "2026-08-22T11:00:00Z",
"window_end": "2099-01-01T00:00:00Z", "operator_contact": "operator",
"max_concurrency": 1, "window_start": "2026-08-22T12:00:00Z",
"window_end": "2026-08-22T12:15:00Z", "operator_contact": "operator",
"abort_contact": "operator", "posture_claim": "E2",
"attacker_model": "E2-authenticated-tenant-a",
"finding_destination": "risk-nexus",
@ -200,26 +219,129 @@ def test_four_custody_schemas_are_published():
def test_adapter_validates_all_four_document_kinds():
assert validate_projection_contract(contract())["engagement_id"] == "WH-ENG-FIXTURE-1"
assert validate_projection_receipt(projection_receipt(), contract=contract())["state"] == "projected"
assert validate_cleanup_receipt(cleanup_receipt(), projection=projection_receipt())["state"] == "cleaned"
assert validate_broker_readiness(broker_readiness())["owner"] == "whitehat-security"
bound = contract()
projection = projection_receipt(bound)
assert validate_projection_contract(bound)["engagement_id"] == "WH-ENG-FIXTURE-1"
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():
tainted = projection_receipt()
tainted["token"] = "never"
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):
path = tmp_path / "engagement.json"
path.write_text(json.dumps(live_record()), encoding="utf-8")
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
from whitehat_security.targets import load_registration
lease = admit(
engagement=engagement,
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"):
broker.revoke(lease.lease_id)
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)
def test_cleanup_receipt_must_match_lease():
bound = contract()
projection = projection_receipt(bound)
with pytest.raises(AuthorizationError, match="lease_id"):
bad = cleanup_receipt()
bad = cleanup_receipt(bound, projection)
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.