key-cape/scripts/openbao_negative_probe.py
tegwick bae9f85160 KEY-WP-0039: probe registration packet and attended negative-probe script
T01 done: railiance-platform agreed the case list. The packet adds an
otherwise-valid expiry client and pins audience on the claim probes so each
token fails only the claim under test. The script prints one refused boolean
and refusal class per case, revokes any accepted token, and never counts a
transport failure as a refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 779409@bnt-lap001
Assistant-Session: c658ca06-19b5-468d-be64-2985dc20bfd3
2026-10-10 13:18:24 +02:00

213 lines
8.6 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.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())