Add versioned ephemeral custody lifecycle
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
codex 2026-08-22 21:56:42 +02:00
parent 985cef2572
commit 30e6edc236
17 changed files with 2855 additions and 6 deletions

View file

@ -23,6 +23,7 @@
| workplan | RAILIANCE-WP-0022 | finished | — | workplans/RAILIANCE-WP-0022-agent-high-risk-boundary-coverage.md |
| workplan | RAILIANCE-WP-0023 | finished | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
| workplan | RAILIANCE-WP-0024 | active | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
| workplan | RAILIANCE-WP-0025 | finished | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
| workplan | RPF-WP-0018 | finished | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
| workplan | RPF-WP-0019 | finished | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md |
| workplan | RPF-WP-0020 | finished | — | workplans/RPF-WP-0020-ccr-schema-drift.md |
@ -97,10 +98,15 @@
| task | RAILIANCE-WP-0023-T01 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
| task | RAILIANCE-WP-0023-T02 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
| task | RAILIANCE-WP-0023-T03 | done | — | workplans/RAILIANCE-WP-0023-hub-core-candidate-credential-lanes.md |
| task | RAILIANCE-WP-0024-T01 | wait | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
| task | RAILIANCE-WP-0024-T01 | done | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
| task | RAILIANCE-WP-0024-T02 | progress | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
| task | RAILIANCE-WP-0024-T03 | progress | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
| task | RAILIANCE-WP-0024-T04 | done | — | workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md |
| task | RAILIANCE-WP-0025-T01 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
| task | RAILIANCE-WP-0025-T02 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
| task | RAILIANCE-WP-0025-T03 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
| task | RAILIANCE-WP-0025-T04 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
| task | RAILIANCE-WP-0025-T05 | done | — | workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md |
| task | RPF-WP-0018-T01 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
| task | RPF-WP-0018-T02 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
| task | RPF-WP-0018-T03 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |

View file

@ -0,0 +1,59 @@
{
"interface": "railiance.custody-projection-contract",
"version": 1,
"workplan_id": "RAILIANCE-WP-0025",
"engagement_id": "REPLACE-WITH-NEW-TERMINAL-UNIQUE-ID",
"status": "replace-with-explicit-approval",
"engagement_contract_sha256": "replace-with-64-lowercase-hex",
"target": {
"id": "replace-with-target-id",
"namespace": "replace-with-target-namespace",
"deployment": "replace-with-target-deployment",
"container": "replace-with-target-container",
"sender_external_secret": "replace-with-sender-external-secret",
"revision": "replace-with-full-40-character-git-revision",
"image_digest": "sha256:replace-with-64-lowercase-hex",
"contract_sha256": "replace-with-64-lowercase-hex"
},
"runner": {
"namespace": "whitehat",
"service_account": "whitehat-runner",
"secret_name": "replace-with-exact-mounted-secret-name",
"mount_root": "/var/run/secrets/whitehat",
"manifest_sha256": "replace-with-64-lowercase-hex"
},
"window": {
"starts_at": "replace-with-RFC3339-start",
"projection_cutoff": "replace-with-RFC3339-projection-cutoff",
"expires_at": "replace-with-RFC3339-expiry-at-most-900-seconds-after-start"
},
"authority": {
"remote": "railiance01",
"registry_path": "replace-with-exact-platform-registry-path",
"registry_field": "senders.json",
"kv_mount": "platform",
"kv_prefix": "engagements/REPLACE-WITH-NEW-TERMINAL-UNIQUE-ID/replace-with-target-id",
"eso_service_account": "external-secrets",
"eso_namespace": "external-secrets"
},
"identities": [
{
"handle": "token-a",
"role": "attacker",
"sender_name": "replace-with-new-attacker-sender",
"tenant": "replace-with-new-attacker-fixture-tenant",
"mount_path": "/var/run/secrets/whitehat/token-a",
"may_read": true,
"may_write": true
},
{
"handle": "token-b",
"role": "owner",
"sender_name": "replace-with-new-owner-sender",
"tenant": "replace-with-new-owner-fixture-tenant",
"mount_path": "/var/run/secrets/whitehat/token-b",
"may_read": true,
"may_write": true
}
]
}

View file

@ -0,0 +1,132 @@
# Ephemeral custody projection lifecycle
`scripts/custody-projection.py` is the platform-owned interface for future
short-lived, mount-only credential exercises. It replaces copying the dated
`audit-core-whitehat-e2-credentials*.py`, HCL, and YAML files. Those dated
artifacts are retained only as historical evidence and cleanup references for
their terminal engagement ids.
The lifecycle does not authorize an engagement. A projection contract is
accepted only when it is `approved`, lasts at most 900 seconds, binds exact
target and runner revisions, names exactly two distinct sender identities, and
uses engagement- and target-bound KV paths. Kubernetes and OpenBao resource
names are derived from the SHA-256 of the engagement id; no previous engagement
name is copied into a new manifest.
## Versioned interfaces
| Interface | Schema | Producer | Consumer |
| --- | --- | --- | --- |
| `railiance.custody-projection-contract` v1 | `schemas/custody-projection-contract.schema.json` | engagement/platform owners | platform lifecycle and Whitehat review |
| `railiance.custody-broker-readiness` v1 | `schemas/custody-broker-readiness.schema.json` | `whitehat-security` | platform pre-projection gate |
| `railiance.custody-projection-receipt` v1 | `schemas/custody-projection-receipt.schema.json` | platform lifecycle | Whitehat broker adapter and operator |
| `railiance.custody-cleanup-receipt` v1 | `schemas/custody-cleanup-receipt.schema.json` | platform lifecycle | engagement owner and evidence record |
Receipts contain no bearer, password, value-derived fingerprint, Secret data,
or registry body. The projection receipt carries an opaque lease id, canonical
receipt id, exact identity roles and mount paths, contract digests, times,
resource names, and Kubernetes UIDs. Changing any canonical field invalidates
the receipt id.
## Broker gate owned directly by Whitehat
The owner does not need a coding agent to translate the request. Given the
final projection contract, it can run:
```sh
python3 scripts/wp0025-broker-readiness.py show \
--contract /path/to/final-projection-contract.json
python3 scripts/wp0025-broker-readiness.py verify \
--contract /path/to/final-projection-contract.json \
--consumer-root /path/to/whitehat-security
python3 scripts/wp0025-broker-readiness.py approve \
--contract /path/to/final-projection-contract.json \
--consumer-root /path/to/whitehat-security \
--reviewer whitehat-owner
```
Verification is closed to
`src/whitehat_security/platform_custody.py` and
`tests/test_platform_custody_adapter.py`. It records the full consumer commit,
adapter SHA-256, focused-test result, exact platform schema hashes, mount paths,
and cleanup support. Approval posts the canonical receipt directly to State
Hub as `from_agent=whitehat-security`. A stale schema, changed projection
contract, wrong engagement, wrong target, absent adapter, or failing focused
test does not count.
The platform lifecycle queries that receipt before token generation and before
the first mutation. State Hub coordination identity is not a secret transport;
the receipt contains no secret.
## Platform commands
The template at `docs/custody-projection-contract.example.json` intentionally
fails validation until every placeholder and the approval status are replaced
by the owners.
```sh
python3 scripts/custody-projection.py validate \
--contract /path/to/final-projection-contract.json
python3 scripts/custody-projection.py render \
--contract /path/to/final-projection-contract.json
python3 scripts/custody-projection.py preflight \
--contract /path/to/final-projection-contract.json
```
`render` is value-free and creates no resource. `preflight` reads only status,
metadata, names, image/readiness, and the broker receipt. It never reads a
Kubernetes Secret value.
Inside the approved projection interval, an attached custody operator may run:
```sh
python3 scripts/custody-projection.py project \
--contract /path/to/final-projection-contract.json \
--confirm '<engagement-id>:attended' \
--receipt-out /approved/non-repo/path/projection.json
```
If any mutation fails, the transaction invokes exact-scope cleanup. The
operator must not release the consumer until the returned projection receipt
passes its adapter. The receipt file is written mode `0600` even though it is
value-safe.
Status and normal attended cleanup require that receipt:
```sh
python3 scripts/custody-projection.py status \
--contract /path/to/final-projection-contract.json \
--receipt /approved/non-repo/path/projection.json
python3 scripts/custody-projection.py cleanup \
--contract /path/to/final-projection-contract.json \
--receipt /approved/non-repo/path/projection.json \
--confirm '<engagement-id>:cleanup' \
--receipt-out /approved/non-repo/path/cleanup.json
```
Status reports `active`, `absent`, `partial`, or `mismatched`. It compares live
resource UIDs with the receipt and does not interpret a connection error as
absence.
## Expired cleanup entry point
`cleanup-expired` refuses until the contract expiry and still requires the
exact projection receipt and confirmation:
```sh
python3 scripts/custody-projection.py cleanup-expired \
--contract /path/to/final-projection-contract.json \
--receipt /approved/non-repo/path/projection.json \
--confirm '<engagement-id>:expired' \
--receipt-out /approved/non-repo/path/cleanup.json
```
This is an approval-ready reaper entry point, not a deployed scheduler. A
future controller still needs an owner-reviewed credential route, runtime
identity, alerting, and rollout decision. Application `expires_at` remains the
acceptance backstop; evidenced resource deletion remains the cleanup result.

View file

@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coulomb.social/schemas/railiance/custody-broker-readiness.v1.json",
"title": "Railiance custody broker readiness receipt v1",
"type": "object",
"additionalProperties": false,
"required": [
"interface", "version", "workplan_id", "owner", "reviewer", "decision",
"created_at", "engagement_id", "target_id", "projection_contract_digest",
"projection_receipt_interface", "interface_artifacts", "required_roles", "mount_paths",
"secret_values_observed"
],
"properties": {
"interface": {"const": "railiance.custody-broker-readiness"},
"version": {"const": 1},
"workplan_id": {"const": "RAILIANCE-WP-0025"},
"owner": {"const": "whitehat-security"},
"reviewer": {"type": "string", "minLength": 2, "maxLength": 128},
"decision": {"enum": ["approve", "request-changes"]},
"created_at": {"type": "string", "format": "date-time"},
"engagement_id": {"type": "string"},
"target_id": {"type": "string"},
"projection_contract_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"projection_receipt_interface": {"const": "railiance.custody-projection-receipt"},
"interface_artifacts": {
"type": "object",
"minProperties": 4,
"additionalProperties": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
},
"required_roles": {"const": ["attacker", "owner"]},
"mount_paths": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "string", "pattern": "^/"}},
"adapter": {
"type": "object",
"additionalProperties": false,
"required": ["repo", "revision", "path", "sha256", "tests_passed"],
"properties": {
"repo": {"const": "whitehat-security"},
"revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"path": {"type": "string", "minLength": 1},
"sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"tests_passed": {"const": true}
}
},
"cleanup_request_supported": {"const": true},
"note": {"type": "string", "minLength": 1, "maxLength": 2000},
"secret_values_observed": {"const": false}
},
"allOf": [
{
"if": {"properties": {"decision": {"const": "approve"}}},
"then": {"required": ["adapter", "cleanup_request_supported"]},
"else": {"required": ["note"]}
}
]
}

View file

