feat: accept KeyCape approval clients with tested recovery
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
ce826cfa8d
commit
303a584bd0
8 changed files with 810 additions and 20 deletions
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue