diff --git a/src/whitehat_security/cli.py b/src/whitehat_security/cli.py index 3a50d4c..9053169 100644 --- a/src/whitehat_security/cli.py +++ b/src/whitehat_security/cli.py @@ -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") diff --git a/src/whitehat_security/plane.py b/src/whitehat_security/plane.py index 1720f2d..ef1d3a8 100644 --- a/src/whitehat_security/plane.py +++ b/src/whitehat_security/plane.py @@ -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"): diff --git a/src/whitehat_security/platform_custody.py b/src/whitehat_security/platform_custody.py index df1d00d..2f3347c 100644 --- a/src/whitehat_security/platform_custody.py +++ b/src/whitehat_security/platform_custody.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3965eae..9f607cc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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") diff --git a/tests/test_platform_custody_adapter.py b/tests/test_platform_custody_adapter.py index 9dfe80d..860be1f 100644 --- a/tests/test_platform_custody_adapter.py +++ b/tests/test_platform_custody_adapter.py @@ -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) diff --git a/workplans/WHITEHAT-WP-0004-canonical-custody-binding.md b/workplans/WHITEHAT-WP-0004-canonical-custody-binding.md new file mode 100644 index 0000000..dd642dd --- /dev/null +++ b/workplans/WHITEHAT-WP-0004-canonical-custody-binding.md @@ -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.