Bind JWT negative probes to exact claim and cleanup evidence

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-11 02:21:03 +02:00
parent f97f006ede
commit aef56bcba1
4 changed files with 322 additions and 22 deletions

View file

@ -0,0 +1,117 @@
# Coordinated OpenBao JWT refusal session proposal
Status: prepared, not an execution grant. Proposal ID: KEY-WP-0039-T02/coordinated-negatives-v2.
The current positive service-JWT login and no-fallback results are retained. This
session adds native refusals for CCR-2026-0038 and must not replay registration,
custody minting or accepted provisioning. It leaves the secrets-engine-openbao
registration, custody and OpenBao role/policy unchanged.
## Exact owner re-agreement and execution authority
Railiance-platform must re-agree the packet's two documented departures: all
non-audience probes pin audience secrets-engine-openbao, and the sixth expiry
client has every production claim and therefore is capable of logging in until
removed. The script now exercises that capability as a positive control before
expiry and immediately revokes the resulting self-only OpenBao session. This
change belongs in the exact owner review, rather than being hidden in a test.
NetKingdom owns live registry/env changes and both KeyCape restarts. KeyCape owns
claim isolation and issuance checks. Platform owns refusal interpretation,
effective-policy capability checks and the final CCR receipt. Founder approval
must name the exact revised script and registration packet digests, bounded
session duration, temporary Secret and registration names, and rollback below.
Previous approval of CCR-2026-0038 alone is not approval to add six clients.
No existing coding-agent secret is requested through chat or a repository. If
codex-railiance-platform is not registered and independently available through
its admitted route, that case is inconclusive and requires owner disposition;
it is never silently skipped or replaced by an altered production client.
## Preflight before any mutation
Record only value-free metadata: current KeyCape Deployment image/revision,
registry content digest with secretRefs only, its resourceVersion, admission of
six unique client IDs, a spare restart window, and baseline workload health.
Confirm live JWT issuer, RS256, exact role bindings and current
expiration_leeway. Omitted/zero OpenBao expiration_leeway means the provider
default 150 seconds; a disabled leeway must be normalized to effective zero
before passing --leeway. Record effective leeway in the receipt. Abort if role
bindings differ from CCR-2026-0038, if any temporary client already exists, or
if the registry has an unreviewed revision. No value or Secret object read is
part of this preflight.
The execution packet must provide a private admitted path to the existing
coding-agent token and an attended human login. Issuer discovery must remain
https://kc.coulomb.social and the token endpoint on that origin. The revised
script rejects redirects and mismatched discovery origins.
## Bounded session and proof
Generate six independent secrets in the owner-controlled session. Materialize
one uniquely named transient Kubernetes Secret and precisely six env references;
no OpenBao custody, retained consumer file, or additional persistent copy.
Merge six registrations from docs/openbao-negative-probe-clients.yaml, preserving
all existing clients byte for byte. Restart once and verify Ready and all six
client exchanges. Run the revised script with private stdin and an attended
human token; never print the stdin, claims, bearer tokens, errors or Secret.
The script inspects decoded claim shape only to establish probe isolation; this
is not signature verification. OpenBao remains the verifier. Audience, subject,
tenant, roles and scope probes must differ on exactly their designated claim;
the expiry probe must match all six bound claims and have its one-minute TTL.
The expiry token must first be accepted and successfully self-revoked, then its
JWT is presented after exp + effective leeway + 10 seconds. An unrelated claim
refusal, not-yet-valid error, generic permission denial, transport failure,
malformed response, unexpected acceptance or unconfirmed revocation fails the
proof. Existing human/coding-agent tokens may differ in several claims and prove
only their class's refusal, not isolated principal_type enforcement.
After any unexpected acceptance, successful revoke-self is required; if it
cannot be confirmed, the runner stops further presentations and the owner must abort and escalate that session to the platform owner.
The runner produces a v2 value-free receipt. all_refused means all eight refusal
checks have semantically appropriate classes and the expiry control succeeded;
there is no broad success from HTTP 400/403 alone.
Platform additionally performs sys/capabilities-self with the actual admitted
service session for both guarded KV data/metadata paths plus
`sys/policies/acl/*`, `auth/*/role/*`, and `auth/token/create`, confirms deny for
every CCR-required path, and revokes the session. No data read is needed. The existing no-fallback receipt
is retained unless the provider or consumer revision changed. These checks are
outside the negative runner and remain required by RPF-WP-0035-T02.
## Cleanup and rollback
In a finally path remove all six registrations and exactly six env references,
restore the unchanged baseline registry/config, restart and wait Ready, then
verify client_credentials for each removed client is invalid_client using the
still-private temporary values. Only then remove the transient Secret and any
private human-token file owned by this session. Confirm all six client IDs and
Secret/env refs are absent, service positive behavior and baseline deployment
health restored. Never restore a complete Deployment over concurrent changes;
compare baseline/revision and stop for owner reconciliation if they changed.
Do not destroy or rotate the production secrets-engine credential.
If interrupted after registration, the session remains incomplete until the
same cleanup is verified. A failed login's unconfirmed revocation remains a
failure in the receipt; later successful cleanup does not rewrite that history.
The exposure is at most the attended window plus the final issued JWT's TTL and
any OpenBao 5-minute session lifetime; unexpected issued sessions require
verified revocation. Cleanup evidence is mandatory before KEY-WP-0039 can finish.
## Distinct remaining acceptance
RPF-WP-0035-T02 can finish only after the eight native refusals, effective-policy
deny checks, unchanged no-fallback evidence and cleanup are retained. This does
not finish MASON-WP-0009-T05: its owner-return requirements remain live operator
and ESO token capability checks, a nonmember refusal, and ops-warden activation
of the already proposed pointer after evidence. No mint, operator policy widening
or routing activation is authorized by this proposal.
## Validation
17 runner tests pass, including malformed/ambiguous semantics, exact claim
isolation, valid expiry control, failed revoke, redirect refusal and no retained
credential values. Two semantic tests fail against the original source: it
reported success when the expiry positive control was refused for a subject
mismatch, and it classified not-yet-valid as expired. Native execution has not
been attempted by this preparation.

View file

@ -21,7 +21,7 @@ import urllib.error
import urllib.parse
import urllib.request
SCHEMA = 'keycape.openbao-negative-probe.v1'
SCHEMA = 'keycape.openbao-negative-probe.v2'
CLAIM_PROBES = [
('aud', 'openbao-probe-aud', 'openbao:login'),
('sub', 'openbao-probe-sub', 'openbao:login'),
@ -34,6 +34,11 @@ CODING_AGENT = ('coding_agent', 'codex-railiance-platform', 'openbao:login')
DEFAULT_LEEWAY = 150 # OpenBao jwt auth expiration_leeway default, seconds
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class Inconclusive(Exception):
"""The probe could not tell refusal from failure; never counted as a refusal."""
@ -48,7 +53,7 @@ def http_post(url, form=None, body=None, headers=None, timeout=15):
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:
with urllib.request.build_opener(NoRedirect()).open(req, timeout=timeout) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.read()
@ -61,7 +66,7 @@ def classify(errors_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):
if re.search(r'expired|\bexp\b', text):
return 'expired'
if re.search(r'claim|audience|\baud\b|subject|\bsub\b|bound', text):
return 'claim_mismatch'
@ -76,11 +81,51 @@ def exchange(post, token_endpoint, client_id, secret, scope):
if status != 200:
raise Inconclusive('token_exchange_failed')
try:
return json.loads(raw)['access_token']
token = json.loads(raw)['access_token']
if not isinstance(token, str) or not token:
raise ValueError
return token
except (ValueError, KeyError, TypeError):
raise Inconclusive('token_response_invalid')
REFERENCE = {'aud': 'secrets-engine-openbao', 'sub': 'service:secrets-engine',
'tenant': 'tenant:platform', 'roles': ['secrets-engine'],
'scope': 'openbao:login', 'principal_type': 'service'}
def check_claims(token, case, now):
"""Inspect claim shape for probe isolation, without asserting signature validity."""
try:
if not isinstance(token, str):
raise ValueError
payload = token.split('.')[1]
claims = json.loads(base64.urlsafe_b64decode(payload + '=' * (-len(payload) % 4)))
exp = claims['exp']
if not isinstance(exp, int) or isinstance(exp, bool) or exp <= now():
raise ValueError
if claims.get('iss') != 'https://kc.coulomb.social':
raise ValueError
if 'nbf' in claims and claims['nbf'] > now():
raise ValueError
mismatches = {key for key, expected in REFERENCE.items() if claims.get(key) != expected}
expected = set() if case == 'expired' else {case}
if case == 'principal_type':
valid = claims.get('principal_type') == 'human'
elif case == 'coding_agent':
valid = claims.get('principal_type') == 'service' and 'coding-agent' in claims.get('roles', [])
else:
valid = mismatches == expected
if case == 'expired':
issued = claims.get('iat')
valid = valid and isinstance(issued, int) and not isinstance(issued, bool) and 0 < exp - issued <= 60
if not valid:
raise ValueError
return exp
except (IndexError, ValueError, KeyError, TypeError):
raise Inconclusive('probe_claims_invalid')
def jwt_exp(token):
try:
payload = token.split('.')[1]
@ -95,6 +140,8 @@ def present(post, bao_addr, mount, role, token):
if status == 200:
try:
bao_token = json.loads(raw)['auth']['client_token']
if not isinstance(bao_token, str) or not bao_token:
raise ValueError
except (ValueError, KeyError, TypeError):
return False, 'accepted', False
rstatus, _ = post(f'{bao_addr}/v1/auth/token/revoke-self', body={},
@ -102,7 +149,10 @@ def present(post, bao_addr, mount, role, token):
return False, 'accepted', rstatus == 204
if status in (400, 403):
try:
errors = ' '.join(str(e) for e in json.loads(raw).get('errors', []))
items = json.loads(raw).get('errors', [])
if not isinstance(items, list) or not all(isinstance(e, str) for e in items):
raise ValueError
errors = ' '.join(items)
except (ValueError, AttributeError):
errors = ''
if errors:
@ -112,23 +162,35 @@ def present(post, bao_addr, mount, role, token):
def run(post, secrets, token_endpoint, bao_addr, mount, role, human_token=None,
leeway=DEFAULT_LEEWAY, sleep=time.sleep, now=time.time):
if isinstance(leeway, bool) or not isinstance(leeway, int) or leeway < 0:
raise Inconclusive('leeway_invalid')
cases = []
halted = False
def record(case, fn):
nonlocal halted
try:
if halted:
raise Inconclusive('session_aborted')
refused, cls, revoked = fn()
entry = {'case': case, 'refused': refused, 'class': cls}
expected = {'expired': {'expired'}, 'principal_type': {'not_service_principal', 'claim_mismatch'},
'coding_agent': {'not_service_principal', 'claim_mismatch'}}.get(case, {'claim_mismatch'})
semantic = refused and cls in expected
entry = {'case': case, 'refused': refused, 'class': cls, 'verified': semantic}
if revoked is not None:
entry['revoked'] = revoked
if revoked is False:
halted = True
except Inconclusive as err:
entry = {'case': case, 'refused': False, 'class': 'inconclusive', 'reason': str(err)}
entry = {'case': case, 'refused': False, 'class': 'inconclusive', 'verified': False, 'reason': str(err)}
cases.append(entry)
def via_client(client_id, scope):
def via_client(case, 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))
token = exchange(post, token_endpoint, client_id, secrets[client_id], scope)
check_claims(token, case, now)
return present(post, bao_addr, mount, role, token)
# Mint the expiry token first so its wait overlaps the other cases.
expiry_token, expiry_error = None, None
@ -136,18 +198,25 @@ def run(post, secrets, token_endpoint, bao_addr, mount, role, human_token=None,
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)
expires_at = check_claims(expiry_token, 'expired', now)
refused, cls, revoked = present(post, bao_addr, mount, role, expiry_token)
if refused or cls != 'accepted' or revoked is not True:
raise Inconclusive('expiry_positive_control_failed')
except Inconclusive as err:
expiry_error = err
halted = True
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]))
record(case, lambda c=client_id, s=scope: via_client(case, c, s))
record(CODING_AGENT[0], lambda: via_client(CODING_AGENT[0], CODING_AGENT[1], CODING_AGENT[2]))
if human_token:
record('principal_type', lambda: present(post, bao_addr, mount, role, human_token))
def principal():
check_claims(human_token, 'principal_type', now)
return present(post, bao_addr, mount, role, human_token)
record('principal_type', principal)
else:
cases.append({'case': 'principal_type', 'refused': False, 'class': 'inconclusive',
'reason': 'human_token_not_supplied'})
'verified': False, 'reason': 'human_token_not_supplied'})
def expired():
if expiry_error:
@ -164,7 +233,9 @@ def run(post, secrets, token_endpoint, bao_addr, mount, role, human_token=None,
'mount': mount,
'role': role,
'cases': cases,
'all_refused': all(c['refused'] for c in cases),
'all_refused': all(c['verified'] for c in cases),
'expiry_positive_control_verified': expiry_error is None,
'expiration_leeway_seconds': leeway,
'values_retained': False,
}
@ -179,7 +250,7 @@ def main(argv=None):
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://'):
if args.issuer.rstrip('/') != 'https://kc.coulomb.social' or not args.bao_addr.startswith('https://') or args.leeway < 0:
print(json.dumps({'schema': SCHEMA, 'error': 'https_required'}))
return 2
try:
@ -198,8 +269,12 @@ def main(argv=None):
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:
with urllib.request.build_opener(NoRedirect()).open(args.issuer.rstrip('/') + '/.well-known/openid-configuration', timeout=15) as r:
token_endpoint = json.loads(r.read())['token_endpoint']
issuer_url = urllib.parse.urlsplit(args.issuer)
endpoint_url = urllib.parse.urlsplit(token_endpoint)
if (endpoint_url.scheme, endpoint_url.netloc) != (issuer_url.scheme, issuer_url.netloc) or endpoint_url.username or endpoint_url.password:
raise ValueError
except (OSError, ValueError, KeyError):
print(json.dumps({'schema': SCHEMA, 'error': 'discovery_failed'}))
return 2

