diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 4134dca..5da992f 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/custody-projection-contract.example.json b/docs/custody-projection-contract.example.json new file mode 100644 index 0000000..1274fed --- /dev/null +++ b/docs/custody-projection-contract.example.json @@ -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 + } + ] +} diff --git a/docs/ephemeral-custody-lifecycle.md b/docs/ephemeral-custody-lifecycle.md new file mode 100644 index 0000000..15bb73f --- /dev/null +++ b/docs/ephemeral-custody-lifecycle.md @@ -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 ':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 ':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 ':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. diff --git a/schemas/custody-broker-readiness.schema.json b/schemas/custody-broker-readiness.schema.json new file mode 100644 index 0000000..5a0ec41 --- /dev/null +++ b/schemas/custody-broker-readiness.schema.json @@ -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"]} + } + ] +} diff --git a/schemas/custody-cleanup-receipt.schema.json b/schemas/custody-cleanup-receipt.schema.json new file mode 100644 index 0000000..9d7333a --- /dev/null +++ b/schemas/custody-cleanup-receipt.schema.json @@ -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} + } +} diff --git a/schemas/custody-projection-contract.schema.json b/schemas/custody-projection-contract.schema.json new file mode 100644 index 0000000..7963136 --- /dev/null +++ b/schemas/custody-projection-contract.schema.json @@ -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])?$"} + } +} diff --git a/schemas/custody-projection-receipt.schema.json b/schemas/custody-projection-receipt.schema.json new file mode 100644 index 0000000..d2efafb --- /dev/null +++ b/schemas/custody-projection-receipt.schema.json @@ -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} + } +} diff --git a/scripts/custody-projection.py b/scripts/custody-projection.py new file mode 100755 index 0000000..a6f120a --- /dev/null +++ b/scripts/custody-projection.py @@ -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()) diff --git a/scripts/custody_contract.py b/scripts/custody_contract.py new file mode 100755 index 0000000..c0f3ac2 --- /dev/null +++ b/scripts/custody_contract.py @@ -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 diff --git a/scripts/remote_exec.py b/scripts/remote_exec.py new file mode 100755 index 0000000..3753976 --- /dev/null +++ b/scripts/remote_exec.py @@ -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 diff --git a/scripts/wp0025-broker-readiness.py b/scripts/wp0025-broker-readiness.py new file mode 100755 index 0000000..184b6ad --- /dev/null +++ b/scripts/wp0025-broker-readiness.py @@ -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 " + "--consumer-root --reviewer " + ), + "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()) diff --git a/tests/test_custody_contract.py b/tests/test_custody_contract.py new file mode 100644 index 0000000..fb1589d --- /dev/null +++ b/tests/test_custody_contract.py @@ -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() diff --git a/tests/test_custody_projection.py b/tests/test_custody_projection.py new file mode 100644 index 0000000..6ea223b --- /dev/null +++ b/tests/test_custody_projection.py @@ -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() diff --git a/tests/test_remote_exec.py b/tests/test_remote_exec.py new file mode 100644 index 0000000..fd63bf3 --- /dev/null +++ b/tests/test_remote_exec.py @@ -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() diff --git a/tests/test_wp0025_broker_readiness.py b/tests/test_wp0025_broker_readiness.py new file mode 100644 index 0000000..60b9e35 --- /dev/null +++ b/tests/test_wp0025_broker_readiness.py @@ -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() diff --git a/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md b/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md index 494fb9f..a727052 100644 --- a/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md +++ b/workplans/RAILIANCE-WP-0024-audit-core-custody-and-recovery-coordination.md @@ -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:15Z–19: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. diff --git a/workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md b/workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md new file mode 100644 index 0000000..3a40acb --- /dev/null +++ b/workplans/RAILIANCE-WP-0025-versioned-ephemeral-custody-lifecycle.md @@ -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.