#!/usr/bin/env python3 """Attended CCR-2026-0038 probe: present tokens the OpenBao role must refuse. KEY-WP-0039-T02. Reads the probe client secrets as one JSON object on stdin ({client_id: secret}), obtains a token for each case from KeyCape, presents it to auth//login with the role under test, and prints one value-free receipt: per case, refused or not and the refusal class. No token, secret, claim value or OpenBao error text is printed or written. If a token is accepted, which is a failure, the resulting OpenBao token is revoked at once. The cases are those in docs/openbao-negative-probe-clients.yaml. """ import argparse import base64 import datetime import json import re import sys import time import urllib.error import urllib.parse import urllib.request SCHEMA = 'keycape.openbao-negative-probe.v1' CLAIM_PROBES = [ ('aud', 'openbao-probe-aud', 'openbao:login'), ('sub', 'openbao-probe-sub', 'openbao:login'), ('tenant', 'openbao-probe-tenant', 'openbao:login'), ('roles', 'openbao-probe-roles', 'openbao:login'), ('scope', 'openbao-probe-scope', 'openbao:probe'), ] EXPIRY_PROBE = ('expired', 'openbao-probe-expiry', 'openbao:login') CODING_AGENT = ('coding_agent', 'codex-railiance-platform', 'openbao:login') DEFAULT_LEEWAY = 150 # OpenBao jwt auth expiration_leeway default, seconds class Inconclusive(Exception): """The probe could not tell refusal from failure; never counted as a refusal.""" def http_post(url, form=None, body=None, headers=None, timeout=15): headers = dict(headers or {}) if form is not None: data = urllib.parse.urlencode(form).encode() headers['Content-Type'] = 'application/x-www-form-urlencoded' else: data = json.dumps(body or {}).encode() headers['Content-Type'] = 'application/json' req = urllib.request.Request(url, data=data, headers=headers, method='POST') try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.read() except urllib.error.HTTPError as err: return err.code, err.read() except (urllib.error.URLError, OSError): return None, b'' def classify(errors_text): """Map OpenBao's refusal text to a class without retaining the text.""" text = errors_text.lower() if 'principal_type' in text: return 'not_service_principal' if re.search(r'expired|\bexp\b|not valid yet|nbf', text): return 'expired' if re.search(r'claim|audience|\baud\b|subject|\bsub\b|bound', text): return 'claim_mismatch' return 'refused_other' def exchange(post, token_endpoint, client_id, secret, scope): basic = base64.b64encode( f'{urllib.parse.quote(client_id, safe="")}:{urllib.parse.quote(secret, safe="")}'.encode()).decode() status, raw = post(token_endpoint, form={'grant_type': 'client_credentials', 'scope': scope}, headers={'Authorization': 'Basic ' + basic}) if status != 200: raise Inconclusive('token_exchange_failed') try: return json.loads(raw)['access_token'] except (ValueError, KeyError, TypeError): raise Inconclusive('token_response_invalid') def jwt_exp(token): try: payload = token.split('.')[1] return int(json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4)))['exp']) except (IndexError, ValueError, KeyError, TypeError): raise Inconclusive('token_unreadable') def present(post, bao_addr, mount, role, token): """Return (refused, class, revoked). revoked is None unless the token was accepted.""" status, raw = post(f'{bao_addr}/v1/auth/{mount}/login', body={'role': role, 'jwt': token}) if status == 200: try: bao_token = json.loads(raw)['auth']['client_token'] except (ValueError, KeyError, TypeError): return False, 'accepted', False rstatus, _ = post(f'{bao_addr}/v1/auth/token/revoke-self', body={}, headers={'X-Vault-Token': bao_token}) return False, 'accepted', rstatus == 204 if status in (400, 403): try: errors = ' '.join(str(e) for e in json.loads(raw).get('errors', [])) except (ValueError, AttributeError): errors = '' if errors: return True, classify(errors), None raise Inconclusive('login_unexpected_response') def run(post, secrets, token_endpoint, bao_addr, mount, role, human_token=None, leeway=DEFAULT_LEEWAY, sleep=time.sleep, now=time.time): cases = [] def record(case, fn): try: refused, cls, revoked = fn() entry = {'case': case, 'refused': refused, 'class': cls} if revoked is not None: entry['revoked'] = revoked except Inconclusive as err: entry = {'case': case, 'refused': False, 'class': 'inconclusive', 'reason': str(err)} cases.append(entry) def via_client(client_id, scope): if client_id not in secrets: raise Inconclusive('secret_not_supplied') return present(post, bao_addr, mount, role, exchange(post, token_endpoint, client_id, secrets[client_id], scope)) # Mint the expiry token first so its wait overlaps the other cases. expiry_token, expiry_error = None, None try: if EXPIRY_PROBE[1] not in secrets: raise Inconclusive('secret_not_supplied') expiry_token = exchange(post, token_endpoint, EXPIRY_PROBE[1], secrets[EXPIRY_PROBE[1]], EXPIRY_PROBE[2]) expires_at = jwt_exp(expiry_token) except Inconclusive as err: expiry_error = err for case, client_id, scope in CLAIM_PROBES: record(case, lambda c=client_id, s=scope: via_client(c, s)) record(CODING_AGENT[0], lambda: via_client(CODING_AGENT[1], CODING_AGENT[2])) if human_token: record('principal_type', lambda: present(post, bao_addr, mount, role, human_token)) else: cases.append({'case': 'principal_type', 'refused': False, 'class': 'inconclusive', 'reason': 'human_token_not_supplied'}) def expired(): if expiry_error: raise expiry_error wait = expires_at + leeway + 10 - now() if wait > 0: sleep(wait) return present(post, bao_addr, mount, role, expiry_token) record(EXPIRY_PROBE[0], expired) return { 'schema': SCHEMA, 'observed_at': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'mount': mount, 'role': role, 'cases': cases, 'all_refused': all(c['refused'] for c in cases), 'values_retained': False, } def main(argv=None): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument('--issuer', default='https://kc.coulomb.social') ap.add_argument('--bao-addr', required=True) ap.add_argument('--mount', default='keycape-services') ap.add_argument('--role', default='secrets-engine-openbao') ap.add_argument('--human-token-file', help='private JSON token file written by keycape login') ap.add_argument('--leeway', type=int, default=DEFAULT_LEEWAY, help='the role mount expiration_leeway in seconds') args = ap.parse_args(argv) if not args.issuer.startswith('https://') or not args.bao_addr.startswith('https://'): print(json.dumps({'schema': SCHEMA, 'error': 'https_required'})) return 2 try: secrets = json.loads(sys.stdin.read()) if not isinstance(secrets, dict) or not all(isinstance(v, str) and v for v in secrets.values()): raise ValueError except ValueError: print(json.dumps({'schema': SCHEMA, 'error': 'secrets_input_invalid'})) return 2 human_token = None if args.human_token_file: try: with open(args.human_token_file) as fh: human_token = json.load(fh)['access_token'] except (OSError, ValueError, KeyError, TypeError): print(json.dumps({'schema': SCHEMA, 'error': 'human_token_file_invalid'})) return 2 try: with urllib.request.urlopen(args.issuer.rstrip('/') + '/.well-known/openid-configuration', timeout=15) as r: token_endpoint = json.loads(r.read())['token_endpoint'] except (OSError, ValueError, KeyError): print(json.dumps({'schema': SCHEMA, 'error': 'discovery_failed'})) return 2 receipt = run(http_post, secrets, token_endpoint, args.bao_addr.rstrip('/'), args.mount, args.role, human_token=human_token, leeway=args.leeway) print(json.dumps(receipt, indent=2)) return 0 if receipt['all_refused'] else 1 if __name__ == '__main__': sys.exit(main())