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:
parent
f97f006ede
commit
aef56bcba1
4 changed files with 322 additions and 22 deletions
117
docs/openbao-negative-probe-session-proposal.md
Normal file
117
docs/openbao-negative-probe-session-proposal.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue