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