Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
293 lines
15 KiB
Python
293 lines
15 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 PDP is a sequencing double; this is not production authorization evidence.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import copy
|
|
import hashlib
|
|
import http.server
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import secrets
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
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.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),
|
|
"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", "config.py", "service_auth.py")
|
|
},
|
|
"limitations": ["PDP sequencing double", "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)
|
|
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 = AuthorizationStub(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"]
|
|
receipt["checks"]["actual_consumer_claim_check_consume"] = True
|
|
binding = ConsumeBinding(obj["id"], digest)
|
|
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
|
|
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("--receipt", required=True, type=Path)
|
|
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())
|