Silent generate-in-custody: verifies the SecurityCase file (digest, type, materialized, confidential, client_id) per ops-warden 23985370, mints the 5m create-only grant token from the caller's session, proves absence, writes once with CAS 0, verifies by metadata only and revokes the token. Agents, root and callers without the issuer policy are refused. Secret length awaits key-cape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 469992@bnt-lap001 Assistant-Session: e4b8e420-df0a-441d-83ae-7f19130594d9
246 lines
9.6 KiB
Python
246 lines
9.6 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
|
|
``oidc-browser-relying-party@1.0``; 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 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'
|
|
CASE_TYPE = 'oidc-browser-relying-party@1.0'
|
|
# oidc-browser-relying-party@1.0 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}$')
|
|
# Proposed 48 random bytes -> 64 URL-safe characters; key-cape confirmation
|
|
# pending (RPF-WP-0054-T01).
|
|
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 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 spec.get('type') != CASE_TYPE:
|
|
raise Exit(2, 'case_type_mismatch')
|
|
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')
|
|
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())
|