View file

@ -14,7 +14,15 @@ SECRETS = {cid: SECRET for _, cid, _ in probe.CLAIM_PROBES + [probe.EXPIRY_PROBE
def fake_jwt(client_id, exp=1000):
payload = base64.urlsafe_b64encode(json.dumps({'exp': exp, 'cid': client_id}).encode()).decode().rstrip('=')
claims = dict({'aud': 'secrets-engine-openbao', 'sub': 'service:secrets-engine', 'tenant': 'tenant:platform', 'roles': ['secrets-engine'], 'scope': 'openbao:login', 'principal_type': 'service'}, exp=exp, iat=exp-60, cid=client_id, iss='https://kc.coulomb.social')
field = client_id.removeprefix('openbao-probe-')
if field in {'aud', 'sub', 'tenant', 'roles', 'scope'}:
claims[field] = ['probe'] if field == 'roles' else 'probe'
if client_id == 'human':
claims['principal_type'] = 'human'
if client_id == 'codex-railiance-platform':
claims['roles'] = ['coding-agent']
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=')
return f'h.{payload}.s'
@ -25,6 +33,7 @@ class FakeBao:
self.accept = set(accept)
self.login_status = login_status
self.revoked = 0
self.expiry_presentations = 0
def __call__(self, url, form=None, body=None, headers=None, timeout=15):
if url == TOKEN_URL:
@ -36,7 +45,9 @@ class FakeBao:
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:
if cid == 'openbao-probe-expiry':
self.expiry_presentations += 1
if cid in self.accept or (cid == 'openbao-probe-expiry' and self.expiry_presentations == 1):
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')
@ -65,7 +76,7 @@ class ProbeTest(unittest.TestCase):
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)
self.assertEqual(fake.revoked, 2)
def test_transport_failure_is_never_a_refusal(self):
receipt = run(FakeBao(login_status=503))
@ -89,6 +100,87 @@ class ProbeTest(unittest.TestCase):
leeway=150, sleep=waits.append, now=lambda: 900)
self.assertEqual(waits, [1000 + 150 + 10 - 900])
def test_wrong_expiry_reason_is_not_evidence(self):
fake = FakeBao()
def wrong(url, **kw):
result = fake(url, **kw)
if url.endswith('/login') and fake.expiry_presentations > 1:
return 400, json.dumps({'errors': ['claim sub does not match']}).encode()
return result
receipt = run(wrong)
self.assertFalse(receipt['all_refused'])
expired = receipt['cases'][-1]
self.assertTrue(expired['refused'])
self.assertFalse(expired['verified'])
def test_generic_denial_is_not_evidence(self):
fake = FakeBao()
def deny(url, **kw):
result = fake(url, **kw)
if result[0] == 400:
return 403, b'{"errors":["permission denied"]}'
return result
self.assertFalse(run(deny)['all_refused'])
def test_expiry_requires_valid_control(self):
fake = FakeBao()
def reject_control(url, **kw):
result = fake(url, **kw)
if url.endswith('/login') and fake.expiry_presentations == 1:
return 400, b'{"errors":["claim sub mismatch"]}'
return result
receipt = run(reject_control)
self.assertFalse(receipt['all_refused'])
self.assertFalse(receipt['expiry_positive_control_verified'])
def test_unconfirmed_control_revoke_fails(self):
fake = FakeBao()
def bad_revoke(url, **kw):
if url.endswith('/revoke-self'):
return 500, b''
return fake(url, **kw)
self.assertFalse(run(bad_revoke)['all_refused'])
def test_each_probe_has_exactly_one_claim_defect(self):
for case, cid, _ in probe.CLAIM_PROBES:
self.assertEqual(probe.check_claims(fake_jwt(cid), case, lambda: 0), 1000)
with self.assertRaises(probe.Inconclusive):
probe.check_claims(fake_jwt(cid), 'expired', lambda: 0)
self.assertEqual(probe.check_claims(fake_jwt('openbao-probe-expiry'), 'expired', lambda: 0), 1000)
def test_audience_alias_breaks_probe_isolation(self):
token = fake_jwt('openbao-probe-sub')
claims = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '=='))
claims['aud'] = 'openbao-probe-sub'
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=')
with self.assertRaises(probe.Inconclusive):
probe.check_claims(f'h.{payload}.s', 'sub', lambda: 0)
def test_redirects_are_not_followed(self):
self.assertIsNone(probe.NoRedirect().redirect_request(None, None, 302, '', {}, 'https://other.example'))
def test_not_yet_valid_does_not_count_as_expired(self):
self.assertNotEqual(probe.classify('token not valid yet: nbf'), 'expired')
def test_malformed_refusal_is_inconclusive(self):
fake = FakeBao()
def malformed(url, **kw):
result = fake(url, **kw)
if result[0] == 400:
return 400, b'{"errors":null}'
return result
receipt = run(malformed)
self.assertFalse(receipt['all_refused'])
self.assertTrue(all(c['class'] == 'inconclusive' for c in receipt['cases'] if c['case'] != 'expired'))
def test_expiry_client_ttl_is_bounded(self):
token = fake_jwt('openbao-probe-expiry')
claims = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '=='))
claims['iat'] = claims['exp'] - 900
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=')
with self.assertRaises(probe.Inconclusive):
probe.check_claims(f'h.{payload}.s', 'expired', lambda: 0)
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')

