From bae9f8516062a94a81ba2bdfac679d68aff58067 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 10 Oct 2026 13:18:24 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 779409@bnt-lap001 Assistant-Session: c658ca06-19b5-468d-be64-2985dc20bfd3 --- docs/openbao-negative-probe-clients.yaml | 97 ++++++++ scripts/openbao_negative_probe.py | 213 ++++++++++++++++++ scripts/test_openbao_negative_probe.py | 99 ++++++++ ...Y-WP-0039-openbao-negative-probe-tokens.md | 34 ++- 4 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 docs/openbao-negative-probe-clients.yaml create mode 100644 scripts/openbao_negative_probe.py create mode 100644 scripts/test_openbao_negative_probe.py 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.