Admit live E2 from a value-safe custody receipt
WH-ENG-20260822-AUDIT-E2-02 projected and then aborted: admit-plane had no receipt adapter, so the runner sent zero packets. Consume custody receipts as handles only, keep unconnected admission fail-closed, and retire -02. Assistant: grok Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb
This commit is contained in:
parent
b6c1806680
commit
45548e44a2
14 changed files with 221 additions and 42 deletions
|
|
@ -13,7 +13,7 @@ from .e3 import CADENCE, PROBES, e3_calibration
|
|||
from .engagement import AuthorizationError, Engagement
|
||||
from .fixtures import FixtureService, probe_suite
|
||||
from .model import RunReport, utc_now
|
||||
from .plane import KillSwitch, admit, default_broker, retired_ids
|
||||
from .plane import KillSwitch, ReceiptBroker, admit, default_broker, retired_ids
|
||||
from .reporting import queue_risk_nexus, risk_nexus_message
|
||||
from .targets import load_catalog, load_registration
|
||||
|
||||
|
|
@ -90,6 +90,7 @@ def main(argv: list[str] | None = None) -> None:
|
|||
admit_plane = commands.add_parser("admit-plane")
|
||||
admit_plane.add_argument("engagement")
|
||||
admit_plane.add_argument("registration")
|
||||
admit_plane.add_argument("--receipt", help="value-safe custody projection receipt")
|
||||
commands.add_parser("kill-switch")
|
||||
deliver = commands.add_parser("deliver")
|
||||
deliver.add_argument("report")
|
||||
|
|
@ -138,8 +139,11 @@ def main(argv: list[str] | None = None) -> None:
|
|||
try:
|
||||
record = Engagement.load(args.engagement)
|
||||
registration = load_registration(args.registration)
|
||||
broker = (
|
||||
ReceiptBroker.load(args.receipt) if args.receipt else default_broker(record)
|
||||
)
|
||||
lease = admit(engagement=record, registration=registration,
|
||||
broker=default_broker(record), kill_switch=KillSwitch(),
|
||||
broker=broker, kill_switch=KillSwitch(),
|
||||
retired=retired_ids())
|
||||
except (AuthorizationError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"not authorized: {error}", file=sys.stderr)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ class Engagement:
|
|||
raise AuthorizationError(
|
||||
"engagement window elapsed with no live run; identifier must not be reused"
|
||||
)
|
||||
if self.raw.get("status") == "aborted":
|
||||
raise AuthorizationError(
|
||||
"engagement aborted without target evidence; identifier must not be reused"
|
||||
)
|
||||
if self.raw.get("status") == "proposed":
|
||||
raise AuthorizationError(
|
||||
"engagement is proposed; operator approval and owner acknowledgement are pending"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ TERMINAL_IDS = frozenset({
|
|||
"WH-ENG-20260821-AUDIT-E2",
|
||||
"WH-ENG-20260821-TENANT-E2",
|
||||
"WH-ENG-20260822-AUDIT-E2-01",
|
||||
"WH-ENG-20260822-AUDIT-E2-02",
|
||||
})
|
||||
APPROVAL_CLASSES = {
|
||||
"fixture-e2": frozenset({"e2-differential"}),
|
||||
|
|
@ -42,7 +43,7 @@ def retired_ids(engagements_dir: str | Path | None = None) -> set[str]:
|
|||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if data.get("status") in {"cancelled", "expired"} and data.get("engagement_id"):
|
||||
if data.get("status") in {"cancelled", "expired", "aborted"} and data.get("engagement_id"):
|
||||
retired.add(data["engagement_id"])
|
||||
return retired
|
||||
|
||||
|
|
@ -113,6 +114,82 @@ class CredentialBroker(Protocol):
|
|||
...
|
||||
|
||||
|
||||
FORBIDDEN_RECEIPT_KEYS = {
|
||||
"token", "token_a", "token_b", "password", "secret", "secret_value",
|
||||
"bearer", "credential", "value", "senders.json",
|
||||
}
|
||||
|
||||
|
||||
def _receipt_contains_secret_material(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
lowered = str(key).lower().replace("-", "_")
|
||||
if lowered in FORBIDDEN_RECEIPT_KEYS:
|
||||
return True
|
||||
if _receipt_contains_secret_material(child):
|
||||
return True
|
||||
return False
|
||||
if isinstance(value, list):
|
||||
return any(_receipt_contains_secret_material(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
class ReceiptBroker:
|
||||
"""Consume a value-safe custody receipt. Never reads or stores secret bytes."""
|
||||
|
||||
def __init__(self, receipt: dict[str, Any]) -> None:
|
||||
if _receipt_contains_secret_material(receipt):
|
||||
raise AuthorizationError("projection receipt contains secret material")
|
||||
if receipt.get("secret_values_observed") is not False:
|
||||
raise AuthorizationError("projection receipt did not prove secret values were unobserved")
|
||||
self.receipt = receipt
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path) -> "ReceiptBroker":
|
||||
return cls(json.loads(Path(path).read_text(encoding="utf-8")))
|
||||
|
||||
def project(self, engagement: Engagement, registration: dict[str, Any]
|
||||
) -> tuple[IdentityHandle, ...]:
|
||||
if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]:
|
||||
raise AuthorizationError("projection receipt engagement_id does not match")
|
||||
if not self.receipt.get("target_ready"):
|
||||
raise AuthorizationError("projection receipt does not show a ready target")
|
||||
expires_at = self.receipt.get("expires_at")
|
||||
if not expires_at:
|
||||
raise AuthorizationError("projection receipt missing expires_at")
|
||||
expiry = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
|
||||
if expiry.tzinfo is None:
|
||||
raise AuthorizationError("projection receipt expiry must include a timezone")
|
||||
if datetime.now(UTC) > expiry.astimezone(UTC):
|
||||
raise AuthorizationError("projection receipt has expired")
|
||||
keys = list(self.receipt.get("mounted_keys") or [])
|
||||
count = int(registration["identities"]["count"])
|
||||
if count == 2 and keys != ["token-a", "token-b"]:
|
||||
raise AuthorizationError("E2 receipt must mount exactly token-a and token-b")
|
||||
names = list(self.receipt.get("identities") or [])
|
||||
if len(names) != count:
|
||||
raise AuthorizationError("projection receipt identity count does not match registration")
|
||||
secret = str(self.receipt.get("mounted_secret") or "")
|
||||
if not secret.startswith("whitehat/"):
|
||||
raise AuthorizationError("projection receipt must mount in namespace whitehat")
|
||||
lease_id = f"{engagement.raw['engagement_id']}:{self.receipt.get('projected_at', '')}"
|
||||
if count == 2:
|
||||
return (
|
||||
IdentityHandle("owner", "/var/run/secrets/whitehat/token-b", lease_id, expires_at),
|
||||
IdentityHandle("attacker", "/var/run/secrets/whitehat/token-a", lease_id, expires_at),
|
||||
)
|
||||
if count == 1:
|
||||
return (
|
||||
IdentityHandle("runtime", "/var/run/secrets/whitehat/runtime", lease_id, expires_at),
|
||||
)
|
||||
return ()
|
||||
|
||||
def revoke(self, lease_id: str) -> None:
|
||||
raise AuthorizationError(
|
||||
"receipt broker does not hold credentials; custody must revoke the projection"
|
||||
)
|
||||
|
||||
|
||||
class UnconnectedCustodyBroker:
|
||||
"""Fail-closed live broker. It never requests or returns credential values."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue