diff --git a/docs/openbao-negative-probe-clients.yaml b/docs/openbao-negative-probe-clients.yaml new file mode 100644 index 0000000..a225193 --- /dev/null +++ b/docs/openbao-negative-probe-clients.yaml @@ -0,0 +1,97 @@ +# Non-secret request packet for KEY-WP-0039: temporary probe registrations whose +# tokens the OpenBao role secrets-engine-openbao (auth/keycape-services) must +# refuse, for CCR-2026-0038 (railiance-platform RPF-WP-0035-T02). +# +# This is not executable authorization. Each probe is a copy of the live +# secrets-engine-openbao registration (config/service-clients.example.yaml) with +# exactly one property changed. `audience` is pinned to secrets-engine-openbao +# wherever aud is not the property under test: a service token's aud is the +# clientId unless audience is set (token.go, accessAudience), so without the pin +# every probe would also fail on aud and prove nothing about its own claim. +# +# The secrets-engine-openbao registration itself is not changed. +reference: + clientId: secrets-engine-openbao + aud: secrets-engine-openbao + sub: service:secrets-engine + tenant: tenant:platform + roles: [secrets-engine] + scope: openbao:login + principal_type: service + +common: + grantTypes: ["client_credentials"] + clientType: confidential + tokenLifetime: "1m" # the minimum KeyCape accepts + # Each secretRef names an env var that exists only for the attended session. + # railiance-platform's condition (a), 2026-10-10: no OpenBao custody. + secretRefPattern: "env:KEYCAPE__CLIENT_SECRET" + +probes: + - case: aud + clientId: openbao-probe-aud + differs: {aud: openbao-probe-aud} # no audience pin + expect: refuse + expected_class: claim_mismatch + - case: sub + clientId: openbao-probe-sub + audience: secrets-engine-openbao + differs: {serviceSubject: "service:openbao-probe"} + expect: refuse + expected_class: claim_mismatch + - case: tenant + clientId: openbao-probe-tenant + audience: secrets-engine-openbao + differs: {tenant: "tenant:openbao-probe"} + expect: refuse + expected_class: claim_mismatch + - case: roles + clientId: openbao-probe-roles + audience: secrets-engine-openbao + differs: {roles: ["openbao-probe"]} + expect: refuse + expected_class: claim_mismatch + - case: scope + clientId: openbao-probe-scope + audience: secrets-engine-openbao + differs: {allowedScopes: ["openbao:probe"]} + expect: refuse + expected_class: claim_mismatch + - case: expired + clientId: openbao-probe-expiry + audience: secrets-engine-openbao + # Identical to the reference in every claim, so expiry is the only defect. + # This makes it a second credential that can log in as secrets-engine until + # it is removed. That exposure is bounded by the session and is the reason the + # probe clients go in the same session. A token from one of the other probes + # cannot stand in: it already fails a claim, so its refusal would not show + # that expiry was checked. + differs: {} + present_after: "exp + OpenBao expiration_leeway (default 150s) + 10s" + expect: refuse + expected_class: expired + +existing_tokens: + # These differ from the reference in several claims, so they prove refusal but + # not which bound claim refused. The receipt records whatever class OpenBao reports. + - case: principal_type + source: a human access token from an attended `keycape login` + expect: refuse + - case: coding_agent + source: codex-railiance-platform (role coding-agent), if registered live + expect: refuse + +session: + # One attended operator session, run by the owner of the live KeyCape client + # registry (net-kingdom). Order: + # 1. Generate the six probe secrets in the session. Write them to a transient + # Kubernetes Secret bound to Deployment sso/keycape, never to OpenBao. + # 2. Add the probe registrations; KeyCape restarts. + # 3. Run scripts/openbao_negative_probe.py with the secrets on stdin. + # 4. Remove the registrations and the transient Secret; KeyCape restarts. + # Verify that client_credentials for each probe returns invalid_client. + # Each restart drops in-flight logins, so pick a quiet window. + # The transient Secret is the only place the values exist outside process + # memory. KeyCape resolves secretRef from its environment at startup, so a + # copy that lives only in memory cannot reach it. + receipt: docs/evidence/-openbao-negative-probe.json diff --git a/scripts/openbao_negative_probe.py b/scripts/openbao_negative_probe.py new file mode 100644 index 0000000..134bb7a --- /dev/null +++ b/scripts/openbao_negative_probe.py @@ -0,0 +1,213 @@ +#!/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.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()) diff --git a/scripts/test_openbao_negative_probe.py b/scripts/test_openbao_negative_probe.py new file mode 100644 index 0000000..8a24d0b --- /dev/null +++ b/scripts/test_openbao_negative_probe.py @@ -0,0 +1,99 @@ +import base64 +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +import openbao_negative_probe as probe # noqa: E402 + +TOKEN_URL = 'https://kc.example/token' +BAO = 'https://bao.example' +SECRET = 'not-a-real-secret' +SECRETS = {cid: SECRET for _, cid, _ in probe.CLAIM_PROBES + [probe.EXPIRY_PROBE, probe.CODING_AGENT]} + + +def fake_jwt(client_id, exp=1000): + payload = base64.urlsafe_b64encode(json.dumps({'exp': exp, 'cid': client_id}).encode()).decode().rstrip('=') + return f'h.{payload}.s' + + +class FakeBao: + """KeyCape and OpenBao in one: refuses every token unless told to accept it.""" + + def __init__(self, accept=(), login_status=None): + self.accept = set(accept) + self.login_status = login_status + self.revoked = 0 + + def __call__(self, url, form=None, body=None, headers=None, timeout=15): + if url == TOKEN_URL: + cid = base64.b64decode(headers['Authorization'].split()[1]).decode().split(':')[0] + return 200, json.dumps({'access_token': fake_jwt(cid)}).encode() + if url.endswith('/revoke-self'): + self.revoked += 1 + return 204, b'' + cid = json.loads(base64.urlsafe_b64decode(body['jwt'].split('.')[1] + '==')).get('cid', 'human') + if self.login_status: + return self.login_status, b'' + if cid in self.accept: + return 200, json.dumps({'auth': {'client_token': 'bao-token'}}).encode() + reason = {'openbao-probe-expiry': 'token is expired', + 'human': 'claim "principal_type" does not match'}.get(cid, 'claim "sub" does not match any bound value') + return 400, json.dumps({'errors': [reason]}).encode() + + +def run(fake, **kw): + kw.setdefault('human_token', fake_jwt('human')) + return probe.run(fake, SECRETS, TOKEN_URL, BAO, 'keycape-services', 'secrets-engine-openbao', + sleep=lambda s: None, now=lambda: 0, **kw) + + +class ProbeTest(unittest.TestCase): + def test_all_refused_with_classes(self): + receipt = run(FakeBao()) + self.assertTrue(receipt['all_refused']) + classes = {c['case']: c['class'] for c in receipt['cases']} + self.assertEqual(classes['expired'], 'expired') + self.assertEqual(classes['principal_type'], 'not_service_principal') + self.assertEqual(classes['sub'], 'claim_mismatch') + self.assertEqual(len(receipt['cases']), 8) + + def test_accepted_token_fails_and_is_revoked(self): + fake = FakeBao(accept={'openbao-probe-tenant'}) + receipt = run(fake) + self.assertFalse(receipt['all_refused']) + tenant = next(c for c in receipt['cases'] if c['case'] == 'tenant') + self.assertEqual((tenant['refused'], tenant['class'], tenant['revoked']), (False, 'accepted', True)) + self.assertEqual(fake.revoked, 1) + + def test_transport_failure_is_never_a_refusal(self): + receipt = run(FakeBao(login_status=503)) + self.assertFalse(receipt['all_refused']) + self.assertTrue(all(c['class'] == 'inconclusive' for c in receipt['cases'])) + + def test_missing_inputs_are_inconclusive(self): + receipt = run(FakeBao(), human_token=None) + pt = next(c for c in receipt['cases'] if c['case'] == 'principal_type') + self.assertEqual(pt['class'], 'inconclusive') + self.assertFalse(receipt['all_refused']) + + def test_receipt_carries_no_values(self): + text = json.dumps(run(FakeBao(accept={'openbao-probe-aud'}))) + for value in (SECRET, 'bao-token', 'does not match', 'h.'): + self.assertNotIn(value, text) + + def test_expiry_waits_past_leeway(self): + waits = [] + probe.run(FakeBao(), SECRETS, TOKEN_URL, BAO, 'm', 'r', human_token=fake_jwt('human'), + leeway=150, sleep=waits.append, now=lambda: 900) + self.assertEqual(waits, [1000 + 150 + 10 - 900]) + + def test_classify(self): + self.assertEqual(probe.classify('error validating token: token is expired'), 'expired') + self.assertEqual(probe.classify('aud claim does not match any bound audience'), 'claim_mismatch') + self.assertEqual(probe.classify('permission denied'), 'refused_other') + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md b/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md index 30822e7..2f4b5e3 100644 --- a/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md +++ b/workplans/KEY-WP-0039-openbao-negative-probe-tokens.md @@ -4,7 +4,7 @@ type: workplan title: "Negative-check tokens for the secrets-engine OpenBao JWT role" domain: infotech repo: key-cape -status: proposed +status: active flavor: implementation owner: claude-code topic_slug: netkingdom @@ -47,7 +47,7 @@ from `secrets-engine` in exactly one property each. ```task id: KEY-WP-0039-T01 -status: todo +status: done priority: medium state_hub_task_id: "4d06b059-3768-53bb-83a4-a9a71c0850b8" ``` @@ -76,6 +76,25 @@ The expired-JWT case reuses any probe client: request a token, wait past its Acceptance: railiance-platform agrees the packet covers every refusal that CCR-2026-0038 names. +2026-10-10: railiance-platform agreed that the case list covers every +CCR-2026-0038 claim negative. The remaining CCR checks (capabilities-self deny, no +provider fallback) are theirs and secrets-engine's. Packet written at +`docs/openbao-negative-probe-clients.yaml`. It departs from the agreed list in two +ways, both sent back for re-agreement: + +- **Expired case.** A token from another probe already fails a claim, so its + refusal does not show that expiry was checked. It needs a sixth client, + `openbao-probe-expiry`, identical to `secrets-engine-openbao` in every claim. That + makes it a second credential able to log in as secrets-engine for the session. + OpenBao's `expiration_leeway` (150s by default) also extends the wait. +- **Audience pin.** A service token's `aud` is its `clientId` unless `audience` is + set. Every probe except the aud probe therefore pins + `audience: secrets-engine-openbao`, so it fails only on its own claim. + +Condition (a), no OpenBao custody, holds. The values still have to reach KeyCape's +environment, because KeyCape resolves `secretRef` at startup. The packet uses a +transient Kubernetes Secret that is deleted in the same session. + ```task id: KEY-WP-0039-T02 status: wait @@ -87,7 +106,16 @@ state_hub_task_id: "03da4c38-1dac-53b1-8b0b-e697df48e9d3" ### T02 — Register the probe clients, run the attended probe, then remove them -Write the attended script, which prints booleans only. Register the clients +Write the attended script, which prints booleans only. + +2026-10-10: script written at `scripts/openbao_negative_probe.py`, with tests in +`scripts/test_openbao_negative_probe.py`. It meets condition (b): one refused +boolean per case plus the refusal class (`claim_mismatch`, `expired`, +`not_service_principal`), and no token, secret, claim value or OpenBao error text. +A transport failure or an unexpected response is reported as `inconclusive`, never +as a refusal. A token that OpenBao accepts is revoked at once and fails the run. +Still waiting: railiance-platform's re-agreement on the two departures above, and +an attended registry session with net-kingdom. Register the clients through the normal registration path, hand the run to railiance-platform, record a value-free receipt in `docs/evidence/`, and deregister the clients afterwards.