@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coulomb.social/schemas/railiance/custody-cleanup-receipt.v1.json",
"title": "Railiance custody cleanup receipt v1",
"type": "object",
"additionalProperties": false,
"required": [
"interface", "version", "workplan_id", "state", "lease_id", "engagement_id",
"projection_receipt_id", "cleaned_at", "removed_resources", "target_ready",
"secret_values_observed"
],
"properties": {
"interface": {"const": "railiance.custody-cleanup-receipt"},
"version": {"const": 1},
"workplan_id": {"const": "RAILIANCE-WP-0025"},
"state": {"const": "cleaned"},
"lease_id": {"type": "string", "pattern": "^custody:[0-9a-f]{32}$"},
"engagement_id": {"type": "string"},
"projection_receipt_id": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"cleaned_at": {"type": "string", "format": "date-time"},
"removed_resources": {"type": "array", "items": {"type": "string"}},
"target_ready": {"const": true},
"secret_values_observed": {"const": false}
}
}

View file

@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coulomb.social/schemas/railiance/custody-projection-contract.v1.json",
"title": "Railiance ephemeral custody projection contract v1",
"type": "object",
"additionalProperties": false,
"required": [
"interface", "version", "workplan_id", "engagement_id", "status",
"engagement_contract_sha256", "target", "runner", "window", "authority",
"identities"
],
"properties": {
"interface": {"const": "railiance.custody-projection-contract"},
"version": {"const": 1},
"workplan_id": {"const": "RAILIANCE-WP-0025"},
"engagement_id": {"type": "string", "pattern": "^[A-Z0-9][A-Z0-9._-]{7,127}$"},
"status": {"const": "approved"},
"engagement_contract_sha256": {"$ref": "#/$defs/sha256"},
"target": {
"type": "object",
"additionalProperties": false,
"required": [
"id", "namespace", "deployment", "container", "sender_external_secret", "revision",
"image_digest", "contract_sha256"
],
"properties": {
"id": {"type": "string", "minLength": 1},
"namespace": {"$ref": "#/$defs/dnsLabel"},
"deployment": {"$ref": "#/$defs/dnsLabel"},
"container": {"$ref": "#/$defs/dnsLabel"},
"sender_external_secret": {"$ref": "#/$defs/dnsLabel"},
"revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"image_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"contract_sha256": {"$ref": "#/$defs/sha256"}
}
},
"runner": {
"type": "object",
"additionalProperties": false,
"required": ["namespace", "service_account", "secret_name", "mount_root", "manifest_sha256"],
"properties": {
"namespace": {"$ref": "#/$defs/dnsLabel"},
"service_account": {"$ref": "#/$defs/dnsLabel"},
"secret_name": {"$ref": "#/$defs/dnsLabel"},
"mount_root": {"type": "string", "pattern": "^/"},
"manifest_sha256": {"$ref": "#/$defs/sha256"}
}
},
"window": {
"type": "object",
"additionalProperties": false,
"required": ["starts_at", "projection_cutoff", "expires_at"],
"properties": {
"starts_at": {"type": "string", "format": "date-time"},
"projection_cutoff": {"type": "string", "format": "date-time"},
"expires_at": {"type": "string", "format": "date-time"}
}
},
"authority": {
"type": "object",
"additionalProperties": false,
"required": [
"remote", "registry_path", "registry_field", "kv_mount", "kv_prefix",
"eso_service_account", "eso_namespace"
],
"properties": {
"remote": {"type": "string", "pattern": "^\\S+$"},
"registry_path": {"type": "string", "minLength": 1},
"registry_field": {"type": "string", "minLength": 1},
"kv_mount": {"type": "string", "pattern": "^[^/]+$"},
"kv_prefix": {"type": "string", "minLength": 1},
"eso_service_account": {"$ref": "#/$defs/dnsLabel"},
"eso_namespace": {"$ref": "#/$defs/dnsLabel"}
}
},
"identities": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["handle", "role", "sender_name", "tenant", "mount_path", "may_read", "may_write"],
"properties": {
"handle": {"enum": ["token-a", "token-b"]},
"role": {"enum": ["attacker", "owner"]},
"sender_name": {"type": "string", "minLength": 1},
"tenant": {"type": "string", "minLength": 1},
"mount_path": {"type": "string", "pattern": "^/"},
"may_read": {"const": true},
"may_write": {"const": true}
}
}
}
},
"$defs": {
"sha256": {"type": "string", "pattern": "^(sha256:)?[0-9a-f]{64}$"},
"dnsLabel": {"type": "string", "pattern": "^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$"}
}
}

View file

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coulomb.social/schemas/railiance/custody-projection-receipt.v1.json",
"title": "Railiance active custody projection receipt v1",
"type": "object",
"additionalProperties": false,
"required": [
"interface", "version", "workplan_id", "state", "receipt_id", "lease_id",
"engagement_id", "target", "projection_contract_digest", "broker_receipt_digest",
"projected_at", "expires_at", "identities", "resources", "cleanup_authority",
"secret_values_observed"
],
"properties": {
"interface": {"const": "railiance.custody-projection-receipt"},
"version": {"const": 1},
"workplan_id": {"const": "RAILIANCE-WP-0025"},
"state": {"const": "projected"},
"receipt_id": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"},
"lease_id": {"type": "string", "pattern": "^custody:[0-9a-f]{32}$"},
"engagement_id": {"type": "string"},
"target": {"type": "object"},
"projection_contract_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"broker_receipt_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"projected_at": {"type": "string", "format": "date-time"},
"expires_at": {"type": "string", "format": "date-time"},
"identities": {"type": "array", "minItems": 2, "maxItems": 2},
"resources": {"type": "object"},
"cleanup_authority": {"const": "railiance-platform"},
"secret_values_observed": {"const": false}
}
}

948
scripts/custody-projection.py Executable file
View file

