Add Railiance WP-0025 custody adapter

Implement src/whitehat_security/platform_custody.py and focused tests
against the four custody schemas. Live admission still fails closed
without a value-safe projection receipt. No new engagement or traffic.

Assistant: grok
Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb
This commit is contained in:
tegwick 2026-08-22 22:02:41 +02:00
parent 5d2c9592e8
commit 1a38080cdd
5 changed files with 527 additions and 3 deletions

View file

@ -13,7 +13,8 @@ from .e3 import CADENCE, PROBES, e3_calibration
from .engagement import AuthorizationError, Engagement
from .fixtures import FixtureService, probe_suite
from .model import RunReport, utc_now
from .plane import KillSwitch, ReceiptBroker, admit, default_broker, retired_ids
from .plane import KillSwitch, admit, default_broker, retired_ids
from .platform_custody import broker_from_receipt
from .reporting import queue_risk_nexus, risk_nexus_message
from .targets import load_catalog, load_registration
@ -91,6 +92,7 @@ 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")
commands.add_parser("kill-switch")
deliver = commands.add_parser("deliver")
deliver.add_argument("report")
@ -140,7 +142,8 @@ def main(argv: list[str] | None = None) -> None:
record = Engagement.load(args.engagement)
registration = load_registration(args.registration)
broker = (
ReceiptBroker.load(args.receipt) if args.receipt else default_broker(record)
broker_from_receipt(args.receipt, contract_path=args.contract)
if args.receipt else default_broker(record)
)
lease = admit(engagement=record, registration=registration,
broker=broker, kill_switch=KillSwitch(),

View file

@ -0,0 +1,237 @@
"""Consume Railiance WP-0025 custody documents. Never reads secret values."""
from __future__ import annotations
import json
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from .engagement import AuthorizationError, Engagement
from .plane import IdentityHandle
CONTRACT_INTERFACE = "railiance.custody-projection-contract"
BROKER_INTERFACE = "railiance.custody-broker-readiness"
PROJECTION_INTERFACE = "railiance.custody-projection-receipt"
CLEANUP_INTERFACE = "railiance.custody-cleanup-receipt"
SCHEMA_FILES = (
"custody-projection-contract.schema.json",
"custody-broker-readiness.schema.json",
"custody-projection-receipt.schema.json",
"custody-cleanup-receipt.schema.json",
)
FORBIDDEN_KEYS = {
"token", "tokens", "password", "secret_value", "secret_values",
"bearer", "private_key", "credential",
}
def schema_dir() -> Path:
override = os.environ.get("WHITEHAT_CUSTODY_SCHEMA_DIR")
if override:
path = Path(override)
if path.is_dir():
return path
raise AuthorizationError("WHITEHAT_CUSTODY_SCHEMA_DIR is not a directory")
sibling = Path(__file__).resolve().parents[2].parent / "railiance-platform" / "schemas"
if sibling.is_dir():
return sibling
raise AuthorizationError("railiance custody schemas are not available")
def load_schemas() -> dict[str, dict[str, Any]]:
root = schema_dir()
loaded: dict[str, dict[str, Any]] = {}
for name in SCHEMA_FILES:
path = root / name
try:
loaded[name] = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise AuthorizationError(f"custody schema unavailable: {name}") from error
if loaded[name].get("$schema") != "https://json-schema.org/draft/2020-12/schema":
raise AuthorizationError(f"custody schema {name} has the wrong $schema")
return loaded
def _assert_value_safe(value: Any) -> None:
if isinstance(value, dict):
for key, child in value.items():
if str(key).lower().replace("-", "_") in FORBIDDEN_KEYS:
raise AuthorizationError(f"custody document contains secret material: {key}")
_assert_value_safe(child)
elif isinstance(value, list):
for child in value:
_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 _timestamp(value: Any, field: str) -> datetime:
if not isinstance(value, str):
raise AuthorizationError(f"{field} must be RFC3339")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise AuthorizationError(f"{field} must be RFC3339") from error
if parsed.tzinfo is None:
raise AuthorizationError(f"{field} must include a timezone")
return parsed.astimezone(UTC)
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("status") != "approved":
raise AuthorizationError("projection contract is not approved")
identities = document.get("identities")
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"}:
raise AuthorizationError("projection contract identities are not attacker/owner token-a/token-b")
return document
def validate_projection_receipt(
document: dict[str, Any], *, contract: dict[str, Any] | None = None
) -> dict[str, Any]:
_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":
raise AuthorizationError("projection receipt is not in projected state")
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")
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")
return document
def validate_cleanup_receipt(
document: dict[str, Any], *, projection: dict[str, Any]
) -> dict[str, Any]:
_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":
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("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")
if document.get("target_ready") is not True:
raise AuthorizationError("cleanup receipt does not show a ready target")
return document
def validate_broker_readiness(document: dict[str, Any]) -> dict[str, Any]:
_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":
raise AuthorizationError("broker readiness owner must be whitehat-security")
if document.get("secret_values_observed") is not False:
raise AuthorizationError("broker readiness receipt is not value-safe")
return document
class PlatformCustodyBroker:
"""Issue plane identity handles from a WP-0025 projection receipt."""
cleanup_request_supported = True
def __init__(
self,
receipt: dict[str, Any],
*,
contract: dict[str, Any] | None = None,
cleanup: dict[str, Any] | None = None,
) -> None:
self.receipt = validate_projection_receipt(receipt, contract=contract)
self.contract = validate_projection_contract(contract) if contract is not None else None
self.cleanup = (
validate_cleanup_receipt(cleanup, projection=self.receipt) if cleanup is not None else None
)
@classmethod
def load(
cls,
receipt_path: str | Path,
*,
contract_path: str | Path | None = None,
cleanup_path: str | Path | None = None,
) -> "PlatformCustodyBroker":
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
)
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)
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]:
raise AuthorizationError("projection receipt engagement_id does not match")
expiry = _timestamp(self.receipt["expires_at"], "expires_at")
if datetime.now(UTC) > 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"]
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"],
self.receipt["expires_at"]))
return tuple(handles)
def revoke(self, lease_id: str) -> None:
if self.cleanup is None:
raise AuthorizationError(
"receipt broker does not hold credentials; custody must revoke the projection"
)
if self.cleanup["lease_id"] != lease_id:
raise AuthorizationError("cleanup receipt lease_id does not match plane lease")
def broker_from_receipt(
path: str | Path, *, contract_path: str | Path | None = None
) -> Any:
"""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:
return PlatformCustodyBroker.load(path, contract_path=contract_path)
from .plane import ReceiptBroker
return ReceiptBroker(data)