Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
327 lines
14 KiB
Python
327 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""RPF-WP-0054-T03: generate a KeyCape confidential-client secret inside custody.
|
|
|
|
Entry point for the oidc-browser-relying-party ``mint-secret`` step (lane
|
|
``keycape-client-secret-generate``). Normally reached as
|
|
``credential.py generate ...``; ops-warden runs it as a child in the caller's
|
|
own environment and holds no token.
|
|
|
|
credential.py generate --grant keycape/client-secret-generate \\
|
|
--client-id forgejo --case-file <case.yaml> \\
|
|
--case-ref forgejo-netkingdom-oidc@sha256:<hex64> --receipt <file>
|
|
|
|
Steps (every one fails closed):
|
|
|
|
1. Refuse agents (``WARDEN_AGENT_ID``) and root. The caller's session must hold
|
|
the broker issuer policy for this grant.
|
|
2. Re-derive the case reference from ``--case-file`` (sha256 over its exact
|
|
bytes) and require: it equals ``--case-ref``; type
|
|
an exactly source-pinned acknowledged browser revision; status ``materialized``; client_type
|
|
``confidential``; ``parameters.client_id`` equals ``--client-id``, which
|
|
matches the type's pattern and is not a reserved entry.
|
|
3. Mint a 5-minute grant token through token role
|
|
``keycape-client-secret-generate``. All KV calls below use only that token.
|
|
4. Prove absence by metadata (404). An existing entry is never touched.
|
|
5. Generate the value in this process and write it with CAS 0 through the
|
|
``bao`` child's stdin.
|
|
6. Verify by metadata only: current version 1, live. The data path is never read.
|
|
7. Revoke the grant token (revoke-self).
|
|
|
|
The process prints nothing. The receipt (mode 0600, refused if it exists) and
|
|
the optional State Hub event hold the path, version, case reference, booleans and
|
|
an outcome code only.
|
|
|
|
Exit codes: 0 generated (dry-run: would generate); 1 contained internal error;
|
|
2 precondition refused; 3 entry already exists (never overwritten, including a
|
|
lost CAS race); 4 token or write failure; 5 verification failure.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
|
|
import yaml
|
|
|
|
GRANT = 'keycape/client-secret-generate'
|
|
TOKEN_ROLE = 'keycape-client-secret-generate'
|
|
GRANT_POLICY = 'keycape-client-secret-generate'
|
|
ISSUER_POLICY = 'credential-broker-keycape-client-secret-generate-issuer'
|
|
PREFIX = 'workloads/keycape'
|
|
RESERVED = {'secrets-engine-openbao'}
|
|
FIELD = 'CLIENT_SECRET'
|
|
# Owner configuration, never a caller-supplied root or a version search/fallback.
|
|
# The sibling source checkout must contain these exact reviewed bytes.
|
|
NETKINGDOM_ROOT = Path(__file__).resolve().parents[2] / 'net-kingdom'
|
|
ACKNOWLEDGED_TYPES = {
|
|
'oidc-browser-relying-party@1.1': (
|
|
'security-case-types/oidc-browser-relying-party/type.yaml',
|
|
'f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c'),
|
|
}
|
|
# Same authenticated producer and parameter projection as ops-warden case_inputs
|
|
# at b593c879, restricted to this broker's currently admitted 1.1. Source-only
|
|
# acknowledgment (including 1.2) never expands broker admission. A future 1.3
|
|
# entry requires real acknowledgment and a separately reviewed admission binding.
|
|
# Neither source status nor caller-supplied YAML expands this set.
|
|
SOURCE_PINS = {
|
|
'tools/security-case-types/security_case_validator.py':
|
|
'1bcc62e9bc374c711363295549f3cd5bb0b32952671ed4b08fddc7f74742959b',
|
|
'tools/security-scenario-composer/security_scenario_composer.py':
|
|
'9f0cc69930aa8ee2cc3fb92236e6a99728df17b8c0fe36e1250241c38fc01c4f',
|
|
'tools/playbook-capability-contract/playbook_contract_validator.py':
|
|
'2e1e0c809d4bc9d64c2d974cc23fecd1c5636785154c2554edd9191852fd926d',
|
|
}
|
|
PARAMETER_PINS = {
|
|
'1.1': '9ec2d6113bf70dbd35cfc247412363ff187e792bc82e24159a6f78e090aa3e4c',
|
|
}
|
|
_VALIDATE = """
|
|
import json, sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(sys.argv[1]) / 'tools/security-case-types'))
|
|
import security_case_validator as v
|
|
case, typ = json.load(sys.stdin)
|
|
issues = v.validate_type(typ) + v.validate_case(case, typ, require_implemented=False)
|
|
print('valid' if not v.has_errors(issues) else 'invalid')
|
|
"""
|
|
# oidc-browser-relying-party@1.1 parameter client_id.
|
|
CLIENT_ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
|
CASE_REF_RE = re.compile(r'^[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64}$')
|
|
# 48 random bytes -> 64 URL-safe characters, confirmed by key-cape 2026-10-10
|
|
# (message 4e0213b3; opaque constant-time comparison, no alphabet limit).
|
|
SECRET_BYTES = 48
|
|
TOKEN_TTL = '5m'
|
|
SCHEMA = 'platform.keycape-client-secret-generate.v1'
|
|
|
|
|
|
class Exit(Exception):
|
|
def __init__(self, code: int, reason: str):
|
|
super().__init__(reason)
|
|
self.code = code
|
|
self.reason = reason
|
|
|
|
|
|
def bao(args, token=None, payload=None):
|
|
env = dict(os.environ)
|
|
if token is not None:
|
|
env['BAO_TOKEN'] = env['VAULT_TOKEN'] = token
|
|
return subprocess.run(['bao', *args], env=env,
|
|
input=None if payload is None else json.dumps(payload).encode(),
|
|
capture_output=True, timeout=60)
|
|
|
|
|
|
def absent(result):
|
|
return result.returncode != 0 and (b'404' in result.stderr or b'No value found' in result.stderr)
|
|
|
|
|
|
def read_json(result, code):
|
|
try:
|
|
return json.loads(result.stdout)
|
|
except ValueError:
|
|
raise Exit(code, 'unparseable_bao_output') from None
|
|
|
|
|
|
def authenticated_source(relative, expected):
|
|
path = NETKINGDOM_ROOT / relative
|
|
# Refuse symlinks at every selected component, including the configured root.
|
|
if any(part.is_symlink() for part in (path, *path.parents)):
|
|
raise ValueError
|
|
raw = path.read_bytes()
|
|
if hashlib.sha256(raw).hexdigest() != expected:
|
|
raise ValueError
|
|
return raw
|
|
|
|
|
|
def check_acknowledged_case(doc):
|
|
# Authenticates the selected TYPE/producer bytes and validates case schema.
|
|
# The caller's self-hashed case reference is not authenticated case admission
|
|
# or owner acceptance. Those require independent Warden/native evidence;
|
|
# this source-only hardening does not complete the real admission binding.
|
|
binding = ACKNOWLEDGED_TYPES.get(doc['spec'].get('type'))
|
|
if binding is None:
|
|
raise Exit(2, 'case_type_unacknowledged')
|
|
try:
|
|
typ = yaml.safe_load(authenticated_source(*binding))
|
|
spec = typ['spec']
|
|
version = str(spec['version'])
|
|
fingerprint = hashlib.sha256(json.dumps(
|
|
spec['parameters'], sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
if (spec['status'] not in ('acknowledged', 'implemented')
|
|
or f"{spec['id']}@{version}" != doc['spec']['type']
|
|
or PARAMETER_PINS.get(version) != fingerprint):
|
|
raise ValueError
|
|
with tempfile.TemporaryDirectory(prefix='platform-case-validator-') as directory:
|
|
for relative, expected in SOURCE_PINS.items():
|
|
raw = authenticated_source(relative, expected)
|
|
dest = Path(directory) / relative
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_bytes(raw)
|
|
result = subprocess.run(
|
|
[sys.executable, '-I', '-c', _VALIDATE, directory],
|
|
input=json.dumps([doc, typ]), text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
env={}, timeout=10, check=True)
|
|
if result.stdout != 'valid\n':
|
|
raise ValueError
|
|
except (OSError, ValueError, TypeError, KeyError, yaml.YAMLError,
|
|
subprocess.SubprocessError):
|
|
raise Exit(2, 'case_source_validation_failed') from None
|
|
|
|
|
|
def check_case(args, receipt):
|
|
if not CLIENT_ID_RE.match(args.client_id) or args.client_id in RESERVED:
|
|
raise Exit(2, 'client_id_invalid_or_reserved')
|
|
if not CASE_REF_RE.match(args.case_ref):
|
|
raise Exit(2, 'case_ref_malformed')
|
|
try:
|
|
raw = Path(args.case_file).read_bytes()
|
|
doc = yaml.safe_load(raw)
|
|
except (OSError, yaml.YAMLError):
|
|
raise Exit(2, 'case_file_unreadable') from None
|
|
spec = (doc or {}).get('spec') if isinstance(doc, dict) else None
|
|
if not isinstance(spec, dict):
|
|
raise Exit(2, 'case_file_shape')
|
|
params = spec.get('parameters') or {}
|
|
derived = f"{spec.get('id')}@sha256:{hashlib.sha256(raw).hexdigest()}"
|
|
if derived != args.case_ref:
|
|
raise Exit(2, 'case_ref_mismatch')
|
|
if not isinstance(spec.get('type'), str):
|
|
raise Exit(2, 'case_type_unacknowledged')
|
|
if spec.get('status') != 'materialized':
|
|
raise Exit(2, 'case_not_materialized')
|
|
if params.get('client_type') != 'confidential':
|
|
raise Exit(2, 'case_client_not_confidential')
|
|
if params.get('client_id') != args.client_id:
|
|
raise Exit(2, 'case_client_id_mismatch')
|
|
check_acknowledged_case(doc)
|
|
receipt['case_verified'] = True
|
|
|
|
|
|
def run(args, receipt, state):
|
|
if args.grant != GRANT:
|
|
raise Exit(2, 'unknown_grant')
|
|
if os.environ.get('WARDEN_AGENT_ID'):
|
|
raise Exit(2, 'agent_callers_refused')
|
|
check_case(args, receipt)
|
|
|
|
ident = bao(['token', 'lookup', '-format=json'])
|
|
if ident.returncode != 0:
|
|
raise Exit(2, 'caller_session_missing')
|
|
policies = (read_json(ident, 2).get('data') or {}).get('policies') or []
|
|
if 'root' in policies:
|
|
raise Exit(2, 'root_refused')
|
|
if ISSUER_POLICY not in policies:
|
|
raise Exit(2, 'caller_lacks_issuer_policy')
|
|
|
|
created = bao(['token', 'create', f'-role={TOKEN_ROLE}', f'-policy={GRANT_POLICY}',
|
|
f'-ttl={TOKEN_TTL}', '-format=json'])
|
|
if created.returncode != 0:
|
|
raise Exit(4, 'grant_token_create_failed')
|
|
try:
|
|
state['token'] = read_json(created, 4)['auth']['client_token']
|
|
except (KeyError, TypeError):
|
|
raise Exit(4, 'grant_token_create_failed') from None
|
|
token = state['token']
|
|
|
|
meta_path = f'platform/metadata/{PREFIX}/{args.client_id}'
|
|
meta = bao(['read', '-format=json', meta_path], token=token)
|
|
if meta.returncode == 0:
|
|
raise Exit(3, 'already_present')
|
|
if not absent(meta):
|
|
raise Exit(2, 'absence_not_proven')
|
|
if args.dry_run:
|
|
return 'dry_run_would_generate'
|
|
|
|
value = secrets.token_urlsafe(SECRET_BYTES)
|
|
written = bao(['write', '-format=json', f'platform/data/{PREFIX}/{args.client_id}', '-'],
|
|
token=token, payload={'options': {'cas': 0}, 'data': {FIELD: value}})
|
|
del value
|
|
if written.returncode != 0:
|
|
if bao(['read', '-format=json', meta_path], token=token).returncode == 0:
|
|
raise Exit(3, 'already_present_lost_race')
|
|
raise Exit(4, 'write_failed')
|
|
|
|
meta = bao(['read', '-format=json', meta_path], token=token)
|
|
if meta.returncode != 0:
|
|
raise Exit(5, 'verify_metadata_unreadable')
|
|
data = read_json(meta, 5).get('data') or {}
|
|
version = (data.get('versions') or {}).get('1') or {}
|
|
if data.get('current_version') != 1 or version.get('deletion_time') or version.get('destroyed'):
|
|
raise Exit(5, 'verify_version_mismatch')
|
|
receipt['kv_version'] = 1
|
|
return 'generated'
|
|
|
|
|
|
def record_state_hub(url, receipt):
|
|
body = {
|
|
'summary': (f"keycape-client-secret-generate {receipt['outcome']}: {receipt['path']} "
|
|
f"v{receipt.get('kv_version', '-')} case {receipt['case_ref']}"),
|
|
'event_type': 'credential_change_evidence',
|
|
'author': 'railiance-platform-credential-broker',
|
|
}
|
|
request = urllib.request.Request(url.rstrip('/') + '/progress/', data=json.dumps(body).encode(),
|
|
headers={'Content-Type': 'application/json'}, method='POST')
|
|
with urllib.request.urlopen(request, timeout=10) as response:
|
|
return 200 <= response.status < 300
|
|
|
|
|
|
def main(argv=None):
|
|
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
os.dup2(devnull, 1)
|
|
os.dup2(devnull, 2)
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--grant', required=True)
|
|
parser.add_argument('--client-id', required=True)
|
|
parser.add_argument('--case-ref', required=True)
|
|
parser.add_argument('--case-file', required=True)
|
|
parser.add_argument('--receipt', type=Path)
|
|
parser.add_argument('--dry-run', action='store_true')
|
|
parser.add_argument('--record-state-hub', action='store_true')
|
|
parser.add_argument('--state-hub-url', default=os.environ.get('STATE_HUB_URL', 'http://127.0.0.1:8000'))
|
|
try:
|
|
args = parser.parse_args(argv)
|
|
except SystemExit:
|
|
return 2
|
|
receipt = {'schema': SCHEMA, 'mode': 'dry-run' if args.dry_run else 'generate',
|
|
'grant': args.grant, 'client_id': args.client_id, 'case_ref': args.case_ref,
|
|
'path': f'platform/{PREFIX}/{args.client_id}', 'value_reads': 0,
|
|
'credential_values_emitted': False}
|
|
fd = None
|
|
if args.receipt is not None:
|
|
try:
|
|
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
except OSError:
|
|
return 2
|
|
state = {}
|
|
try:
|
|
receipt['outcome'] = run(args, receipt, state)
|
|
code = 0
|
|
except Exit as exc:
|
|
receipt['outcome'] = exc.reason
|
|
code = exc.code
|
|
except Exception:
|
|
receipt['outcome'] = 'contained_internal_error'
|
|
code = 1
|
|
if state.get('token'):
|
|
receipt['grant_token_revoked'] = bao(['token', 'revoke', '-self'], token=state['token']).returncode == 0
|
|
receipt['exit_code'] = code
|
|
if args.record_state_hub:
|
|
try:
|
|
receipt['state_hub_recorded'] = record_state_hub(args.state_hub_url, receipt)
|
|
except Exception:
|
|
receipt['state_hub_recorded'] = False
|
|
if fd is not None:
|
|
os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode())
|
|
os.close(fd)
|
|
return code
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|