secrets-engine/tools/exercise_approval_identity.py
tegwick 42b48aa54f
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Bind credential exec to exact owner inputs and approval digest
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 09:29:38 +02:00

453 lines
27 KiB
Python

#!/usr/bin/env python3
"""Disposable KeyCape image -> actual Secrets Engine client -> Approval Engine.
Synthetic keys/credentials only. No OpenBao, cluster mutation, or model call.
The actual Flex Auth evaluator runs locally with its published policy/registry.
This is component conformance evidence, not production authorization evidence.
"""
from __future__ import annotations
import argparse
import base64
import copy
import hashlib
import http.server
import io
import ipaddress
import json
import os
from pathlib import Path
import secrets
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
from contextlib import contextmanager, redirect_stdout
from datetime import datetime, timedelta, timezone
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from unittest.mock import patch
from wsgiref.simple_server import WSGIRequestHandler, make_server
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
import yaml
KEYCAPE_IMAGE = "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611"
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / "src"), str(ROOT)]
def run(args):
result = subprocess.run(args, capture_output=True, timeout=45)
if result.returncode:
raise RuntimeError("contained command failed")
return result.stdout.decode().strip()
def commit(path):
return run(["git", "-C", str(path), "rev-parse", "HEAD"])
def private_write(path, value):
with path.open("x") as out:
os.chmod(path, 0o600)
out.write(value)
def exercise(args):
sys.path.insert(0, str(args.approval_engine_source))
from approval_engine.api import App
from approval_engine.auth import JWTAuthenticator
from approval_engine.store import Engine
from secrets_engine import cli
from secrets_engine.approval_auth import approval_token, credential_urlopen
from secrets_engine.approval_consume import ConsumeBinding, _expected_request, consume_approval
from secrets_engine.authorization import request_digest, validate_decision_envelope
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import BackendError, DecisionError
from tests.authorization_stub import AuthorizationStub
from tests.test_catalog import VALID
receipt = {
"schema_version": 1, "target": "disposable local processes; synthetic credentials",
"started_at": datetime.now(timezone.utc).isoformat(),
"keycape_image": KEYCAPE_IMAGE,
"approval_engine_commit": commit(args.approval_engine_source),
"flex_auth_commit": commit(args.flex_auth_source),
"keycape_contract_commit": commit(args.keycape_source),
"consumer_source_sha256": {
name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest()
for name in ("approval_auth.py", "approval_consume.py", "approval_claim.py", "authorization.py", "config.py", "service_auth.py", "cli.py", "catalog.py", "exec_owner.py", "exec_delivery.py", "routing.py")
},
"limitations": ["standalone Flex Auth source, not deployed pin", "local Approval Engine source, not deployed image",
"no live custody or client-side read grant", "no OpenBao effect or model execution"],
"checks": {},
}
with tempfile.TemporaryDirectory(prefix="approval-identity-private-") as temporary:
root = Path(temporary)
source = args.flex_auth_source.resolve()
run(["go", "build", "-C", str(source), "-o", str(root / "flex-auth"), "./cmd/flex-auth"])
producer_files = source / "examples/secrets-engine"
receipt["flex_auth_binary_sha256"] = hashlib.sha256((root / "flex-auth").read_bytes()).hexdigest()
receipt["producer_input_sha256"] = {
name: hashlib.sha256((producer_files / name).read_bytes()).hexdigest()
for name in ("policy_package.md", "registry_snapshot.json")
}
class ProducerPDP(AuthorizationStub):
"""Loopback transport only; every decision comes from the Go evaluator."""
last_decision = None
def decision(self, request):
path = root / "check-request.json"
path.write_text(json.dumps(request))
path.chmod(0o600)
self.last_decision = json.loads(run([str(root / "flex-auth"), "check",
"-policy", str(producer_files / "policy_package.md"),
"-registry", str(producer_files / "registry_snapshot.json"), "-request", str(path)]))
return self.last_decision
def bind_request(self, request):
# At issue, record the evaluator's claim-free digest, even if
# the destructive action denies until approval is supplied.
return self.decision(request)["binding"]["request_digest"]
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_write(root / "key.pem", key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode())
now = datetime.now(timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "local approval identity exercise")])
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), False)
.add_extension(x509.BasicConstraints(ca=True, path_length=None), True)
.sign(key, hashes.SHA256()))
(root / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
keycape_port = sock.getsockname()[1]
class Proxy(http.server.BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def proxy(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if self.command == "POST" else None
req = Request(f"http://127.0.0.1:{keycape_port}" + self.path, data=body,
headers={k: self.headers[k] for k in ("Content-Type", "Authorization") if k in self.headers})
try:
response = urlopen(req, timeout=10)
except HTTPError as error:
response = error
with response:
self.send_response(response.code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(response.read())
do_GET = proxy
do_POST = proxy
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Proxy)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(root / "cert.pem", root / "key.pem")
server.socket = context.wrap_socket(server.socket, server_side=True)
issuer = f"https://127.0.0.1:{server.server_port}"
proxy_thread = threading.Thread(target=server.serve_forever, daemon=True)
proxy_thread.start()
config = yaml.safe_load((args.keycape_source / "config/dev-config.yaml").read_text())
clients = yaml.safe_load((args.keycape_source / "config/service-clients.example.yaml").read_text())["clients"]
clients = [c for c in clients if c["clientId"] in ("secrets-engine-approval", "approval-engine-operator")]
assert len(clients) == 2
config.update(issuer=issuer, clients=clients)
config["authelia"]["issuer"] = "https://synthetic-upstream.invalid"
private_write(root / "config.yaml", yaml.safe_dump(config, sort_keys=False))
values = {c["clientId"]: secrets.token_urlsafe(48) for c in clients}
private_write(root / "env", "KEYCAPE_CONFIG=/etc/keycape/config.yaml\n" + "".join(
c["secretRef"].removeprefix("env:") + "=" + values[c["clientId"]] + "\n" for c in clients))
private_write(root / "approval.secret", values["secrets-engine-approval"])
private_write(root / "pdp.token", "synthetic-pdp-token")
container = "approval-identity-exercise-" + secrets.token_hex(5)
created = False
engine = None
api_server = None
pdp = None
try:
run(["docker", "run", "-d", "--rm", "--name", container, "--user", str(os.getuid()),
"--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges",
"--publish", f"127.0.0.1:{keycape_port}:8080", "--env-file", str(root / "env"),
"--mount", "type=bind,source=" + temporary + ",target=/etc/keycape,readonly", KEYCAPE_IMAGE])
created = True
with patch.dict(os.environ, {"SSL_CERT_FILE": str(root / "cert.pem"), "SECRETS_ENGINE_UNSAFE_DEMO": ""}):
for _ in range(40):
try:
with credential_urlopen(issuer + "/jwks", timeout=1) as response:
if response.status == 200:
break
except Exception:
time.sleep(0.25)
else:
raise RuntimeError("disposable KeyCape did not start")
engine = Engine(root / "approval.sqlite")
app = App(engine, JWTAuthenticator(issuer=issuer, audience="approval-engine", jwks_url=issuer + "/jwks"))
class Quiet(WSGIRequestHandler):
def log_message(self, *_args):
pass
api_server = make_server("127.0.0.1", 0, app, handler_class=Quiet)
threading.Thread(target=api_server.serve_forever, daemon=True).start()
api = f"http://127.0.0.1:{api_server.server_port}"
def operator_token(scope):
credential = base64.b64encode(("approval-engine-operator:" + values["approval-engine-operator"]).encode()).decode()
req = Request(issuer + "/token", data=urlencode({"grant_type": "client_credentials", "scope": scope}).encode(),
headers={"Authorization": "Basic " + credential})
with credential_urlopen(req, timeout=3) as response:
return json.load(response)["access_token"]
def operator_request(path, payload, scope):
req = Request(api + path, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + operator_token(scope)})
with credential_urlopen(req, timeout=3) as response:
return json.load(response)
raw = copy.deepcopy(VALID)
raw["stage"] = "prod"
raw["approval"] = {"model": "bootstrap-only", "authorization_id": "synthetic-approval", "purpose": "disposable identity proof"}
entry = validate_entry(raw)
pdp = ProducerPDP(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start()
cfg = Config(catalog_dir=root, policy_dir=root, evidence_dir=root / "evidence", hub_url="", bao_addr="", topic_id="",
approval_url=api, approval_client_secret_file=root / "approval.secret",
keycape_issuer=issuer, keycape_token_url=issuer + "/token",
authorization_subject_id="secrets-engine", authorization_subject_type="service",
authorization_policy_package="secrets-engine.catalog-lane.lifecycle", authorization_policy_version="v2",
pdp_url=pdp.url, pdp_token_file=root / "pdp.token")
request = _expected_request(cfg, entry, "apply")
digest = pdp.bind_request(request)
obj = operator_request("/v1/approvals", {
"id": "synthetic-approval",
"binding": {"action": "secrets.apply", "target": {"id": entry.id, "stage": "prod"},
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
"pdp_digest": digest, "pdp_path": True}, "approval:create")
operator_request(f"/v1/approvals/{obj['id']}/entries", {}, "approval:approve")
receipt["checks"]["operator_issued_and_approved_via_verified_jwt"] = True
try:
cli._require_lane_approval(cfg, entry, "destroy")
except DecisionError:
assert not engine.claim(obj["id"])["consumed"]
else:
raise AssertionError("wrong action accepted")
receipt["checks"]["wrong_action_refused_before_consume"] = True
cli._require_lane_approval(cfg, entry, "apply")
assert engine.claim(obj["id"])["consumed"]
assert pdp.calls == ["check", "check"]
accepted = pdp.last_decision
assert accepted["binding"]["approval_binding_digest"] == digest
assert accepted["binding"]["request_digest"] != digest
assert accepted["binding"]["submitted_request_digest"] != accepted["binding"]["request_digest"]
assert accepted["binding"]["context"]["approval"]["approval_id"] == obj["id"]
receipt["checks"]["producer_origin_join_with_carried_claim"] = True
receipt["checks"]["actual_consumer_claim_check_consume"] = True
binding = ConsumeBinding(obj["id"], accepted["binding"]["request_digest"], accepted["id"])
retry = consume_approval(base_url=api, binding=binding, token_provider=lambda: approval_token(cfg, scope="approval:consume"))
assert retry.idempotent
receipt["checks"]["same_digest_retry_idempotent"] = True
try:
consume_approval(base_url=api, binding=ConsumeBinding(obj["id"], "sha256:" + "f" * 64),
token_provider=lambda: approval_token(cfg, scope="approval:consume"))
except DecisionError:
pass
else:
raise AssertionError("different digest accepted")
receipt["checks"]["different_digest_refused"] = True
try:
cli._require_lane_approval(cfg, entry, "apply")
except DecisionError:
pass
else:
raise AssertionError("spent claim accepted")
receipt["checks"]["spent_claim_refused"] = True
try:
operator_token("approval:consume")
except HTTPError as error:
assert error.code in (400, 401)
assert json.load(error)["error"] == "invalid_profile_usage"
else:
raise AssertionError("operator consume scope accepted")
receipt["checks"]["operator_consume_scope_denied_by_issuer"] = True
(root / "approval.secret").write_text("synthetic-wrong-secret")
try:
approval_token(cfg, scope="approval:read")
except BackendError:
pass
else:
raise AssertionError("wrong secret accepted")
receipt["checks"]["wrong_secret_refused"] = True
assert not (root / "approval.token").exists()
receipt["checks"]["no_access_token_file_created"] = True
# Independent input -> evaluator -> consumer: registry may
# override a caller's same-key facts without breaking replay.
request = json.loads((producer_files / "check_request_allow_rotate.json").read_text())
request["subject"]["attributes"] = {"roles": ["UntrustedCallerRole"]}
decision = pdp.decision(request)
assert decision["binding"]["subject"]["attributes"]["roles"] == ["Operator"]
assert decision["binding"]["submitted_request_digest"] == request_digest(request)
validate_decision_envelope(decision, request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"})
receipt["checks"]["registry_override_accepts_exact_submission"] = True
replay = copy.deepcopy(request)
replay["subject"]["attributes"]["roles"] = ["Operator"]
try:
validate_decision_envelope(decision, replay,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"})
except DecisionError:
pass
else:
raise AssertionError("changed submission replayed")
receipt["checks"]["same_enriched_result_different_submission_refused"] = True
# Real dual-control policy: empty claim denies; fresh issued
# claim passes. No destructive backend is connected.
raw["approval"]["authorization_id"] = "synthetic-destroy"
destroy_entry = validate_entry(raw)
destroy_request = _expected_request(cfg, destroy_entry, "destroy")
denied = pdp.decision(destroy_request)
assert denied["effect"] == "deny" and denied["reason"] == "dual_control_required"
operator_request("/v1/approvals", {
"id": "synthetic-destroy", "binding": {"action": "secrets.kv.destroy", "target": {"id": destroy_entry.id, "stage": "prod"},
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
"pdp_digest": denied["binding"]["request_digest"], "pdp_path": True}, "approval:create")
operator_request("/v1/approvals/synthetic-destroy/entries", {}, "approval:approve")
(root / "approval.secret").write_text(values["secrets-engine-approval"])
cli._require_lane_approval(cfg, destroy_entry, "destroy")
assert engine.claim("synthetic-destroy")["consumed"]
receipt["checks"]["real_dual_control_denial_then_claim_check_consume"] = True
if args.exec_owner:
from secrets_engine.errors import DeliveryError
from secrets_engine.exec_owner import owner_digest
from types import SimpleNamespace
# Synthetic recipient and backend, real engine CLI, exact
# producer digest, JWT claim/consume, spawn and environment.
owner_dir = root / "exec-owner"
owner_dir.mkdir(mode=0o700)
executable = owner_dir / "dash"
shutil.copyfile("/bin/dash", executable)
executable.chmod(0o700)
script = owner_dir / "owner.sh"
private_write(script, 'test "$API_TOKEN" = synthetic-owner-value || exit 10\n'
'test -z "$SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE" || exit 11\n'
'test -z "$BAO_TOKEN" || exit 12\n'
'printf "owner-delivery-ok\\n"\n')
raw["approval"]["authorization_id"] = "synthetic-owner-delivery"
raw["delivery_config"] = {"exec_owner": {
"status": "configured", "owner": "synthetic-metered-owner",
"command": [str(executable), str(script)], "cwd": str(owner_dir),
"environment": {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"},
"files": {str(p): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "private": True}
for p in [executable, script]},
}}
owner_entry = validate_entry(raw)
req = _expected_request(cfg, owner_entry, "exec", fields=("api_token",))
owner_request_digest = pdp.bind_request(req)
operator_request("/v1/approvals", {
"id": "synthetic-owner-delivery", "binding": {"action": "secrets.exec", "target": {"id": owner_entry.id, "stage": "prod"},
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
"pdp_digest": owner_request_digest, "pdp_path": True}, "approval:create")
operator_request("/v1/approvals/synthetic-owner-delivery/entries", {}, "approval:approve")
backend_calls = []
class Backend:
@contextmanager
def approle_session(self, role):
assert role == owner_entry.role_name
yield SimpleNamespace(client=self)
def _run(self, command):
assert command == ["kv", "get", "-format=json", f"{owner_entry.mount}/{owner_entry.path}"]
return SimpleNamespace(returncode=0, stdout=json.dumps({"data": {"data": {"api_token": "synthetic-owner-value"}}}))
@contextmanager
def open_fixture_backend(*_args):
assert engine.claim("synthetic-owner-delivery")["consumed"]
backend_calls.append("after-consume")
yield Backend()
command = raw["delivery_config"]["exec_owner"]["command"]
cli_args = SimpleNamespace(catalog=owner_entry.id, field="api_token", mode="exec-env", command=command)
with patch.object(cli, "get_entry", return_value=owner_entry), patch.object(cli, "_open_backend", open_fixture_backend):
substituted = SimpleNamespace(**vars(cli_args))
substituted.command = ["/bin/echo", "substitute"]
try:
cli.cmd_exec(cfg, substituted)
except DeliveryError:
assert not engine.claim("synthetic-owner-delivery")["consumed"] and not backend_calls
else:
raise AssertionError("substitute recipient accepted")
original_env = raw["delivery_config"]["exec_owner"]["environment"]["LANG"]
raw["delivery_config"]["exec_owner"]["environment"]["LANG"] = "C"
try:
cli.cmd_exec(cfg, cli_args)
except DecisionError:
assert not engine.claim("synthetic-owner-delivery")["consumed"] and not backend_calls
else:
raise AssertionError("changed owner replay accepted")
raw["delivery_config"]["exec_owner"]["environment"]["LANG"] = original_env
output = io.StringIO()
with redirect_stdout(output), patch.dict(os.environ, {"BAO_TOKEN": "synthetic-parent-only", "SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE": str(root / "approval.secret")}):
assert cli.cmd_exec(cfg, cli_args) == 0
assert "owner-delivery-ok" in output.getvalue() and "synthetic-parent-only" not in output.getvalue()
assert backend_calls == ["after-consume"]
assert pdp.last_decision["binding"]["context"]["exec_owner_sha256"] == owner_digest(owner_entry)
receipt["checks"].update({
"exec_owner_substitution_refused_before_consume_backend": True,
"exec_owner_changed_environment_replay_refused_by_real_pdp_join": True,
"exec_owner_real_cli_consumes_before_fixture_backend": True,
"exec_owner_actual_child_excludes_parent_credentials": True,
"exec_owner_digest_preserved_by_real_evaluator": True,
})
receipt["exec_owner_scope"] = "Synthetic recipient/backend with real KeyCape, Approval Engine, Flex Auth and Secrets Engine CLI; not native custody or human approval proof"
finally:
if api_server:
api_server.shutdown()
api_server.server_close()
if pdp:
pdp.stop()
if engine:
engine.close()
if created:
run(["docker", "stop", "--time=5", container])
server.shutdown()
server.server_close()
proxy_thread.join()
receipt.update(status="passed", cleanup_complete=True, finished_at=datetime.now(timezone.utc).isoformat())
private_write(args.receipt, json.dumps(receipt, indent=2) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--keycape-source", required=True, type=Path)
parser.add_argument("--approval-engine-source", required=True, type=Path)
parser.add_argument("--flex-auth-source", required=True, type=Path)
parser.add_argument("--receipt", required=True, type=Path)
parser.add_argument("--exec-owner", action="store_true", help="also prove catalog-bound child delivery against the actual approval/PDP chain")
args = parser.parse_args()
if args.receipt.exists():
raise SystemExit("receipt path must be new")
try:
exercise(args)
except Exception as error:
# Never dump a request, client environment or credential-bearing frame.
print("Synthetic approval identity exercise failed: " + type(error).__name__, file=sys.stderr)
return 1
print("Synthetic approval identity exercise passed; temporary resources removed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())