2026-09-15 20:38:47 +02:00
|
|
|
"""Silent create-only token-exchange proof; no sitting POST."""
|
|
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import stat
|
2026-09-15 20:51:28 +02:00
|
|
|
from datetime import datetime, timezone
|
2026-09-15 20:38:47 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from urllib.error import HTTPError
|
|
|
|
|
from urllib.parse import urlencode
|
|
|
|
|
from urllib.request import ProxyHandler, Request, build_opener, HTTPRedirectHandler
|
|
|
|
|
import jwt
|
|
|
|
|
|
|
|
|
|
ROOT = Path('/home/worsch/railiance-platform')
|
|
|
|
|
RECEIPT = ROOT / 'docs/evidence/2026-09-15-sitting-requester-exchange.json'
|
|
|
|
|
POLICY = 'workload-kv-read-informed-decision-sitting-requester-client'
|
|
|
|
|
KV = 'platform/data/workloads/informed-decision/sitting-requester'
|
|
|
|
|
SIBLING = 'platform/data/workloads/secrets-engine/approval-requester'
|
|
|
|
|
PARENT = 'platform/metadata/workloads/informed-decision'
|
|
|
|
|
ISSUER = 'https://kc.coulomb.social'
|
|
|
|
|
CLIENT_ID = 'informed-decision-sitting-requester'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NoRedirect(HTTPRedirectHandler):
|
|
|
|
|
def redirect_request(self, *args, **kwargs):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 20:51:28 +02:00
|
|
|
class Refused(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 20:38:47 +02:00
|
|
|
def http(url, *, body=None, headers=None):
|
|
|
|
|
req = Request(url, data=body, headers=headers or {})
|
|
|
|
|
try:
|
|
|
|
|
with build_opener(ProxyHandler({}), NoRedirect()).open(req, timeout=20) as response:
|
|
|
|
|
content = response.read(1048577)
|
|
|
|
|
status = response.status
|
|
|
|
|
except HTTPError as error:
|
|
|
|
|
status = error.code
|
|
|
|
|
content = error.read(1048577)
|
|
|
|
|
error.close()
|
|
|
|
|
if len(content) > 1048576:
|
2026-09-15 20:51:28 +02:00
|
|
|
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')
|
2026-09-15 20:38:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def bao(*args):
|
|
|
|
|
import subprocess
|
|
|
|
|
p = subprocess.run(['bao', *args], capture_output=True, text=True, timeout=20)
|
|
|
|
|
if p.returncode:
|
2026-09-15 20:51:28 +02:00
|
|
|
raise Refused('metadata_failed')
|
2026-09-15 20:38:47 +02:00
|
|
|
return json.loads(p.stdout)
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 20:51:28 +02:00
|
|
|
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'})
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 20:38:47 +02:00
|
|
|
def check_identity(data):
|
|
|
|
|
policies = set(data.get('policies', [])) | set(data.get('identity_policies', []))
|
2026-09-15 20:51:28 +02:00
|
|
|
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
|
2026-09-15 20:38:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(receipt):
|
|
|
|
|
if Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'):
|
2026-09-15 20:51:28 +02:00
|
|
|
raise Refused('attended_reader_required')
|
|
|
|
|
receipt['phase'] = 'envelope_ok'
|
2026-09-15 20:38:47 +02:00
|
|
|
check_identity(bao('token', 'lookup', '-format=json')['data'])
|
2026-09-15 20:51:28 +02:00
|
|
|
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'
|
2026-09-15 20:38:47 +02:00
|
|
|
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():
|
2026-09-15 20:51:28 +02:00
|
|
|
raise Refused('private_helper_required')
|
|
|
|
|
secret = secret_from_read(bao('read', '-format=json', KV))
|
|
|
|
|
receipt['phase'] = 'secret_present'
|
|
|
|
|
receipt['secret_present'] = True
|
2026-09-15 20:38:47 +02:00
|
|
|
|
|
|
|
|
def exchange(scope, credential=secret):
|
|
|
|
|
auth = base64.b64encode((CLIENT_ID + ':' + credential).encode()).decode()
|
|
|
|
|
return http(ISSUER + '/token', body=urlencode({'grant_type': 'client_credentials', 'scope': scope}).encode(),
|
|
|
|
|
headers={'Authorization': 'Basic ' + auth, 'Content-Type': 'application/x-www-form-urlencoded'})
|
|
|
|
|
|
|
|
|
|
status, tokens = exchange('approval:create')
|
2026-09-15 20:51:28 +02:00
|
|
|
if status != 200 or 'access_token' not in tokens:
|
|
|
|
|
raise Refused('requester_exchange_failed')
|
|
|
|
|
receipt['phase'] = 'exchange_ok'
|
2026-09-15 20:38:47 +02:00
|
|
|
token = tokens['access_token']
|
|
|
|
|
status, jwks = http(ISSUER + '/jwks')
|
|
|
|
|
if status != 200:
|
2026-09-15 20:51:28 +02:00
|
|
|
raise Refused('jwks_failed')
|
2026-09-15 20:38:47 +02:00
|
|
|
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:
|
2026-09-15 20:51:28 +02:00
|
|
|
raise Refused('signing_key_failed')
|
2026-09-15 20:38:47 +02:00
|
|
|
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']})
|
2026-09-15 20:51:28 +02:00
|
|
|
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'
|
2026-09-15 20:38:47 +02:00
|
|
|
for scope in ('approval:approve', 'approval:consume', 'approval:read'):
|
|
|
|
|
if exchange(scope)[0] != 400:
|
2026-09-15 20:51:28 +02:00
|
|
|
raise Refused('excess_scope_not_refused')
|
2026-09-15 20:38:47 +02:00
|
|
|
if exchange('approval:create', 'invalid-synthetic-credential')[0] != 401:
|
2026-09-15 20:51:28 +02:00
|
|
|
raise Refused('wrong_secret_not_refused')
|
2026-09-15 20:38:47 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
if RECEIPT.exists():
|
2026-09-15 20:51:28 +02:00
|
|
|
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(),
|
2026-09-15 20:38:47 +02:00
|
|
|
'status': 'failed', 'phase': 'preflight', 'credential_values_emitted': False, 'sitting_post': False}
|
|
|
|
|
try:
|
|
|
|
|
main(receipt)
|
2026-09-15 20:51:28 +02:00
|
|
|
except Exception as error:
|
|
|
|
|
receipt['failure'] = str(error) if isinstance(error, Refused) else 'contained_operation_failed'
|
2026-09-15 20:38:47 +02:00
|
|
|
raise SystemExit(1) from None
|
|
|
|
|
finally:
|
|
|
|
|
RECEIPT.write_text(json.dumps(receipt, indent=2) + '\n')
|