@ -0,0 +1,948 @@
#!/usr/bin/env python3
"""Data-driven, attended ephemeral credential custody lifecycle.
The command never prints or persists bearer values. Live projection requires a
current Whitehat broker-readiness receipt in State Hub and an exact attended
confirmation. Cleanup is scoped only by the validated projection contract and
receipt.
"""
from __future__ import annotations
import argparse
import json
import os
import secrets
import stat
import subprocess
import sys
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from custody_contract import ( # noqa: E402
CLEANUP_INTERFACE,
PROJECTION_INTERFACE,
WORKPLAN_ID,
ContractError,
canonical_json,
contract_digest,
current_broker_receipt,
digest,
load_json,
parse_time,
resource_names,
validate_cleanup_receipt,
validate_projection_contract,
validate_projection_receipt,
)
from remote_exec import RemoteExecutionError, run_remote # noqa: E402
DEFAULT_API_BASE = os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000")
class ProcedureError(RuntimeError):
pass
def utc_now() -> datetime:
return datetime.now(UTC)
def rfc3339(value: datetime) -> str:
return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def safe_run(
command: list[str],
*,
label: str,
env: dict[str, str] | None = None,
input_text: str | None = None,
allow_missing: bool = False,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> subprocess.CompletedProcess[str]:
completed = runner(
command,
text=True,
input=input_text,
capture_output=True,
env=env,
check=False,
)
if completed.returncode and not allow_missing:
raise ProcedureError(f"{label} failed (exit {completed.returncode})")
return completed
def secure_unlink(path: Path) -> None:
if not path.exists():
return
size = path.stat().st_size
with path.open("r+b", buffering=0) as handle:
handle.write(b"\0" * size)
handle.flush()
os.fsync(handle.fileno())
path.unlink()
def write_receipt(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(json.dumps(value, indent=2, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
finally:
if temporary.exists():
temporary.unlink()
class Operator:
def __init__(self, contract: dict[str, Any], token_file: Path) -> None:
mode = stat.S_IMODE(token_file.stat().st_mode) if token_file.exists() else None
if mode != 0o600:
raise ProcedureError(f"OpenBao token file must exist with mode 0600: {token_file}")
token = token_file.read_text(encoding="utf-8").splitlines()[0].strip()
if not token:
raise ProcedureError("OpenBao token file is empty")
self.contract = contract
self.remote = contract["authority"]["remote"]
self.bao_env = dict(
os.environ,
BAO_ADDR=os.environ.get("BAO_ADDR", "https://bao.coulomb.social"),
BAO_TOKEN=token,
)
def bao(
self,
args: list[str],
*,
label: str,
input_text: str | None = None,
allow_missing: bool = False,
) -> subprocess.CompletedProcess[str]:
return safe_run(
["bao", *args],
label=label,
env=self.bao_env,
input_text=input_text,
allow_missing=allow_missing,
)
def kubectl(
self,
args: list[str],
*,
label: str,
input_text: str | None = None,
allow_missing: bool = False,
) -> subprocess.CompletedProcess[str]:
try:
return run_remote(
self.remote,
["kubectl", *args],
label=label,
input_text=input_text,
allow_missing=allow_missing,
)
except RemoteExecutionError as exc:
raise ProcedureError(str(exc)) from exc
def bao_exists(self, args: list[str], *, label: str) -> bool:
result = self.bao(args, label=label, allow_missing=True)
if result.returncode == 0:
return True
error = result.stderr.lower()
if "404" in error or "no value found" in error or "no policy named" in error:
return False
raise ProcedureError(f"{label} failed without a confirmed not-found response")
def kubectl_exists(self, args: list[str], *, label: str) -> bool:
result = self.kubectl(args, label=label, allow_missing=True)
return result.returncode == 0 and bool(result.stdout.strip())
def registry(self) -> list[dict[str, Any]]:
authority = self.contract["authority"]
result = self.bao(
["kv", "get", f"-field={authority['registry_field']}", authority["registry_path"]],
label="read sender registry",
)
try:
value = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ProcedureError("sender registry is not valid JSON") from exc
if not isinstance(value, list):
raise ProcedureError("sender registry must be a list")
return value
def write_registry(self, registry: list[dict[str, Any]]) -> None:
authority = self.contract["authority"]
with tempfile.TemporaryDirectory(prefix="railiance-custody-registry-") as directory:
root = Path(directory)
os.chmod(root, 0o700)
document = root / "registry.json"
document.write_text(canonical_json(registry), encoding="utf-8")
os.chmod(document, 0o600)
try:
self.bao(
[
"kv",
"patch",
authority["registry_path"],
f"{authority['registry_field']}=@{document}",
],
label="write sender registry",
)
finally:
secure_unlink(document)
def identity_names(contract: dict[str, Any]) -> set[str]:
return {item["sender_name"] for item in contract["identities"]}
def token_paths(contract: dict[str, Any]) -> dict[str, str]:
authority = contract["authority"]
prefix = authority["kv_prefix"].strip("/")
return {
item["handle"]: f"{authority['kv_mount']}/{prefix}/{item['handle']}"
for item in contract["identities"]
}
def add_temporary_identities(
registry: list[dict[str, Any]], contract: dict[str, Any], values: dict[str, str]
) -> list[dict[str, Any]]:
names = identity_names(contract)
present = {str(item.get("name")) for item in registry if isinstance(item, dict)}
if present & names:
raise ProcedureError(f"temporary identities already exist: {sorted(present & names)}")
updated = list(registry)
expires = contract["window"]["expires_at"]
for identity in contract["identities"]:
updated.append(
{
"name": identity["sender_name"],
"tokens": [values[identity["handle"]]],
"sources": ["whitehat-security"],
"tenants": [identity["tenant"]],
"may_write": True,
"may_read": True,
"expires_at": expires,
}
)
return updated
def remove_temporary_identities(
registry: list[dict[str, Any]], contract: dict[str, Any]
) -> tuple[list[dict[str, Any]], list[str]]:
names = identity_names(contract)
removed = sorted(
str(item.get("name"))
for item in registry
if isinstance(item, dict) and item.get("name") in names
)
updated = [
item
for item in registry
if not isinstance(item, dict) or item.get("name") not in names
]
return updated, removed
def build_policy(contract: dict[str, Any]) -> str:
paths = token_paths(contract)
blocks: list[str] = []
for path in paths.values():
mount, relative = path.split("/", 1)
blocks.extend(
[
f'path "{mount}/data/{relative}" {{',
' capabilities = ["read"]',
"}",
"",
f'path "{mount}/metadata/{relative}" {{',
' capabilities = ["read"]',
"}",
"",
]
)
return "\n".join(blocks).rstrip() + "\n"
def build_manifest(contract: dict[str, Any], lease_id: str) -> str:
names = resource_names(contract)
authority = contract["authority"]
runner = contract["runner"]
engagement = contract["engagement_id"]
annotations = {
"custody.railiance/engagement": engagement,
"custody.railiance/lease-id": lease_id,
"custody.railiance/contract-sha256": contract_digest(contract),
}
document = {
"apiVersion": "v1",
"kind": "List",
"items": [
{
"apiVersion": "external-secrets.io/v1",
"kind": "ClusterSecretStore",
"metadata": {"name": names["store"], "annotations": annotations},
"spec": {
"provider": {
"vault": {
"server": "http://openbao.openbao.svc:8200",
"path": authority["kv_mount"],
"version": "v2",
"auth": {
"kubernetes": {
"mountPath": "kubernetes",
"role": names["role"],
"serviceAccountRef": {
"name": authority["eso_service_account"],
"namespace": authority["eso_namespace"],
},
}
},
}
},
"conditions": [{"namespaces": [runner["namespace"]]}],
},
},
{
"apiVersion": "external-secrets.io/v1",
"kind": "ExternalSecret",
"metadata": {
"name": names["external_secret"],
"namespace": runner["namespace"],
"annotations": annotations,
},
"spec": {
"refreshInterval": "1m",
"secretStoreRef": {"kind": "ClusterSecretStore", "name": names["store"]},
"target": {
"name": runner["secret_name"],
"creationPolicy": "Owner",
"deletionPolicy": "Delete",
},
"data": [
{
"secretKey": identity["handle"],
"remoteRef": {
"key": f"{authority['kv_prefix'].strip('/')}/{identity['handle']}",
"property": "token",
},
}
for identity in sorted(contract["identities"], key=lambda item: item["handle"])
],
},
},
],
}
return canonical_json(document)
def time_state(contract: dict[str, Any], now: datetime) -> str:
starts = parse_time(contract["window"]["starts_at"], "window.starts_at")
cutoff = parse_time(contract["window"]["projection_cutoff"], "window.projection_cutoff")
if now < starts:
return "before-window"
if now > cutoff:
return "projection-cutoff-passed"
return "projection-window-open"
def target_ready(operator: Operator, contract: dict[str, Any]) -> str:
target = contract["target"]
image = operator.kubectl(
[
"-n",
target["namespace"],
"get",
"deploy",
target["deployment"],
"-o",
f"jsonpath={{.spec.template.spec.containers[?(@.name==\"{target['container']}\")].image}}",
],
label="read target image",
).stdout.strip()
if not image.endswith("@" + target["image_digest"]):
raise ProcedureError("target is not on the contract-approved image digest")
ready = operator.kubectl(
[
"-n",
target["namespace"],
"get",
"deploy",
target["deployment"],
"-o",
"jsonpath={.status.readyReplicas}/{.status.replicas}",
],
label="read target readiness",
).stdout.strip()
if ready != "1/1":
raise ProcedureError(f"target is not 1/1 Ready (observed {ready or 'unknown'})")
return ready
def resource_presence(operator: Operator, contract: dict[str, Any]) -> dict[str, Any]:
names = resource_names(contract)
runner = contract["runner"]
paths = token_paths(contract)
identities = sorted(
identity_names(contract)
& {
str(item.get("name"))
for item in operator.registry()
if isinstance(item, dict)
}
)
exact_paths = sorted(
handle
for handle, path in paths.items()
if operator.bao_exists(
["kv", "metadata", "get", "-format=json", path],
label=f"check exact {handle} KV metadata",
)
)
resources: list[str] = []
checks = (
("store", ["get", "clustersecretstore", names["store"], "-o", "name"]),
(
"external_secret",
["-n", runner["namespace"], "get", "externalsecret", names["external_secret"], "-o", "name"],
),
(
"mounted_secret",
["-n", runner["namespace"], "get", "secret", runner["secret_name"], "-o", "name"],
),
)
for label, args in checks:
if operator.kubectl_exists(args, label=f"check exact {label}"):
resources.append(label)
return {
"temporary_identities_present": identities,
"exact_token_paths_present": exact_paths,
"projection_resources_present": resources,
}
def live_preflight(
operator: Operator,
contract: dict[str, Any],
*,
api_base: str = DEFAULT_API_BASE,
now: datetime | None = None,
) -> dict[str, Any]:
current = (now or utc_now()).astimezone(UTC)
operator.bao(["status", "-format=json"], label="read OpenBao status")
target = target_ready(operator, contract)
runner = contract["runner"]
authority = contract["authority"]
operator.kubectl(["get", "ns", runner["namespace"], "-o", "name"], label="verify runner namespace")
operator.kubectl(
["-n", authority["eso_namespace"], "get", "sa", authority["eso_service_account"], "-o", "name"],
label="verify ESO service account",
)
presence = resource_presence(operator, contract)
broker_ready = True
broker_reason = None
try:
current_broker_receipt(contract, api_base, now=current)
except ContractError as exc:
broker_ready = False
broker_reason = str(exc)
return {
"interface": "railiance.custody-preflight",
"version": 1,
"engagement_id": contract["engagement_id"],
"projection_contract_digest": contract_digest(contract),
"time_state": time_state(contract, current),
"broker_ready": broker_ready,
"broker_gate": "pass" if broker_ready else "blocked",
"broker_reason": broker_reason,
"target_image_matches": True,
"target_ready": target,
"openbao_initialized": True,
"openbao_sealed": False,
**presence,
"secret_values_observed": False,
}
def write_exact_values(operator: Operator, contract: dict[str, Any], values: dict[str, str]) -> None:
with tempfile.TemporaryDirectory(prefix="railiance-custody-values-") as directory:
root = Path(directory)
os.chmod(root, 0o700)
files: list[Path] = []
try:
for handle, path in token_paths(contract).items():
document = root / handle
document.write_text(values[handle], encoding="utf-8")
os.chmod(document, 0o600)
files.append(document)
operator.bao(
[
"kv",
"put",
path,
f"token=@{document}",
f"engagement_id={contract['engagement_id']}",
f"expires_at={contract['window']['expires_at']}",
],
label=f"write exact {handle} KV path",
)
finally:
for document in files:
secure_unlink(document)
def configure_projection(operator: Operator, contract: dict[str, Any], lease_id: str) -> None:
names = resource_names(contract)
authority = contract["authority"]
runner = contract["runner"]
operator.bao(
["policy", "write", names["policy"], "-"],
label="write exact ESO policy",
input_text=build_policy(contract),
)
operator.bao(
[
"write",
f"auth/kubernetes/role/{names['role']}",
f"bound_service_account_names={authority['eso_service_account']}",
f"bound_service_account_namespaces={authority['eso_namespace']}",
f"policies={names['policy']}",
"ttl=5m",
"max_ttl=15m",
],
label="write exact ESO Kubernetes role",
)
operator.kubectl(
["apply", "-f", "-"],
label="apply exact projection manifest",
input_text=build_manifest(contract, lease_id),
)
operator.kubectl(
["wait", "--for=condition=Ready", f"clustersecretstore/{names['store']}", "--timeout=90s"],
label="wait for exact store",
)
operator.kubectl(
[
"-n",
runner["namespace"],
"wait",
"--for=condition=Ready",
f"externalsecret/{names['external_secret']}",
"--timeout=90s",
],
label="wait for exact ExternalSecret",
)
keys = operator.kubectl(
[
"-n",
runner["namespace"],
"get",
"secret",
runner["secret_name"],
"-o",
'go-template={{range $k, $_ := .data}}{{$k}}{{"\\n"}}{{end}}',
],
label="verify projected key names",
).stdout.splitlines()
expected = sorted(identity["handle"] for identity in contract["identities"])
if sorted(keys) != expected:
raise ProcedureError(f"projected Secret has unexpected key names: {sorted(keys)}")
def force_sender_sync_and_restart(operator: Operator, contract: dict[str, Any]) -> None:
target = contract["target"]
namespace = target["namespace"]
external_secret = target["sender_external_secret"]
before = operator.kubectl(
["-n", namespace, "get", "secret", external_secret, "-o", "jsonpath={.metadata.resourceVersion}"],
label="read sender Secret resource version",
).stdout.strip()
operator.kubectl(
[
"-n",
namespace,
"annotate",
"externalsecret",
external_secret,
f"force-sync={int(time.time())}",
"--overwrite",
],
label="force sender registry sync",
)
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
current = operator.kubectl(
["-n", namespace, "get", "secret", external_secret, "-o", "jsonpath={.metadata.resourceVersion}"],
label="poll sender Secret resource version",
).stdout.strip()
if current and current != before:
break
time.sleep(2)
else:
raise ProcedureError("sender Secret did not refresh within 120 seconds")
operator.kubectl(
["-n", namespace, "rollout", "restart", f"deploy/{target['deployment']}"],
label="restart sender registry reader",
)
operator.kubectl(
["-n", namespace, "rollout", "status", f"deploy/{target['deployment']}", "--timeout=120s"],
label="wait for target rollout",
)
def delete_projection(operator: Operator, contract: dict[str, Any]) -> None:
names = resource_names(contract)
runner = contract["runner"]
operator.kubectl(
["-n", runner["namespace"], "delete", "externalsecret", names["external_secret"], "--ignore-not-found"],
label="delete exact ExternalSecret",
)
operator.kubectl(
["-n", runner["namespace"], "delete", "secret", runner["secret_name"], "--ignore-not-found"],
label="delete exact mounted Secret",
)
operator.kubectl(
["delete", "clustersecretstore", names["store"], "--ignore-not-found"],
label="delete exact store",
)
for handle, path in token_paths(contract).items():
if operator.bao_exists(
["kv", "metadata", "get", "-format=json", path],
label=f"check exact {handle} metadata before delete",
):
operator.bao(["kv", "metadata", "delete", path], label=f"delete exact {handle} KV path")
if operator.bao_exists(["read", f"auth/kubernetes/role/{names['role']}"] , label="check exact role"):
operator.bao(["delete", f"auth/kubernetes/role/{names['role']}"] , label="delete exact role")
if operator.bao_exists(["policy", "read", names["policy"]], label="check exact policy"):
operator.bao(["policy", "delete", names["policy"]], label="delete exact policy")
def verify_cleanup_absent(operator: Operator, contract: dict[str, Any]) -> None:
operator.bao(["status", "-format=json"], label="verify OpenBao after cleanup")
operator.kubectl(["get", "ns", contract["runner"]["namespace"], "-o", "name"], label="verify Kubernetes after cleanup")
presence = resource_presence(operator, contract)
if any(presence.values()):
raise ProcedureError(f"engagement resources remain after cleanup: {presence}")
def cleanup_scope(operator: Operator, contract: dict[str, Any]) -> dict[str, Any]:
registry = operator.registry()
updated, removed = remove_temporary_identities(registry, contract)
if removed:
operator.write_registry(updated)
force_sender_sync_and_restart(operator, contract)
delete_projection(operator, contract)
verify_cleanup_absent(operator, contract)
target_ready(operator, contract)
return {
"removed_identities": removed,
"removed_resources": sorted(
[
*token_paths(contract).values(),
*resource_names(contract).values(),
]
),
}
def transactional(
steps: list[Callable[[], None]], rollback: Callable[[], Any]
) -> None:
try:
for step in steps:
step()
except Exception as original:
try:
rollback()
except Exception as cleanup_error:
raise ProcedureError(
"projection failed and exact cleanup could not be proven"
) from cleanup_error
raise original
def resource_uids(
operator: Operator, contract: dict[str, Any], *, require_all: bool = True
) -> dict[str, str]:
names = resource_names(contract)
runner = contract["runner"]
requests = {
"store": [
"get", "clustersecretstore", names["store"], "--ignore-not-found",
"-o", "jsonpath={.metadata.uid}",
],
"external_secret": [
"-n", runner["namespace"], "get", "externalsecret", names["external_secret"],
"--ignore-not-found",
"-o", "jsonpath={.metadata.uid}",
],
"mounted_secret": [
"-n", runner["namespace"], "get", "secret", runner["secret_name"],
"--ignore-not-found",
"-o", "jsonpath={.metadata.uid}",
],
}
result = {
key: operator.kubectl(args, label=f"read exact {key} UID").stdout.strip()
for key, args in requests.items()
}
if require_all and any(not value for value in result.values()):
raise ProcedureError("projection resource UID evidence is incomplete")
return {key: value for key, value in result.items() if value}
def verify_receipt_resource_scope(
operator: Operator, contract: dict[str, Any], receipt: dict[str, Any]
) -> dict[str, str]:
present = resource_uids(operator, contract, require_all=False)
expected = receipt["resources"]["uids"]
mismatched = {
key: value
for key, value in present.items()
if expected.get(key) != value
}
if mismatched:
raise ProcedureError(
"live projection resource UID differs from the cleanup receipt; refusing deletion"
)
return present
def project(
operator: Operator,
contract: dict[str, Any],
*,
api_base: str = DEFAULT_API_BASE,
now: datetime | None = None,
lease_id: str | None = None,
) -> dict[str, Any]:
current = (now or utc_now()).astimezone(UTC)
if time_state(contract, current) != "projection-window-open":
raise ProcedureError("projection is outside the approved projection window")
# This is intentionally before token generation and every mutation.
broker_receipt = current_broker_receipt(contract, api_base, now=current)
preflight = live_preflight(operator, contract, api_base=api_base, now=current)
if not preflight["broker_ready"]:
raise ProcedureError("consumer broker is not ready")
if any(
preflight[key]
for key in (
"temporary_identities_present",
"exact_token_paths_present",
"projection_resources_present",
)
):
raise ProcedureError("cleanup is required before a new projection")
active_lease = lease_id or f"custody:{secrets.token_hex(16)}"
values = {identity["handle"]: secrets.token_urlsafe(48) for identity in contract["identities"]}
if len(set(values.values())) != len(values):
raise ProcedureError("credential generator returned duplicate values")
try:
transactional(
[
lambda: write_exact_values(operator, contract, values),
lambda: operator.write_registry(
add_temporary_identities(operator.registry(), contract, values)
),
lambda: configure_projection(operator, contract, active_lease),
lambda: force_sender_sync_and_restart(operator, contract),
],
lambda: cleanup_scope(operator, contract),
)
finally:
values.clear()
projected_at = rfc3339(utc_now())
base = {
"interface": PROJECTION_INTERFACE,
"version": 1,
"workplan_id": WORKPLAN_ID,
"state": "projected",
"lease_id": active_lease,
"engagement_id": contract["engagement_id"],
"target": {
"id": contract["target"]["id"],
"revision": contract["target"]["revision"],
"image_digest": contract["target"]["image_digest"],
},
"projection_contract_digest": contract_digest(contract),
"broker_receipt_digest": digest(broker_receipt),
"projected_at": projected_at,
"expires_at": contract["window"]["expires_at"],
"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"],
),
"resources": {
"names": resource_names(contract),
"uids": resource_uids(operator, contract),
},
"cleanup_authority": "railiance-platform",
"secret_values_observed": False,
}
receipt = {**base, "receipt_id": f"sha256:{digest(base)}"}
validate_projection_receipt(receipt, contract)
return receipt
def projection_status(
operator: Operator, contract: dict[str, Any], receipt: dict[str, Any]
) -> dict[str, Any]:
validate_projection_receipt(receipt, contract)
presence = resource_presence(operator, contract)
counts = [
len(presence["temporary_identities_present"]),
len(presence["exact_token_paths_present"]),
len(presence["projection_resources_present"]),
]
state = "active" if counts == [2, 2, 3] else "absent" if counts == [0, 0, 0] else "partial"
uid_match = False
present_uids = resource_uids(operator, contract, require_all=False)
uid_match = bool(present_uids) and all(
receipt["resources"]["uids"].get(key) == value
for key, value in present_uids.items()
)
if present_uids and not uid_match:
state = "mismatched"
return {
"interface": "railiance.custody-projection-status",
"version": 1,
"engagement_id": contract["engagement_id"],
"lease_id": receipt["lease_id"],
"state": state,
"resource_uids_match": uid_match,
**presence,
"target_ready": target_ready(operator, contract),
"secret_values_observed": False,
}
def cleanup(
operator: Operator, contract: dict[str, Any], projection: dict[str, Any]
) -> dict[str, Any]:
validate_projection_receipt(projection, contract)
verify_receipt_resource_scope(operator, contract, projection)
result = cleanup_scope(operator, contract)
receipt = {
"interface": CLEANUP_INTERFACE,
"version": 1,
"workplan_id": WORKPLAN_ID,
"state": "cleaned",
"lease_id": projection["lease_id"],
"engagement_id": contract["engagement_id"],
"projection_receipt_id": projection["receipt_id"],
"cleaned_at": rfc3339(utc_now()),
"removed_resources": result["removed_resources"],
"target_ready": True,
"secret_values_observed": False,
}
validate_cleanup_receipt(receipt, projection, contract)
return receipt
def assert_confirm(value: str | None, expected: str) -> None:
if value != expected:
raise ProcedureError(f"live command requires --confirm {expected}")
def assert_expired_cleanup(contract: dict[str, Any], now: datetime) -> None:
expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
if now.astimezone(UTC) <= expires:
raise ProcedureError("cleanup-expired refuses before receipt expiry")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"command",
choices=["validate", "render", "preflight", "project", "status", "cleanup", "cleanup-expired"],
)
parser.add_argument("--contract", type=Path, required=True)
parser.add_argument("--receipt", type=Path)
parser.add_argument("--receipt-out", type=Path)
parser.add_argument("--token-file", type=Path, default=Path.home() / ".local/openbao/platform-admin.token")
parser.add_argument("--state-hub", default=DEFAULT_API_BASE)
parser.add_argument("--confirm")
args = parser.parse_args()
try:
contract = validate_projection_contract(load_json(args.contract))
if args.command == "validate":
result = {
"valid": True,
"projection_contract_digest": contract_digest(contract),
"resource_names": resource_names(contract),
"secret_values_observed": False,
}
elif args.command == "render":
result = {
"policy": build_policy(contract),
"manifest": json.loads(build_manifest(contract, "custody:" + "0" * 32)),
"secret_values_observed": False,
}
else:
projection = None
if args.command in {"status", "cleanup", "cleanup-expired"}:
if not args.receipt:
raise ProcedureError(f"{args.command} requires --receipt")
projection = validate_projection_receipt(load_json(args.receipt), contract)
if args.command == "cleanup-expired":
assert_expired_cleanup(contract, utc_now())
if args.command == "project":
# Reject an unready consumer before reading platform-admin
# custody material. project() repeats this immutable gate.
current_broker_receipt(contract, args.state_hub)
operator = Operator(contract, args.token_file)
if args.command == "preflight":
result = live_preflight(operator, contract, api_base=args.state_hub)
elif args.command == "project":
assert_confirm(args.confirm, f"{contract['engagement_id']}:attended")
result = project(operator, contract, api_base=args.state_hub)
else:
assert projection is not None
if args.command == "status":
result = projection_status(operator, contract, projection)
else:
if args.command == "cleanup-expired":
assert_confirm(args.confirm, f"{contract['engagement_id']}:expired")
else:
assert_confirm(args.confirm, f"{contract['engagement_id']}:cleanup")
result = cleanup(operator, contract, projection)
if args.receipt_out:
write_receipt(args.receipt_out, result)
print(json.dumps(result, indent=2, sort_keys=True))
return 0
except (ContractError, OSError, ProcedureError, RemoteExecutionError) as exc:
print(f"custody projection failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

444
scripts/custody_contract.py Executable file
View file

@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""Canonical, value-safe contracts for ephemeral credential projection."""
from __future__ import annotations
import hashlib
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable
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"
BROKER_SUBJECT_PREFIX = "WP0025-BROKER-READINESS"
REPO_ROOT = Path(__file__).resolve().parents[1]
INTERFACE_ARTIFACTS = (
"schemas/custody-projection-contract.schema.json",
"schemas/custody-broker-readiness.schema.json",
"schemas/custody-projection-receipt.schema.json",
"schemas/custody-cleanup-receipt.schema.json",
)
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")
class ContractError(RuntimeError):
pass
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 parse_time(value: Any, field: str) -> datetime:
if not isinstance(value, str):
raise ContractError(f"{field} must be an RFC3339 timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ContractError(f"{field} must be an RFC3339 timestamp") from exc
if parsed.tzinfo is None:
raise ContractError(f"{field} must include a timezone")
return parsed.astimezone(UTC)
def load_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ContractError(f"JSON document is unavailable or invalid: {path}") from exc
if not isinstance(value, dict):
raise ContractError("JSON document must be an object")
return value
def _require_string(value: dict[str, Any], key: str) -> str:
observed = value.get(key)
if not isinstance(observed, str) or not observed:
raise ContractError(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 ContractError(f"{key} must be a SHA-256 digest")
return observed.removeprefix("sha256:")
def resource_suffix(engagement_id: str) -> str:
return hashlib.sha256(engagement_id.encode("utf-8")).hexdigest()[:12]
def interface_artifacts(root: Path = REPO_ROOT) -> dict[str, str]:
return {path: file_digest(root / path) for path in INTERFACE_ARTIFACTS}
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 validate_projection_contract(value: dict[str, Any]) -> dict[str, Any]:
if value.get("interface") != CONTRACT_INTERFACE or value.get("version") != 1:
raise ContractError("unsupported projection contract interface/version")
if value.get("workplan_id") != WORKPLAN_ID:
raise ContractError("projection contract has the wrong workplan")
engagement_id = _require_string(value, "engagement_id")
if not re.fullmatch(r"[A-Z0-9][A-Z0-9._-]{7,127}", engagement_id):
raise ContractError("engagement_id has an invalid shape")
if value.get("status") != "approved":
raise ContractError("engagement must be approved")
target = value.get("target")
runner = value.get("runner")
window = value.get("window")
authority = value.get("authority")
identities = value.get("identities")
if not all(isinstance(item, dict) for item in (target, runner, window, authority)):
raise ContractError("target, runner, window and authority must be objects")
if not isinstance(identities, list) or len(identities) != 2:
raise ContractError("exactly two identity handles are required")
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 ContractError("target namespace and deployment must be DNS labels")
revision = _require_string(target, "revision")
if not GIT_REVISION.fullmatch(revision):
raise ContractError("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 ContractError("target.image_digest must be a SHA-256 image digest")
_require_sha(target, "contract_sha256")
_require_sha(runner, "manifest_sha256")
_require_sha(value, "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 ContractError("runner namespace and secret_name must be DNS labels")
if not mount_root.startswith("/") or ".." in Path(mount_root).parts:
raise ContractError("runner.mount_root must be an absolute safe path")
starts = parse_time(window.get("starts_at"), "window.starts_at")
cutoff = parse_time(window.get("projection_cutoff"), "window.projection_cutoff")
expires = parse_time(window.get("expires_at"), "window.expires_at")
if not starts < cutoff < expires:
raise ContractError("window must satisfy starts_at < projection_cutoff < expires_at")
if (expires - starts).total_seconds() > 900:
raise ContractError("ephemeral custody window may not exceed 900 seconds")
remote = _require_string(authority, "remote")
if any(character.isspace() for character in remote):
raise ContractError("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 ContractError("authority.kv_prefix must bind engagement_id and target id")
if "/" in kv_mount or not registry_path or "/" in registry_field:
raise ContractError("invalid KV mount or registry field")
expected_handles = {"token-a", "token-b"}
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 ContractError("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 ContractError("temporary identities require may_read=true and may_write=true")
if mount_path != f"{mount_root}/{handle}":
raise ContractError("identity mount_path must be mount_root plus its handle")
handles.add(handle)
roles.add(role)
senders.add(sender)
tenants.add(tenant)
if handles != expected_handles or roles != {"attacker", "owner"}:
raise ContractError("identities must provide token-a/token-b and attacker/owner roles")
if len(senders) != 2 or len(tenants) != 2:
raise ContractError("sender names and tenants must be distinct")
names = resource_names(value)
if any(not DNS_LABEL.fullmatch(name) for name in names.values()):
raise ContractError("derived resource name is not a DNS label")
return value
def contract_digest(contract: dict[str, Any]) -> str:
validate_projection_contract(contract)
return digest(contract)
def assert_value_safe(value: Any) -> None:
forbidden_keys = {
"token",
"tokens",
"password",
"secret_value",
"secret_values",
"bearer",
"private_key",
}
if isinstance(value, dict):
for key, item in value.items():
if str(key).lower() in forbidden_keys:
raise ContractError(f"value-bearing field is forbidden: {key}")
assert_value_safe(item)
elif isinstance(value, list):
for item in value:
assert_value_safe(item)
def broker_subject(receipt: dict[str, Any]) -> str:
return "/".join(
(
BROKER_SUBJECT_PREFIX,
"v1",
receipt["engagement_id"],
receipt["decision"],
receipt["projection_contract_digest"],
)
)
def validate_broker_receipt(
receipt: dict[str, Any], contract: dict[str, Any], *, now: datetime | None = None
) -> dict[str, Any]:
validate_projection_contract(contract)
assert_value_safe(receipt)
if receipt.get("interface") != BROKER_INTERFACE or receipt.get("version") != 1:
raise ContractError("unsupported broker receipt interface/version")
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("owner") != BROKER_OWNER:
raise ContractError("broker receipt has the wrong workplan or owner")
if receipt.get("decision") != "approve":
raise ContractError("broker receipt is not approved")
if receipt.get("engagement_id") != contract["engagement_id"]:
raise ContractError("broker receipt engagement does not match")
if receipt.get("target_id") != contract["target"]["id"]:
raise ContractError("broker receipt target does not match")
if receipt.get("projection_contract_digest") != contract_digest(contract):
raise ContractError("broker receipt projection contract is stale")
if receipt.get("projection_receipt_interface") != PROJECTION_INTERFACE:
raise ContractError("broker receipt cannot consume projection receipt v1")
if receipt.get("interface_artifacts") != interface_artifacts():
raise ContractError("broker receipt interface artifacts are stale")
if receipt.get("required_roles") != ["attacker", "owner"]:
raise ContractError("broker receipt has the wrong identity roles")
expected_mounts = sorted(identity["mount_path"] for identity in contract["identities"])
if receipt.get("mount_paths") != expected_mounts:
raise ContractError("broker receipt mount paths do not match")
adapter = receipt.get("adapter")
if not isinstance(adapter, dict):
raise ContractError("broker receipt 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 ContractError("broker adapter repo/path is invalid")
if not GIT_REVISION.fullmatch(str(adapter.get("revision", ""))):
raise ContractError("broker adapter revision must be a full Git revision")
if not SHA256.fullmatch(str(adapter.get("sha256", ""))):
raise ContractError("broker adapter requires a SHA-256 digest")
if adapter.get("tests_passed") is not True or receipt.get("cleanup_request_supported") is not True:
raise ContractError("broker adapter tests and cleanup support are required")
if receipt.get("secret_values_observed") is not False:
raise ContractError("broker receipt is not value-safe")
created = parse_time(receipt.get("created_at"), "broker.created_at")
current = (now or datetime.now(UTC)).astimezone(UTC)
starts = parse_time(contract["window"]["starts_at"], "window.starts_at")
expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
if created > current or created > expires or created < starts.replace(hour=0, minute=0, second=0, microsecond=0):
raise ContractError("broker receipt timestamp is outside the engagement day")
return receipt
def parse_broker_message(
message: dict[str, Any], contract: dict[str, Any], *, now: datetime | None = None
) -> dict[str, Any] | None:
if message.get("from_agent") != BROKER_OWNER or message.get("to_agent") != "railiance-platform":
return None
body = message.get("body")
if not isinstance(body, str):
return None
try:
receipt = json.loads(body)
except json.JSONDecodeError:
return None
if not isinstance(receipt, dict):
return None
try:
if message.get("subject") != broker_subject(receipt):
return None
return validate_broker_receipt(receipt, contract, now=now)
except (ContractError, KeyError, TypeError):
return None
def http_json(
method: str,
url: str,
payload: dict[str, Any] | None = None,
*,
opener: Callable[..., Any] = urllib.request.urlopen,
) -> Any:
data = canonical_json(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(
url,
data=data,
method=method,
headers={"Content-Type": "application/json"} if data else {},
)
try:
with opener(request, timeout=10) as response:
return json.loads(response.read().decode("utf-8"))
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
raise ContractError(f"State Hub request failed: {method} {url}") from exc
def current_broker_receipt(
contract: dict[str, Any], api_base: str, *, now: datetime | None = None
) -> dict[str, Any]:
query = urllib.parse.urlencode(
{"to_agent": "railiance-platform", "unread_only": "false"}
)
messages = http_json("GET", f"{api_base.rstrip('/')}/messages/?{query}")
if not isinstance(messages, list):
raise ContractError("State Hub returned an invalid message list")
receipts = [
parsed
for message in messages
if isinstance(message, dict)
and (parsed := parse_broker_message(message, contract, now=now)) is not None
]
if not receipts:
raise ContractError("no current Whitehat broker-readiness receipt exists")
return max(receipts, key=lambda item: item["created_at"])
def validate_projection_receipt(
receipt: dict[str, Any], contract: dict[str, Any]
) -> dict[str, Any]:
validate_projection_contract(contract)
assert_value_safe(receipt)
if receipt.get("interface") != PROJECTION_INTERFACE or receipt.get("version") != 1:
raise ContractError("unsupported projection receipt interface/version")
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("state") != "projected":
raise ContractError("projection receipt has the wrong workplan or state")
if receipt.get("engagement_id") != contract["engagement_id"]:
raise ContractError("projection receipt engagement does not match")
if receipt.get("projection_contract_digest") != contract_digest(contract):
raise ContractError("projection receipt contract is stale")
expected_target = {
"id": contract["target"]["id"],
"revision": contract["target"]["revision"],
"image_digest": contract["target"]["image_digest"],
}
if receipt.get("target") != expected_target:
raise ContractError("projection receipt target does not match")
lease_id = receipt.get("lease_id")
if not isinstance(lease_id, str) or not re.fullmatch(r"custody:[0-9a-f]{32}", lease_id):
raise ContractError("projection receipt lease_id is invalid")
if receipt.get("secret_values_observed") is not False:
raise ContractError("projection receipt is not value-safe")
projected = parse_time(receipt.get("projected_at"), "projection.projected_at")
expires = parse_time(receipt.get("expires_at"), "projection.expires_at")
contract_expires = parse_time(contract["window"]["expires_at"], "window.expires_at")
if expires != contract_expires or projected >= expires:
raise ContractError("projection receipt has invalid projection/expiry times")
identities = receipt.get("identities")
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 ContractError("projection receipt identity handles do not match")
resources = receipt.get("resources")
if not isinstance(resources, dict) or resources.get("names") != resource_names(contract):
raise ContractError("projection receipt resources do not match")
uids = resources.get("uids")
if not isinstance(uids, dict) or set(uids) != {"store", "external_secret", "mounted_secret"}:
raise ContractError("projection receipt requires exact Kubernetes UIDs")
if any(not isinstance(uid, str) or not uid for uid in uids.values()):
raise ContractError("projection receipt contains an invalid Kubernetes UID")
base = dict(receipt)
observed_id = base.pop("receipt_id", None)
if observed_id != f"sha256:{digest(base)}":
raise ContractError("projection receipt id does not match its canonical content")
return receipt
def validate_cleanup_receipt(
receipt: dict[str, Any], projection: dict[str, Any], contract: dict[str, Any]
) -> dict[str, Any]:
validate_projection_receipt(projection, contract)
assert_value_safe(receipt)
if receipt.get("interface") != CLEANUP_INTERFACE or receipt.get("version") != 1:
raise ContractError("unsupported cleanup receipt interface/version")
if receipt.get("workplan_id") != WORKPLAN_ID or receipt.get("state") != "cleaned":
raise ContractError("cleanup receipt has the wrong workplan or state")
if receipt.get("engagement_id") != contract["engagement_id"]:
raise ContractError("cleanup receipt engagement does not match")
if receipt.get("lease_id") != projection["lease_id"]:
raise ContractError("cleanup receipt lease does not match")
if receipt.get("projection_receipt_id") != projection["receipt_id"]:
raise ContractError("cleanup receipt projection id does not match")
parse_time(receipt.get("cleaned_at"), "cleanup.cleaned_at")
if receipt.get("target_ready") is not True or receipt.get("secret_values_observed") is not False:
raise ContractError("cleanup receipt does not prove safe target recovery")
return receipt

43
scripts/remote_exec.py Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Small, value-safe SSH argv transport shared by attended procedures."""
from __future__ import annotations
import shlex
import subprocess
from typing import Callable
class RemoteExecutionError(RuntimeError):
pass
def remote_command(argv: list[str]) -> str:
if not argv or any(not isinstance(part, str) or "\0" in part for part in argv):
raise RemoteExecutionError("remote argv must contain non-NUL strings")
return shlex.join(argv)
def run_remote(
host: str,
argv: list[str],
*,
label: str,
input_text: str | None = None,
allow_missing: bool = False,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> subprocess.CompletedProcess[str]:
if not host or any(character.isspace() for character in host):
raise RemoteExecutionError("remote host must be a single SSH destination")
completed = runner(
["ssh", "-o", "BatchMode=yes", host, remote_command(argv)],
text=True,
capture_output=True,
input=input_text,
check=False,
)
if completed.returncode and not (allow_missing and completed.returncode == 1):
# Remote output may contain provider or application material. Keep the
# durable error value-safe and let an attended operator inspect locally.
raise RemoteExecutionError(f"{label} failed (exit {completed.returncode})")
return completed

View file

@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Direct Whitehat owner interface for WP-0025 broker readiness."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from custody_contract import ( # noqa: E402
BROKER_INTERFACE,
BROKER_OWNER,
PROJECTION_INTERFACE,
WORKPLAN_ID,
ContractError,
broker_subject,
canonical_json,
contract_digest,
current_broker_receipt,
file_digest,
http_json,
interface_artifacts,
load_json,
validate_broker_receipt,
validate_projection_contract,
)
DEFAULT_ADAPTER = Path("src/whitehat_security/platform_custody.py")
DEFAULT_TEST = Path("tests/test_platform_custody_adapter.py")
class ReadinessError(RuntimeError):
pass
def validate_reviewer(value: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@/+\-]{1,127}", value):
raise ReadinessError("reviewer must be a stable 2-128 character identifier")
return value
def validate_note(value: str) -> str:
note = value.strip()
if not note or len(note) > 2000:
raise ReadinessError("request-changes requires a note of 1-2000 characters")
forbidden = ("BEGIN PRIVATE KEY", "BEGIN OPENSSH PRIVATE KEY", "AGE-SECRET-KEY-1", "hvs.")
if any(marker in note for marker in forbidden):
raise ReadinessError("note appears to contain credential material")
return note
def safe_child(root: Path, relative: Path) -> Path:
if relative.is_absolute():
raise ReadinessError("adapter and test paths must be repository-relative")
target = (root / relative).resolve()
try:
target.relative_to(root.resolve())
except ValueError as exc:
raise ReadinessError("consumer artifact escapes repository root") from exc
return target
def verify_adapter(
consumer_root: Path,
*,
adapter_path: Path = DEFAULT_ADAPTER,
test_path: Path = DEFAULT_TEST,
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
) -> dict[str, Any]:
root = consumer_root.resolve()
adapter = safe_child(root, adapter_path)
test = safe_child(root, test_path)
if not adapter.is_file() or not test.is_file():
return {
"passed": False,
"adapter_present": adapter.is_file(),
"test_present": test.is_file(),
"secret_values_observed": False,
}
revision_result = runner(
["git", "rev-parse", "HEAD"],
cwd=root,
text=True,
capture_output=True,
check=False,
)
revision = revision_result.stdout.strip() if revision_result.returncode == 0 else ""
tests = runner(
[sys.executable, "-m", "pytest", "-q", str(test_path)],
cwd=root,
text=True,
capture_output=True,
check=False,
)
passed = bool(re.fullmatch(r"[0-9a-f]{40}", revision)) and tests.returncode == 0
return {
"passed": passed,
"adapter_present": True,
"test_present": True,
"adapter": {
"repo": "whitehat-security",
"revision": revision,
"path": str(adapter_path),
"sha256": file_digest(adapter),
"tests_passed": tests.returncode == 0,
},
"test_exit_code": tests.returncode,
"secret_values_observed": False,
}
def receipt_base(contract: dict[str, Any], reviewer: str, decision: str) -> dict[str, Any]:
return {
"interface": BROKER_INTERFACE,
"version": 1,
"workplan_id": WORKPLAN_ID,
"owner": BROKER_OWNER,
"reviewer": validate_reviewer(reviewer),
"decision": decision,
"created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"engagement_id": contract["engagement_id"],
"target_id": contract["target"]["id"],
"projection_contract_digest": contract_digest(contract),
"projection_receipt_interface": PROJECTION_INTERFACE,
"interface_artifacts": interface_artifacts(),
"required_roles": ["attacker", "owner"],
"mount_paths": sorted(item["mount_path"] for item in contract["identities"]),
"secret_values_observed": False,
}
def build_approval(
contract: dict[str, Any], reviewer: str, verification: dict[str, Any]
) -> dict[str, Any]:
if verification.get("passed") is not True or verification.get("secret_values_observed") is not False:
raise ReadinessError("consumer adapter verification did not pass")
receipt = {
**receipt_base(contract, reviewer, "approve"),
"adapter": verification["adapter"],
"cleanup_request_supported": True,
}
validate_broker_receipt(receipt, contract)
return receipt
def build_change_request(contract: dict[str, Any], reviewer: str, note: str) -> dict[str, Any]:
return {
**receipt_base(contract, reviewer, "request-changes"),
"note": validate_note(note),
}
def post_receipt(receipt: dict[str, Any], api_base: str) -> dict[str, Any]:
response = http_json(
"POST",
f"{api_base.rstrip('/')}/messages/",
{
"from_agent": BROKER_OWNER,
"to_agent": "railiance-platform",
"subject": broker_subject(receipt),
"body": canonical_json(receipt),
},
)
if not isinstance(response, dict) or not response.get("id"):
raise ReadinessError("State Hub returned an invalid message receipt")
return response
def show(contract: dict[str, Any]) -> dict[str, Any]:
return {
"interface": BROKER_INTERFACE,
"version": 1,
"owner": BROKER_OWNER,
"engagement_id": contract["engagement_id"],
"target_id": contract["target"]["id"],
"projection_contract_digest": contract_digest(contract),
"projection_receipt_interface": PROJECTION_INTERFACE,
"required_roles": ["attacker", "owner"],
"mount_paths": sorted(item["mount_path"] for item in contract["identities"]),
"adapter_path": str(DEFAULT_ADAPTER),
"test_path": str(DEFAULT_TEST),
"approve_command": (
"python3 scripts/wp0025-broker-readiness.py approve --contract <contract.json> "
"--consumer-root <whitehat-security> --reviewer <stable-id>"
),
"live_mutation_authorized": False,
"secret_values_observed": False,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["show", "verify", "approve", "request-changes", "status"])
parser.add_argument("--contract", type=Path, required=True)
parser.add_argument("--consumer-root", type=Path)
parser.add_argument("--reviewer")
parser.add_argument("--note")
parser.add_argument("--state-hub", default="http://127.0.0.1:8000")
args = parser.parse_args()
try:
contract = validate_projection_contract(load_json(args.contract))
if args.command == "show":
result = show(contract)
elif args.command == "status":
try:
receipt = current_broker_receipt(contract, args.state_hub)
except ContractError as exc:
result = {
"ready": False,
"engagement_id": contract["engagement_id"],
"reason": str(exc),
"secret_values_observed": False,
}
else:
result = {
"ready": True,
"engagement_id": contract["engagement_id"],
"receipt": receipt,
"secret_values_observed": False,
}
elif args.command == "verify":
if not args.consumer_root:
raise ReadinessError("verify requires --consumer-root")
result = verify_adapter(args.consumer_root)
elif args.command == "approve":
if not args.consumer_root or not args.reviewer:
raise ReadinessError("approve requires --consumer-root and --reviewer")
verification = verify_adapter(args.consumer_root)
receipt = build_approval(contract, args.reviewer, verification)
posted = post_receipt(receipt, args.state_hub)
result = {"submitted": True, "message_id": posted["id"], "receipt": receipt}
else:
if not args.reviewer or not args.note:
raise ReadinessError("request-changes requires --reviewer and --note")
receipt = build_change_request(contract, args.reviewer, args.note)
posted = post_receipt(receipt, args.state_hub)
result = {"submitted": True, "message_id": posted["id"], "receipt": receipt}
print(json.dumps(result, indent=2, sort_keys=True))
return 0
except (ContractError, OSError, ReadinessError) as exc:
print(f"broker readiness failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,248 @@
from __future__ import annotations
import copy
import importlib.util
import json
import sys
import unittest
from datetime import UTC, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import custody_contract as module
NOW = datetime(2026, 8, 22, 22, 1, tzinfo=UTC)
def projection_contract() -> dict:
return {
"interface": module.CONTRACT_INTERFACE,
"version": 1,
"workplan_id": module.WORKPLAN_ID,
"engagement_id": "WH-ENG-20260822-AUDIT-E2-03",
"status": "approved",
"engagement_contract_sha256": "1" * 64,
"target": {
"id": "audit-core",
"namespace": "audit-core",
"deployment": "audit-core",
"container": "audit-core",
"sender_external_secret": "audit-core-senders",
"revision": "2" * 40,
"image_digest": "sha256:" + "3" * 64,
"contract_sha256": "4" * 64,
},
"runner": {
"namespace": "whitehat",
"service_account": "whitehat-runner",
"secret_name": "whitehat-e2-audit-credentials",
"mount_root": "/var/run/secrets/whitehat",
"manifest_sha256": "5" * 64,
},
"window": {
"starts_at": "2026-08-22T22:00:00Z",
"projection_cutoff": "2026-08-22T22:03:00Z",
"expires_at": "2026-08-22T22:15:00Z",
},
"authority": {
"remote": "railiance01",
"registry_path": "platform/workloads/audit-core/senders",
"registry_field": "senders.json",
"kv_mount": "platform",
"kv_prefix": "engagements/WH-ENG-20260822-AUDIT-E2-03/audit-core",
"eso_service_account": "external-secrets",
"eso_namespace": "external-secrets",
},
"identities": [
{
"handle": "token-a",
"role": "attacker",
"sender_name": "whitehat-e2-a-20260822-03",
"tenant": "tenant:trial:whitehat-a-20260822-03",
"mount_path": "/var/run/secrets/whitehat/token-a",
"may_read": True,
"may_write": True,
},
{
"handle": "token-b",
"role": "owner",
"sender_name": "whitehat-e2-b-20260822-03",
"tenant": "tenant:trial:whitehat-b-20260822-03",
"mount_path": "/var/run/secrets/whitehat/token-b",
"may_read": True,
"may_write": True,
},
],
}
def broker_receipt(contract: dict) -> dict:
return {
"interface": module.BROKER_INTERFACE,
"version": 1,
"workplan_id": module.WORKPLAN_ID,
"owner": module.BROKER_OWNER,
"reviewer": "whitehat-owner",
"decision": "approve",
"created_at": "2026-08-22T21:50:00Z",
"engagement_id": contract["engagement_id"],
"target_id": contract["target"]["id"],
"projection_contract_digest": module.contract_digest(contract),
"projection_receipt_interface": module.PROJECTION_INTERFACE,
"interface_artifacts": module.interface_artifacts(),
"required_roles": ["attacker", "owner"],
"mount_paths": sorted(item["mount_path"] for item in contract["identities"]),
"adapter": {
"repo": "whitehat-security",
"revision": "6" * 40,
"path": "src/whitehat_security/platform_custody.py",
"sha256": "7" * 64,
"tests_passed": True,
},
"cleanup_request_supported": True,
"secret_values_observed": False,
}
def projection_receipt(contract: dict) -> dict:
base = {
"interface": module.PROJECTION_INTERFACE,
"version": 1,
"workplan_id": module.WORKPLAN_ID,
"state": "projected",
"lease_id": "custody:" + "8" * 32,
"engagement_id": contract["engagement_id"],
"target": {
"id": contract["target"]["id"],
"revision": contract["target"]["revision"],
"image_digest": contract["target"]["image_digest"],
},
"projection_contract_digest": module.contract_digest(contract),
"broker_receipt_digest": module.digest(broker_receipt(contract)),
"projected_at": "2026-08-22T22:02:00Z",
"expires_at": contract["window"]["expires_at"],
"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"],
),
"resources": {
"names": module.resource_names(contract),
"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:" + module.digest(base)}
class CustodyContractTests(unittest.TestCase):
def test_contract_validates_and_derives_hash_only_resource_names(self) -> None:
contract = projection_contract()
self.assertIs(contract, module.validate_projection_contract(contract))
names = module.resource_names(contract)
self.assertTrue(all(name.startswith("custody-") for key, name in names.items() if key != "external_secret"))
self.assertNotIn("E2-03", json.dumps(names))
self.assertEqual(64, len(module.contract_digest(contract)))
def test_contract_rejects_stale_prefix_duplicate_tenant_and_long_window(self) -> None:
contract = projection_contract()
contract["authority"]["kv_prefix"] = "engagements/WH-ENG-20260822-AUDIT-E2-02/audit-core"
with self.assertRaises(module.ContractError):
module.validate_projection_contract(contract)
contract = projection_contract()
contract["identities"][1]["tenant"] = contract["identities"][0]["tenant"]
with self.assertRaises(module.ContractError):
module.validate_projection_contract(contract)
contract = projection_contract()
contract["window"]["expires_at"] = "2026-08-22T22:16:00Z"
with self.assertRaises(module.ContractError):
module.validate_projection_contract(contract)
def test_broker_receipt_is_bound_to_contract_and_current_artifacts(self) -> None:
contract = projection_contract()
receipt = broker_receipt(contract)
self.assertIs(receipt, module.validate_broker_receipt(receipt, contract, now=NOW))
stale = copy.deepcopy(receipt)
stale["projection_contract_digest"] = "0" * 64
with self.assertRaises(module.ContractError):
module.validate_broker_receipt(stale, contract, now=NOW)
stale = copy.deepcopy(receipt)
stale["interface_artifacts"][next(iter(stale["interface_artifacts"]))] = "0" * 64
with self.assertRaises(module.ContractError):
module.validate_broker_receipt(stale, contract, now=NOW)
def test_broker_message_requires_whitehat_origin_and_exact_subject(self) -> None:
contract = projection_contract()
receipt = broker_receipt(contract)
message = {
"from_agent": module.BROKER_OWNER,
"to_agent": "railiance-platform",
"subject": module.broker_subject(receipt),
"body": module.canonical_json(receipt),
}
self.assertEqual(receipt, module.parse_broker_message(message, contract, now=NOW))
message["from_agent"] = "coding-agent"
self.assertIsNone(module.parse_broker_message(message, contract, now=NOW))
malformed = {
"from_agent": module.BROKER_OWNER,
"to_agent": "railiance-platform",
"subject": module.BROKER_SUBJECT_PREFIX + "/malformed",
"body": "{}",
}
self.assertIsNone(module.parse_broker_message(malformed, contract, now=NOW))
def test_projection_receipt_canonical_id_and_scope_are_enforced(self) -> None:
contract = projection_contract()
receipt = projection_receipt(contract)
self.assertIs(receipt, module.validate_projection_receipt(receipt, contract))
changed = copy.deepcopy(receipt)
changed["resources"]["uids"]["mounted_secret"] = "replacement"
with self.assertRaises(module.ContractError):
module.validate_projection_receipt(changed, contract)
value_bearing = copy.deepcopy(receipt)
value_bearing["token"] = "never-allowed"
with self.assertRaises(module.ContractError):
module.validate_projection_receipt(value_bearing, contract)
def test_cleanup_receipt_is_bound_to_projection_lease(self) -> None:
contract = projection_contract()
projection = projection_receipt(contract)
cleanup = {
"interface": module.CLEANUP_INTERFACE,
"version": 1,
"workplan_id": module.WORKPLAN_ID,
"state": "cleaned",
"lease_id": projection["lease_id"],
"engagement_id": contract["engagement_id"],
"projection_receipt_id": projection["receipt_id"],
"cleaned_at": "2026-08-22T22:14:00Z",
"removed_resources": ["custody-resource"],
"target_ready": True,
"secret_values_observed": False,
}
self.assertIs(cleanup, module.validate_cleanup_receipt(cleanup, projection, contract))
cleanup["lease_id"] = "custody:" + "0" * 32
with self.assertRaises(module.ContractError):
module.validate_cleanup_receipt(cleanup, projection, contract)
def test_all_published_schemas_are_valid_json(self) -> None:
for relative in module.INTERFACE_ARTIFACTS:
value = json.loads((ROOT / relative).read_text(encoding="utf-8"))
self.assertEqual("https://json-schema.org/draft/2020-12/schema", value["$schema"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,186 @@
from __future__ import annotations
import importlib.util
import json
import os
import subprocess
import stat
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from datetime import UTC, datetime
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
TESTS = ROOT / "tests"
for path in (SCRIPTS, TESTS):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from test_custody_contract import broker_receipt, projection_contract, projection_receipt
SPEC = importlib.util.spec_from_file_location(
"custody_projection", SCRIPTS / "custody-projection.py"
)
assert SPEC and SPEC.loader
module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(module)
NOW = datetime(2026, 8, 22, 22, 1, tzinfo=UTC)
class CustodyProjectionTests(unittest.TestCase):
def test_policy_and_manifest_are_derived_from_one_contract(self) -> None:
contract = projection_contract()
policy = module.build_policy(contract)
manifest = json.loads(module.build_manifest(contract, "custody:" + "9" * 32))
rendered = json.dumps(manifest, sort_keys=True)
self.assertEqual(4, policy.count('capabilities = ["read"]'))
self.assertIn(contract["engagement_id"], policy)
self.assertIn(contract["engagement_id"], rendered)
self.assertNotIn("WH-ENG-20260822-AUDIT-E2-01", policy + rendered)
self.assertEqual(
["token-a", "token-b"],
[item["secretKey"] for item in manifest["items"][1]["spec"]["data"]],
)
def test_identity_overlay_is_exact_and_removable(self) -> None:
contract = projection_contract()
existing = [{"name": "production", "tokens": ["not-inspected"]}]
updated = module.add_temporary_identities(
existing, contract, {"token-a": "a", "token-b": "b"}
)
self.assertEqual(3, len(updated))
temporary = updated[1:]
self.assertTrue(all(item["expires_at"] == contract["window"]["expires_at"] for item in temporary))
restored, removed = module.remove_temporary_identities(updated, contract)
self.assertEqual(existing, restored)
self.assertEqual(sorted(module.identity_names(contract)), removed)
def test_broker_gate_runs_before_operator_or_token_generation(self) -> None:
contract = projection_contract()
operator = object()
with mock.patch.object(
module,
"current_broker_receipt",
side_effect=module.ContractError("not connected"),
), mock.patch.object(module.secrets, "token_urlsafe") as token_urlsafe:
with self.assertRaises(module.ContractError):
module.project(operator, contract, now=NOW)
token_urlsafe.assert_not_called()
def test_cli_broker_gate_runs_before_platform_authority_is_opened(self) -> None:
contract = projection_contract()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "contract.json"
path.write_text(json.dumps(contract), encoding="utf-8")
argv = [
"custody-projection.py",
"project",
"--contract",
str(path),
"--confirm",
f"{contract['engagement_id']}:attended",
]
with mock.patch.object(sys, "argv", argv), mock.patch.object(
module,
"current_broker_receipt",
side_effect=module.ContractError("broker missing"),
), mock.patch.object(module, "Operator") as operator, redirect_stderr(StringIO()):
self.assertEqual(1, module.main())
operator.assert_not_called()
def test_validate_and_render_cli_require_no_authority(self) -> None:
contract = projection_contract()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "contract.json"
path.write_text(json.dumps(contract), encoding="utf-8")
for command in ("validate", "render"):
with mock.patch.object(
sys, "argv", ["custody-projection.py", command, "--contract", str(path)]
), mock.patch.object(module, "Operator") as operator, redirect_stdout(StringIO()):
self.assertEqual(0, module.main())
operator.assert_not_called()
def test_transaction_rolls_back_after_every_mutation_boundary(self) -> None:
for failed_index in range(4):
events: list[str] = []
steps = []
for index in range(4):
def step(index=index) -> None:
events.append(f"step-{index}")
if index == failed_index:
raise module.ProcedureError(f"fail-{index}")
steps.append(step)
with self.assertRaisesRegex(module.ProcedureError, f"fail-{failed_index}"):
module.transactional(steps, lambda: events.append("rollback"))
self.assertEqual("rollback", events[-1])
self.assertEqual(1, events.count("rollback"))
self.assertNotIn(f"step-{failed_index + 1}", events)
def test_cleanup_failure_is_reported_without_original_output(self) -> None:
def fail() -> None:
raise module.ProcedureError("projection-stage")
def cleanup_fail() -> None:
raise module.ProcedureError("provider-secret-output")
with self.assertRaisesRegex(
module.ProcedureError, "cleanup could not be proven"
) as caught:
module.transactional([fail], cleanup_fail)
self.assertNotIn("provider-secret-output", str(caught.exception))
def test_receipt_write_is_mode_0600_and_canonical_receipt_validates(self) -> None:
contract = projection_contract()
receipt = projection_receipt(contract)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "projection.json"
module.write_receipt(path, receipt)
self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode))
self.assertEqual(receipt, json.loads(path.read_text(encoding="utf-8")))
self.assertIs(receipt, module.validate_projection_receipt(receipt, contract))
def test_status_cannot_infer_absence_after_connectivity_failure(self) -> None:
class Disconnected:
def registry(self):
raise module.ProcedureError("registry connectivity failed")
with self.assertRaisesRegex(module.ProcedureError, "connectivity"):
module.projection_status(
Disconnected(), projection_contract(), projection_receipt(projection_contract())
)
def test_cleanup_refuses_recreated_resource_uid(self) -> None:
contract = projection_contract()
receipt = projection_receipt(contract)
class RecreatedResource:
def kubectl(self, args, *, label, **kwargs):
if "clustersecretstore" in args:
value = "replacement-store-uid"
else:
value = ""
return subprocess.CompletedProcess(args, 0, stdout=value, stderr="")
with self.assertRaisesRegex(module.ProcedureError, "refusing deletion"):
module.verify_receipt_resource_scope(RecreatedResource(), contract, receipt)
def test_expiry_state_is_fail_closed(self) -> None:
contract = projection_contract()
self.assertEqual("before-window", module.time_state(contract, datetime(2026, 8, 22, 21, 59, tzinfo=UTC)))
self.assertEqual("projection-window-open", module.time_state(contract, NOW))
self.assertEqual("projection-cutoff-passed", module.time_state(contract, datetime(2026, 8, 22, 22, 4, tzinfo=UTC)))
with self.assertRaisesRegex(module.ProcedureError, "before receipt expiry"):
module.assert_expired_cleanup(contract, NOW)
module.assert_expired_cleanup(contract, datetime(2026, 8, 22, 22, 16, tzinfo=UTC))
if __name__ == "__main__":
unittest.main()

49
tests/test_remote_exec.py Normal file
View file

@ -0,0 +1,49 @@
from __future__ import annotations
import subprocess
import unittest
from unittest import mock
import sys
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import remote_exec as module
class RemoteExecTests(unittest.TestCase):
def test_shell_hostile_argv_is_one_quoted_remote_command(self) -> None:
template = 'go-template={{range $k, $_ := .data}}{{$k}}{{"\\n"}}{{end}}'
completed = subprocess.CompletedProcess([], 0, stdout="token-a\ntoken-b\n", stderr="")
runner = mock.Mock(return_value=completed)
module.run_remote(
"railiance01",
["kubectl", "get", "secret", "example", "-o", template, "literal;$(false)", "a'b"],
label="hostile argv",
runner=runner,
)
command = runner.call_args.args[0]
self.assertEqual(["ssh", "-o", "BatchMode=yes", "railiance01"], command[:4])
self.assertIn("'go-template={{range $k, $_ := .data}}", command[4])
self.assertIn("'literal;$(false)'", command[4])
self.assertIn("'a'\"'\"'b'", command[4])
def test_invalid_host_and_nul_fail_closed(self) -> None:
with self.assertRaises(module.RemoteExecutionError):
module.run_remote("bad host", ["true"], label="invalid")
with self.assertRaises(module.RemoteExecutionError):
module.remote_command(["bad\0value"])
def test_error_does_not_include_remote_output(self) -> None:
runner = mock.Mock(
return_value=subprocess.CompletedProcess([], 9, stdout="sensitive", stderr="also-sensitive")
)
with self.assertRaisesRegex(module.RemoteExecutionError, "exit 9") as caught:
module.run_remote("railiance01", ["false"], label="safe failure", runner=runner)
self.assertNotIn("sensitive", str(caught.exception))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,89 @@
from __future__ import annotations
import importlib.util
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
TESTS = ROOT / "tests"
for path in (SCRIPTS, TESTS):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from test_custody_contract import projection_contract
SPEC = importlib.util.spec_from_file_location(
"wp0025_broker_readiness", SCRIPTS / "wp0025-broker-readiness.py"
)
assert SPEC and SPEC.loader
module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(module)
class BrokerReadinessTests(unittest.TestCase):
def test_show_is_direct_and_authorizes_no_live_mutation(self) -> None:
result = module.show(projection_contract())
self.assertEqual("whitehat-security", result["owner"])
self.assertIn("approve", result["approve_command"])
self.assertFalse(result["live_mutation_authorized"])
self.assertFalse(result["secret_values_observed"])
def test_adapter_verification_pins_revision_file_and_focused_test(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
adapter = root / module.DEFAULT_ADAPTER
test = root / module.DEFAULT_TEST
adapter.parent.mkdir(parents=True)
test.parent.mkdir(parents=True)
adapter.write_text("class PlatformCustodyBroker: pass\n", encoding="utf-8")
test.write_text("def test_adapter(): assert True\n", encoding="utf-8")
def runner(command, **kwargs):
if command[:2] == ["git", "rev-parse"]:
return subprocess.CompletedProcess(command, 0, stdout="a" * 40 + "\n", stderr="")
return subprocess.CompletedProcess(command, 0, stdout="1 passed\n", stderr="")
result = module.verify_adapter(root, runner=runner)
self.assertTrue(result["passed"])
self.assertEqual("a" * 40, result["adapter"]["revision"])
self.assertEqual(64, len(result["adapter"]["sha256"]))
self.assertTrue(result["adapter"]["tests_passed"])
def test_missing_adapter_fails_without_running_commands(self) -> None:
with tempfile.TemporaryDirectory() as directory:
result = module.verify_adapter(
Path(directory),
runner=lambda *args, **kwargs: self.fail("runner must not execute"),
)
self.assertFalse(result["passed"])
self.assertFalse(result["secret_values_observed"])
def test_approval_round_trip_and_change_request(self) -> None:
contract = projection_contract()
verification = {
"passed": True,
"adapter": {
"repo": "whitehat-security",
"revision": "a" * 40,
"path": str(module.DEFAULT_ADAPTER),
"sha256": "b" * 64,
"tests_passed": True,
},
"secret_values_observed": False,
}
receipt = module.build_approval(contract, "whitehat-owner", verification)
self.assertEqual("approve", receipt["decision"])
self.assertTrue(receipt["cleanup_request_supported"])
change = module.build_change_request(contract, "whitehat-owner", "Bind cleanup acknowledgement.")
self.assertEqual("request-changes", change["decision"])
with self.assertRaises(module.ReadinessError):
module.build_change_request(contract, "whitehat-owner", "")
if __name__ == "__main__":
unittest.main()

View file

@ -13,6 +13,7 @@ related:
- AUDIT-WP-0008
- WH-ENG-20260822-AUDIT-E2-01
- WH-ENG-20260822-AUDIT-E2-02
- RAILIANCE-WP-0025
origin: routed
origin_ref: "State Hub messages 10f80080-4c83-42ba-8590-f23c582d9f05 and a93fa88f-a9c5-4539-93ae-0c8f8490f53d"
state_hub_workstream_id: "88c4ef7f-0af8-580e-90dc-a2bae2675a4d"
@ -47,7 +48,7 @@ lease revocation, firewall change, or host reboot.
```task
id: RAILIANCE-WP-0024-T01
status: wait
status: done
priority: high
state_hub_task_id: "c4da371a-f35d-5377-9f6a-d34e274c98d0"
```
@ -93,8 +94,26 @@ projection gate is 19:15Z19:18Z and cleanup must finish by 19:30Z. Platform
tests (114), Whitehat tests (53), both Kubernetes server-side dry-runs, and a
live value-safe preflight passed. At preflight there were no temporary token
paths, projection resources, runner pod, or temporary sender identities, and
no secret value was observed. This task remains `wait` until an attended
operator invokes projection inside the gate and completes the cleanup receipt.
no secret value was observed. At that pre-run point the task remained `wait`
until an attended operator invoked projection and completed the cleanup receipt.
**Terminal result (2026-08-22):** projection succeeded at 19:17:54Z with
exactly two bounded identities and the two declared mounted keys. Whitehat
failed closed before traffic because its live broker could not consume the
value-safe platform receipt; the runner sent zero target packets and was
deleted at 19:21:07Z. Exact cleanup completed at 19:21:39Z and independent
preflight proved no temporary identity, KV path, ESO resource, mounted Secret,
or runner remained; audit-core was `1/1` Ready and no secret value was
observed. The first attempt also proved automatic rollback when SSH shell
expansion corrupted a Go-template argument; commit `d239ed3` fixes that defect.
T01 is done because the mount-only projection, application expiry, reload,
abort, and cleanup lane is implemented and live-proven. The missing consumable
broker contract is not carried as prose in this completed task: versioned
receipts, the pre-projection broker gate, generic lifecycle, and future expired
cleanup interface are implemented under `RAILIANCE-WP-0025`. The dated `-01`
and `-02` procedures are terminal evidence/cleanup references and must not be
copied for another engagement.
## T02 — Define the runtime database lease recovery exercise
@ -260,7 +279,7 @@ each task.
## Acceptance
- [ ] E2 cannot proceed without enforced sender expiry and exact-path projection.
- [x] E2 cannot proceed without enforced sender expiry and exact-path projection.
- [ ] Database recovery is restart-free and evidenced without credential values.
- [ ] Reboot recovery has an ordered, owner-signed checklist and abort path.
- [ ] No live action is implied by completing this design workplan.
- [x] No live action is implied by completing this design workplan.

View file

@ -0,0 +1,158 @@
---
id: RAILIANCE-WP-0025
type: workplan
title: "Version ephemeral custody projection and broker handoff"
domain: financials
repo: railiance-platform
status: finished
owner: codex
topic_slug: railiance
created: "2026-08-22"
updated: "2026-08-22"
related:
- RAILIANCE-WP-0024
- WHITEHAT-WP-0001
- AUDIT-WP-0008
origin: run-review
origin_ref: "WH-ENG-20260822-AUDIT-E2-02 terminal clean admission abort"
---
# RAILIANCE-WP-0025 — Version ephemeral custody projection and broker handoff
## Goal
Replace engagement-specific credential scripts and prose handoffs with one
versioned, value-safe, fail-closed lifecycle. A consumer must prove it can
consume the projection receipt before platform custody mints anything. The
platform then exposes direct `preflight`, `project`, `status`, `cleanup`, and
expired-lease cleanup interfaces without transferring bearer values or
collapsing owner approvals.
The implementation is repository code and contracts only. It does not
authorize or schedule a live credential projection, workload, probe, or
unattended cleanup controller.
## T01 — Define projection, broker-readiness, and cleanup receipts
```task
id: RAILIANCE-WP-0025-T01
status: done
priority: high
```
Publish versioned JSON Schemas and canonical validation for the projection
contract, broker-readiness receipt, active projection receipt, and cleanup
receipt. Bind every receipt to the exact engagement, target revision and image,
runner and target contract digests, mounted identity handles, expiry, opaque
lease id, and cleanup authority. Values and value-derived fingerprints are
forbidden.
Done when malformed, stale, mismatched, expired, and value-bearing documents
fail closed and consumers can implement without interpreting prose.
Implemented in four JSON Schemas plus `scripts/custody_contract.py`. Canonical
projection ids cover all receipt content; validators bind target, contract,
roles, mount paths, resource names and UIDs, and reject value-bearing fields.
Broker approvals also pin the current hashes of all four interface schemas.
## T02 — Implement a data-driven custody lifecycle
```task
id: RAILIANCE-WP-0025-T02
status: done
priority: high
```
Replace copied per-engagement code and static manifests with one lifecycle that
derives exact OpenBao and ESO resources from a validated engagement contract.
Expose value-safe `preflight`, `project`, `status`, and idempotent `cleanup`
commands. Preserve the existing two-identity sender overlay, application-level
expiry, exact-key mount, readiness reload, and exact-scope rollback behavior.
Done when a new engagement requires data only, not copied Python, HCL, or YAML,
and an old engagement identifier cannot leak into generated resources.
Implemented by `scripts/custody-projection.py` and the intentionally invalid
placeholder template `docs/custody-projection-contract.example.json`. Policy,
role, store, KV paths, ExternalSecret data mappings, sender overlays, expiry,
and cleanup scope derive from one validated contract. Resource names use an
engagement-id digest rather than a copied run suffix.
## T03 — Gate projection on a direct broker capability receipt
```task
id: RAILIANCE-WP-0025-T03
status: done
priority: high
```
Provide a direct owner interface for `whitehat-security` to inspect, verify,
approve, or request changes to the contract. Approval must identify the tested
adapter revision and artifact digest and be posted directly to State Hub. The
platform lifecycle must find a current matching approval before generating a
credential.
Done when an unconnected, stale, wrong-engagement, or wrong-contract broker is
rejected before the first OpenBao or sender-registry mutation.
Implemented by `scripts/wp0025-broker-readiness.py`. Its closed verification
surface pins Whitehat's adapter and focused test paths, computes their commit
and digest, runs only the named focused test, and posts the canonical receipt
directly as `whitehat-security`. `project` checks State Hub before reading the
platform-admin token file, generating bearer values, or touching live state.
No adapter approval is fabricated by this implementation; a future Whitehat
adapter must pass the interface itself.
## T04 — Harden execution and transactional cleanup
```task
id: RAILIANCE-WP-0025-T04
status: done
priority: high
```
Centralize remote argv quoting, test shell-hostile arguments, and inject
failures at each mutation boundary. Prove that partial projection invokes exact
cleanup and that status never infers absence after a connectivity failure.
Retire the copied `-01`/`-02` procedure from future-run instructions.
Done when the SSH expansion defect from the `-02` run and every modeled partial
failure have regression coverage.
Implemented by `scripts/remote_exec.py` and focused tests covering `$`, shell
substitution, semicolons, quotes, NUL refusal, and value-safe failure messages.
The lifecycle uses a transaction whose rollback is exercised after every
modeled mutation boundary. Connectivity failure cannot be interpreted as
absence, and cleanup refuses a recreated same-name Kubernetes resource whose
UID differs from the receipt.
## T05 — Add an approval-ready expired-lease cleanup entry point
```task
id: RAILIANCE-WP-0025-T05
status: done
priority: medium
```
Add a fail-closed `cleanup-expired` interface that accepts only a valid
projection receipt after its expiry, removes only the receipt-bound resources,
and emits the normal cleanup receipt. Do not install a scheduler or controller;
deployment of unattended cleanup remains a separate owner decision.
Done when an approved future reaper can call a deterministic command without
receiving projected bearer values or reconstructing cleanup scope from chat.
Implemented as `cleanup-expired`. It refuses before expiry, requires the exact
canonical projection receipt and engagement confirmation, verifies live UIDs,
uses the ordinary exact cleanup transaction, and emits the versioned cleanup
receipt. No scheduler, controller, runtime identity, or unattended authority
was deployed; those remain a separate owner decision.
## Acceptance
- [x] No projection can begin without a current consumer broker receipt.
- [x] A new engagement is configuration, not copied executable code.
- [x] Projection and cleanup receipts are versioned, canonical, and value-safe.
- [x] Remote command arguments survive shell-hostile templates unchanged.
- [x] Every modeled partial mutation cleans only its receipt-bound scope.
- [x] Expired cleanup is executable but no unattended scheduler is deployed.