diff --git a/scripts/prove-sitting-requester-exchange.py b/scripts/prove-sitting-requester-exchange.py index 9eba58d..c98f1a6 100644 --- a/scripts/prove-sitting-requester-exchange.py +++ b/scripts/prove-sitting-requester-exchange.py @@ -3,6 +3,7 @@ import base64 import json import os import stat +from datetime import datetime, timezone from pathlib import Path from urllib.error import HTTPError from urllib.parse import urlencode @@ -24,6 +25,10 @@ class NoRedirect(HTTPRedirectHandler): return None +class Refused(Exception): + pass + + def http(url, *, body=None, headers=None): req = Request(url, data=body, headers=headers or {}) try: @@ -35,44 +40,72 @@ def http(url, *, body=None, headers=None): content = error.read(1048577) error.close() if len(content) > 1048576: - raise ValueError('response_too_large') - return status, json.loads(content) if content else {} + raise Refused('response_too_large') + if not content: + return status, {} + try: + return status, json.loads(content) + except json.JSONDecodeError: + raise Refused('non_json_http_response') def bao(*args): import subprocess p = subprocess.run(['bao', *args], capture_output=True, text=True, timeout=20) if p.returncode: - raise ValueError('metadata_failed') + raise Refused('metadata_failed') return json.loads(p.stdout) +def capabilities(result): + if isinstance(result, dict): + result = result.get('data', result).get('capabilities', result) + return result if isinstance(result, list) else [] + + +def is_deny(result): + caps = set(capabilities(result)) + return caps <= {'deny'} or not caps.intersection({'read', 'create', 'update', 'delete', 'list', 'patch', 'sudo'}) + + def check_identity(data): policies = set(data.get('policies', [])) | set(data.get('identity_policies', [])) - if POLICY not in policies or policies - {POLICY, 'default'} or not data.get('entity_id') or not 0 < data.get('ttl', 0) <= 900: - raise ValueError('reader_identity_failed') + if POLICY not in policies or policies - {POLICY, 'default'}: + raise Refused('reader_identity_failed') + if not data.get('entity_id') or not 0 < int(data.get('ttl') or 0) <= 900: + raise Refused('reader_identity_failed') + + +def secret_from_read(payload): + body = payload.get('data', payload) + inner = body.get('data', body) + version = (body.get('metadata') or {}).get('version') + secret = inner.get('CLIENT_SECRET') if isinstance(inner, dict) else None + if version != 1 or not secret: + raise Refused('requester_delivery_failed') + return secret def main(receipt): if Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'): - raise ValueError('attended_reader_required') + raise Refused('attended_reader_required') + receipt['phase'] = 'envelope_ok' check_identity(bao('token', 'lookup', '-format=json')['data']) - for path, expected in ((KV, ['read']), (SIBLING, ['deny']), (PARENT, ['deny'])): - result = bao('token', 'capabilities', '-format=json', path) - if isinstance(result, dict): - result = result.get('data', result).get('capabilities', result) - if result != expected: - raise ValueError('reader_scope_failed') + receipt['phase'] = 'identity_ok' + if capabilities(bao('token', 'capabilities', '-format=json', KV)) != ['read']: + raise Refused('reader_scope_failed:kv') + if not is_deny(bao('token', 'capabilities', '-format=json', SIBLING)): + raise Refused('reader_scope_failed:sibling') + if not is_deny(bao('token', 'capabilities', '-format=json', PARENT)): + raise Refused('reader_scope_failed:parent') + receipt['phase'] = 'scope_ok' helper = Path.home() / '.vault-token' info = helper.lstat() if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or info.st_uid != os.getuid(): - raise ValueError('private_helper_required') - addr = os.environ.get('BAO_ADDR', 'http://127.0.0.1:18200').rstrip('/') - status, data = http(addr + '/v1/' + KV + '?version=1', headers={'X-Vault-Token': helper.read_text().strip()}) - if status != 200 or data['data']['metadata']['version'] != 1: - raise ValueError('requester_delivery_failed') - secret = data['data']['data']['CLIENT_SECRET'] - del data + raise Refused('private_helper_required') + secret = secret_from_read(bao('read', '-format=json', KV)) + receipt['phase'] = 'secret_present' + receipt['secret_present'] = True def exchange(scope, credential=secret): auth = base64.b64encode((CLIENT_ID + ':' + credential).encode()).decode() @@ -80,27 +113,42 @@ def main(receipt): headers={'Authorization': 'Basic ' + auth, 'Content-Type': 'application/x-www-form-urlencoded'}) status, tokens = exchange('approval:create') - if status != 200: - raise ValueError('requester_exchange_failed') + if status != 200 or 'access_token' not in tokens: + raise Refused('requester_exchange_failed') + receipt['phase'] = 'exchange_ok' token = tokens['access_token'] status, jwks = http(ISSUER + '/jwks') if status != 200: - raise ValueError('jwks_failed') + raise Refused('jwks_failed') header = jwt.get_unverified_header(token) keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')] if header.get('alg') != 'RS256' or len(keys) != 1: - raise ValueError('signing_key_failed') + raise Refused('signing_key_failed') claims = jwt.decode(token, jwt.PyJWK.from_dict(keys[0]).key, algorithms=['RS256'], issuer=ISSUER, audience='approval-engine', options={'strict_aud': True, 'require': ['sub', 'iat', 'exp', 'iss', 'aud']}) - expected = {'sub': 'informed-decision', 'tenant': 'tenant:platform', 'principal_type': 'service', - 'scope': 'approval:create', 'roles': ['informed-decision-sitting-requester'], 'groups': []} - if any(claims.get(key) != value for key, value in expected.items()) or claims['exp'] - claims['iat'] != 900: - raise ValueError('requester_claims_failed') + roles = claims.get('roles') + if isinstance(roles, str): + roles = [roles] + if claims.get('sub') != 'informed-decision': + raise Refused('requester_claims_failed:sub') + if claims.get('tenant') != 'tenant:platform': + raise Refused('requester_claims_failed:tenant') + if claims.get('principal_type') != 'service': + raise Refused('requester_claims_failed:principal_type') + if claims.get('scope') != 'approval:create': + raise Refused('requester_claims_failed:scope') + if roles != ['informed-decision-sitting-requester']: + raise Refused('requester_claims_failed:roles') + if claims.get('groups') not in (None, []): + raise Refused('requester_claims_failed:groups') + if int(claims['exp']) - int(claims['iat']) != 900: + raise Refused('requester_claims_failed:ttl') + receipt['phase'] = 'claims_ok' for scope in ('approval:approve', 'approval:consume', 'approval:read'): if exchange(scope)[0] != 400: - raise ValueError('excess_scope_not_refused') + raise Refused('excess_scope_not_refused') if exchange('approval:create', 'invalid-synthetic-credential')[0] != 401: - raise ValueError('wrong_secret_not_refused') + raise Refused('wrong_secret_not_refused') receipt.update(status='verified', phase='requester_verified', signature_verified=True, excess_scopes_refused=True, wrong_secret_refused=True, reader_scope_verified=True, sitting_post=False) @@ -108,12 +156,18 @@ def main(receipt): if __name__ == '__main__': if RECEIPT.exists(): - raise SystemExit(1) - receipt = {'observed_at': __import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat(), + try: + existing = json.loads(RECEIPT.read_text()) + except Exception: + raise SystemExit(1) + if existing.get('status') == 'verified': + raise SystemExit(1) + receipt = {'observed_at': datetime.now(timezone.utc).isoformat(), 'status': 'failed', 'phase': 'preflight', 'credential_values_emitted': False, 'sitting_post': False} try: main(receipt) - except Exception: + except Exception as error: + receipt['failure'] = str(error) if isinstance(error, Refused) else 'contained_operation_failed' raise SystemExit(1) from None finally: RECEIPT.write_text(json.dumps(receipt, indent=2) + '\n') diff --git a/tests/test_sitting_requester_provisioning.py b/tests/test_sitting_requester_provisioning.py index c2dfd1b..bf88d64 100644 --- a/tests/test_sitting_requester_provisioning.py +++ b/tests/test_sitting_requester_provisioning.py @@ -45,6 +45,10 @@ class SittingRequesterExchangeTests(unittest.TestCase): self.assertEqual(exchange.CLIENT_ID, 'informed-decision-sitting-requester') self.assertEqual(exchange.POLICY, 'workload-kv-read-informed-decision-sitting-requester-client') self.assertIn('approval-requester', exchange.SIBLING) + self.assertTrue(exchange.is_deny(['deny'])) + self.assertTrue(exchange.is_deny([])) + self.assertFalse(exchange.is_deny(['read'])) + self.assertEqual(exchange.capabilities({'data': {'capabilities': ['read']}}), ['read']) if __name__ == '__main__': diff --git a/workplans/RPF-WP-0042-informed-decision-sitting-requester.md b/workplans/RPF-WP-0042-informed-decision-sitting-requester.md index 06158e5..4ed10b7 100644 --- a/workplans/RPF-WP-0042-informed-decision-sitting-requester.md +++ b/workplans/RPF-WP-0042-informed-decision-sitting-requester.md @@ -61,3 +61,8 @@ applied. No sitting POST. Remaining: create-only token-exchange proof Exchange proof uses reader lane `informed-decision-sitting-requester-login` and `scripts/prove-sitting-requester-exchange.sh`. It does not POST sittings. + +2026-09-15T18:47Z reader login reached a helper-backed session; the child +failed at `preflight` with no failure class. A first browser sign-in did not +complete. Retry records a named failure class, reads KV through `bao`, and +overwrites a failed receipt only.