secrets-engine/tools/exercise_approval_identity.py

294 lines
15 KiB
Python
Raw Normal View History

#!/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())