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