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:
parent
da6f5fb3f8
commit
5a0eb6b343
6 changed files with 592 additions and 140 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue