feat: accept KeyCape approval clients with tested recovery
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 02:18:12 +02:00
parent ce826cfa8d
commit 303a584bd0
8 changed files with 810 additions and 20 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import argparse
import base64
import copy
import hashlib
import importlib.util
import json
import os
@ -26,11 +27,18 @@ spec = importlib.util.spec_from_file_location('pin', Path(__file__).with_name('o
pin = importlib.util.module_from_spec(spec); spec.loader.exec_module(pin)
ISSUER = 'https://kc.coulomb.social'
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611'
VERIFIER = Path('/home/worsch/.local/share/key-cape/verified-bin/dcebd46/keycape')
VERIFIER_SHA256 = '4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92'
PRIOR_IMAGE = 'forgejo.coulomb.social/coulomb/key-cape:main-153258b'
IDS = ('secrets-engine-approval', 'approval-engine-operator')
SECRET_NAMES = ('keycape-secrets-engine-approval-client', 'keycape-approval-engine-operator-client')
def verify_artifact():
require(VERIFIER.is_file() and not VERIFIER.is_symlink()
and hashlib.sha256(VERIFIER.read_bytes()).hexdigest() == VERIFIER_SHA256, 'pinned_verifier_artifact_required')
def registrations():
source = yaml.safe_load((KEYCAPE / 'config/service-clients.example.yaml').read_text())['clients']
result = [next(c for c in source if c['clientId'] == name) for name in IDS]
@ -115,7 +123,9 @@ def ready(kube, image, timeout=150):
podlist = data(command(kube + ['-n', 'sso', 'get', 'pods', '-l', 'app.kubernetes.io/name=keycape', '-o', 'json']))['items']
if len(podlist) == 1 and not podlist[0]['metadata'].get('deletionTimestamp'):
containers = podlist[0].get('status', {}).get('containerStatuses', [])
if any(c['name'] == 'keycape' and c['ready'] and c['image'] == image for c in containers):
if any(c['name'] == 'keycape' and c['ready'] and
(c.get('imageID', '').removeprefix('docker-pullable://') == image
if '@sha256:' in image else c.get('image') == image) for c in containers):
return {'deployment_uid': dep['metadata']['uid'], 'generation': dep['metadata']['generation'],
'pod_uid': podlist[0]['metadata']['uid'], 'single_ready_replica': True}
time.sleep(3)
@ -141,7 +151,10 @@ def http(path, fields=None, credentials=None):
with opener.open(request, timeout=20) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, json.load(error)
try:
return error.code, json.load(error)
except (ValueError, TypeError):
raise LaneError('http_' + str(error.code) + '_non_json_error') from None
def denied(result, status, feature):
@ -150,53 +163,79 @@ def denied(result, status, feature):
and body.get('feature') == feature, 'keycape_denial_inconclusive')
def verified_claims(token, public_key):
# Match authclient/client.go: tolerate at most 30s future iat, but no
# extension to expiry or not-before. PyJWT's global leeway would extend both.
claims = jwt.decode(token, public_key, algorithms=['RS256'], issuer=ISSUER, audience='approval-engine',
options={'verify_iat': False, 'require': ['exp', 'iat', 'sub', 'iss', 'aud']})
require(type(claims['iat']) is int and type(claims['exp']) is int
and claims['iat'] <= int(time.time()) + 30 and claims['iat'] < claims['exp'], 'issued_at_binding_failed')
return claims
def acceptance(kube, receipt):
receipt['acceptance_phase'] = 'discovery'
code, discovery = http('/.well-known/openid-configuration')
require(code == 200 and discovery['issuer'] == ISSUER
and discovery['token_endpoint'] == ISSUER + '/token'
and discovery['jwks_uri'].startswith(ISSUER + '/'), 'discovery_binding_mismatch')
receipt['acceptance_phase'] = 'jwks'
code, jwks = http(discovery['jwks_uri'].removeprefix(ISSUER))
require(code == 200, 'jwks_failed')
receipt['clients'] = []
for client, secret_name in zip(registrations(), SECRET_NAMES):
receipt['acceptance_client'] = client['clientId']
receipt['acceptance_phase'] = 'verifier_secret_read'
secret = get(kube, 'secret', secret_name)
credential = base64.b64decode(secret['data']['client-secret'], validate=True).decode()
scopes = ' '.join(client['allowedScopes'])
receipt['acceptance_phase'] = 'token_exchange'
result, response = http('/token', {'grant_type': 'client_credentials', 'scope': scopes}, (client['clientId'], credential))
require(result == 200 and response.get('token_type') == 'Bearer' and response.get('expires_in') == 900, 'service_issuance_failed')
receipt['acceptance_phase'] = 'signature_and_claims'
token = response['access_token']; header = jwt.get_unverified_header(token)
keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')]
require(header.get('alg') == 'RS256' and len(keys) == 1, 'signing_key_selection_failed')
claims = jwt.decode(token, jwt.PyJWK.from_dict(keys[0]).key, algorithms=['RS256'],
issuer=ISSUER, audience='approval-engine',
options={'require': ['exp', 'iat', 'sub', 'iss', 'aud']})
claims = verified_claims(token, jwt.PyJWK.from_dict(keys[0]).key)
require(claims['sub'] == client['serviceSubject'] and claims['tenant'] == 'tenant:platform'
and claims['aud'] == 'approval-engine'
and claims['roles'] == client['roles'] and claims['exp'] - claims['iat'] == 900
and claims['principal_type'] == 'service'
and set(claims['scope'].split()) == set(client['allowedScopes']), 'exact_claims_mismatch')
excessive = 'approval:approve' if client['clientId'] == IDS[0] else 'approval:consume'
receipt['acceptance_phase'] = 'excess_scope_denial'
denied(http('/token', {'grant_type': 'client_credentials', 'scope': excessive},
(client['clientId'], credential)), 400, 'scope')
receipt['acceptance_phase'] = 'wrong_secret_denial'
denied(http('/token', {'grant_type': 'client_credentials', 'scope': scopes},
(client['clientId'], secrets.token_urlsafe(48))), 401, 'Authorization')
args = kube + ['-n', 'sso', 'exec', 'deployment/keycape', '--', '/keycape', 'verify-client',
args = [str(VERIFIER), 'verify-client',
'-issuer', ISSUER, '-client-id', client['clientId'], '-audience', 'approval-engine', '-scope', scopes,
'-secret-env', client['secretRef'].removeprefix('env:'), '-expect-subject', client['serviceSubject'],
'-expect-tenant', 'tenant:platform', '-expect-roles', ','.join(client['roles']), '-deny-scope', excessive]
command(args)
receipt['acceptance_phase'] = 'pinned_artifact_verifier'
verify_artifact()
verifier_env = os.environ.copy()
verifier_env[client['secretRef'].removeprefix('env:')] = credential
command(args, env=verifier_env)
receipt['clients'].append({'client_id': client['clientId'], 'live_jwks_signature_verified': True,
'exact_claims_verified': True, 'lifetime_seconds': 900, 'excess_scope_denied': True,
'wrong_secret_denied': True, 'pod_verify_client_passed': True,
'exact_claims_verified': True, 'lifetime_seconds': 900, 'maximum_future_iat_seconds': 30, 'expiry_leeway_seconds': 0, 'excess_scope_denied': True,
'wrong_secret_denied': True, 'pinned_artifact_verifier_passed': True,
'verifier_location': 'attended owner process', 'verifier_sha256': VERIFIER_SHA256,
'real_predecessor_rotation_tested': False, 'observed_wall_clock_expiry': False})
receipt['acceptance_phase'] = 'human_consume_denial'
query = urllib.parse.urlencode({'client_id': 'openbao-admin', 'response_type': 'code',
'redirect_uri': 'http://localhost:8250/oidc/callback', 'scope': 'openid approval:consume',
'code_challenge_method': 'S256', 'code_challenge': 'A' * 43, 'state': secrets.token_urlsafe(32)})
denied(http('/authorize?' + query), 400, 'scope')
receipt['human_client_consume_denied'] = True
receipt['acceptance_phase'] = 'passed'
def rollout(kube, receipt, recovery_path):
assert_cluster(kube)
verify_artifact()
before = get(kube, 'secret', 'keycape-config')
deployment = get(kube, 'deployment', 'keycape')
require(before['metadata']['uid'] == '2e94519d-1550-41c7-9701-2efe47fe1fd3'
@ -223,7 +262,8 @@ def rollout(kube, receipt, recovery_path):
receipt.update(status='service_acceptance_passed_pending_fresh_human_login', image=IMAGE,
config_resource_version=after['metadata']['resourceVersion'], signing_key_unchanged=True,
unrelated_config_bytes_preserved=True, existing_human_login_after=False)
except Exception:
except Exception as failure:
receipt['failure_class'] = type(failure).__name__
# Read after uncertain API outcomes too; never assume a timeout means no write.
now = get(kube, 'secret', 'keycape-config')
require(now['metadata']['uid'] == before['metadata']['uid'], 'rollback_config_identity_drift')