key-cape/scripts/openbao_negative_probe.py
tegwick aef56bcba1 Bind JWT negative probes to exact claim and cleanup evidence
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
2026-10-11 02:21:03 +02:00

288 lines
12 KiB
Python

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