#!/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.v2' 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 NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None 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.build_opener(NoRedirect()).open(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', 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: token = json.loads(raw)['access_token'] if not isinstance(token, str) or not token: raise ValueError return token except (ValueError, KeyError, TypeError): raise Inconclusive('token_response_invalid') REFERENCE = {'aud': 'secrets-engine-openbao', 'sub': 'service:secrets-engine', 'tenant': 'tenant:platform', 'roles': ['secrets-engine'], 'scope': 'openbao:login', 'principal_type': 'service'} def check_claims(token, case, now): """Inspect claim shape for probe isolation, without asserting signature validity.""" try: if not isinstance(token, str): raise ValueError payload = token.split('.')[1] claims = json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4))) exp = claims['exp'] if not isinstance(exp, int) or isinstance(exp, bool) or exp <= now(): raise ValueError if claims.get('iss') != 'https://kc.coulomb.social': raise ValueError if 'nbf' in claims and claims['nbf'] > now(): raise ValueError mismatches = {key for key, expected in REFERENCE.items() if claims.get(key) != expected} expected = set() if case == 'expired' else {case} if case == 'principal_type': valid = claims.get('principal_type') == 'human' elif case == 'coding_agent': valid = claims.get('principal_type') == 'service' and 'coding-agent' in claims.get('roles', []) else: valid = mismatches == expected if case == 'expired': issued = claims.get('iat') valid = valid and isinstance(issued, int) and not isinstance(issued, bool) and 0 < exp - issued <= 60 if not valid: raise ValueError return exp except (IndexError, ValueError, KeyError, TypeError): raise Inconclusive('probe_claims_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'] if not isinstance(bao_token, str) or not bao_token: raise ValueError 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: items = json.loads(raw).get('errors', []) if not isinstance(items, list) or not all(isinstance(e, str) for e in items): raise ValueError errors = ' '.join(items) 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): if isinstance(leeway, bool) or not isinstance(leeway, int) or leeway < 0: raise Inconclusive('leeway_invalid') cases = [] halted = False def record(case, fn): nonlocal halted try: if halted: raise Inconclusive('session_aborted') refused, cls, revoked = fn() expected = {'expired': {'expired'}, 'principal_type': {'not_service_principal', 'claim_mismatch'}, 'coding_agent': {'not_service_principal', 'claim_mismatch'}}.get(case, {'claim_mismatch'}) semantic = refused and cls in expected entry = {'case': case, 'refused': refused, 'class': cls, 'verified': semantic} if revoked is not None: entry['revoked'] = revoked if revoked is False: halted = True except Inconclusive as err: entry = {'case': case, 'refused': False, 'class': 'inconclusive', 'verified': False, 'reason': str(err)} cases.append(entry) def via_client(case, client_id, scope): if client_id not in secrets: raise Inconclusive('secret_not_supplied') token = exchange(post, token_endpoint, client_id, secrets[client_id], scope) check_claims(token, case, now) return present(post, bao_addr, mount, role, token) # 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 = check_claims(expiry_token, 'expired', now) refused, cls, revoked = present(post, bao_addr, mount, role, expiry_token) if refused or cls != 'accepted' or revoked is not True: raise Inconclusive('expiry_positive_control_failed') except Inconclusive as err: expiry_error = err halted = True for case, client_id, scope in CLAIM_PROBES: record(case, lambda c=client_id, s=scope: via_client(case, c, s)) record(CODING_AGENT[0], lambda: via_client(CODING_AGENT[0], CODING_AGENT[1], CODING_AGENT[2])) if human_token: def principal(): check_claims(human_token, 'principal_type', now) return present(post, bao_addr, mount, role, human_token) record('principal_type', principal) else: cases.append({'case': 'principal_type', 'refused': False, 'class': 'inconclusive', 'verified': False, '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['verified'] for c in cases), 'expiry_positive_control_verified': expiry_error is None, 'expiration_leeway_seconds': leeway, '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 args.issuer.rstrip('/') != 'https://kc.coulomb.social' or not args.bao_addr.startswith('https://') or args.leeway < 0: 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.build_opener(NoRedirect()).open(args.issuer.rstrip('/') + '/.well-known/openid-configuration', timeout=15) as r: token_endpoint = json.loads(r.read())['token_endpoint'] issuer_url = urllib.parse.urlsplit(args.issuer) endpoint_url = urllib.parse.urlsplit(token_endpoint) if (endpoint_url.scheme, endpoint_url.netloc) != (issuer_url.scheme, issuer_url.netloc) or endpoint_url.username or endpoint_url.password: raise ValueError 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())