View file

@ -9,7 +9,7 @@ flavor: implementation
owner: claude-code
topic_slug: netkingdom
created: "2026-10-10"
updated: "2026-10-10"
updated: "2026-10-11"
depends_on: []
related_repos:
- railiance-platform
@ -121,3 +121,19 @@ a value-free receipt in `docs/evidence/`, and deregister the clients afterwards.
Acceptance: every case is refused, the receipt is committed, and the probe clients
are gone.
2026-10-11 source preparation: T01 remains progress because the exact expiry
client and audience-pin amendments require platform-owner re-agreement; the
prior agreement covered the earlier case list, not this revised packet. The
prepared runner revision adds a valid expiry positive control and immediate
revoke, exact decoded claim-isolation preflight (not signature evidence),
semantic refusal classes, bounded expiry TTL and redirect refusal. Seventeen
local tests pass; original-source semantic regressions demonstrate two false
successes. See `docs/openbao-negative-probe-session-proposal.md` for exact owner
returns, live authority, effective leeway, custody boundaries and cleanup. No
live token, registry or OpenBao action occurred. T02 continues to wait for owner
re-agreement plus separate attended live authorization. MASON-WP-0009-T05
operator/ESO capability and nonmember checks remain independent acceptance.
2026-10-11: Platform independently re-agreed the final amended design (session proposal SHA25623f13d2a5a4204f749f35c5d44ef39d213838cd2fce1c36fb4775254ef1c7f09). Original conditional review and narrow confirmation are retained in railiance-platform/docs/reviews/2026-10-11-keycape-openbao-negative-session-owner-review*. Source acceptance T01 is complete; actual registry/leeway/readiness, founder live go, negative/cleanup and separate capability/no-fallback obligations remain T02 and owner plans.