feat(oidc): prepare one-shot upstream issuer proof without token disclosure
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
5c7db26b7c
commit
6f33abddcf
7 changed files with 836 additions and 0 deletions
58
scripts/collect-upstream-issuer-proof.py
Normal file
58
scripts/collect-upstream-issuer-proof.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read bounded probe logs on stdin and emit only a validated metadata receipt."""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
ISSUERS = {'https://auth.coulomb.social', 'http://auth.coulomb.social',
|
||||
'http://authelia.sso.svc.cluster.local:9091'}
|
||||
FAILURES = {'authorization_callback_refused', 'token_exchange_error', 'token_exchange_refused',
|
||||
'token_response_invalid', 'id_token_verification_error', 'issuer_outside_reviewed_set',
|
||||
'id_token_issuer_mismatch', 'id_token_audience_mismatch', 'id_token_expired',
|
||||
'id_token_validity_window', 'id_token_signature', 'provider_metadata_unavailable',
|
||||
'provider_keys_unavailable', 'id_token_nonce_mismatch', 'probe_deadline', 'probe_listener_stopped'}
|
||||
|
||||
|
||||
def sanitize(raw):
|
||||
if len(raw) > 8192:
|
||||
raise ValueError()
|
||||
lines = raw.splitlines()
|
||||
if not lines:
|
||||
raise ValueError()
|
||||
data = json.loads(lines[0])
|
||||
if not isinstance(data, dict) or data.get('schema') != 'keycape.upstream-issuer-proof.v1':
|
||||
raise ValueError()
|
||||
if data.get('tokens_retained') is not False or data.get('downstream_credential_issued') is not False:
|
||||
raise ValueError()
|
||||
keys = {'schema', 'status', 'tokens_retained', 'downstream_credential_issued'}
|
||||
if 'observed_at' in data:
|
||||
if not isinstance(data['observed_at'], str) or not re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', data['observed_at']):
|
||||
raise ValueError()
|
||||
keys.add('observed_at')
|
||||
if data.get('status') == 'verified':
|
||||
if len(lines) != 1 or data.get('issuer') not in ISSUERS or 'observed_at' not in data:
|
||||
raise ValueError()
|
||||
keys.add('issuer')
|
||||
for field in ['signature_verified', 'audience_verified', 'validity_window_verified', 'nonce_verified']:
|
||||
if data.get(field) is not True:
|
||||
raise ValueError()
|
||||
keys.add(field)
|
||||
elif data.get('status') == 'failed' and data.get('failure') in FAILURES:
|
||||
if len(lines) > 2 or (len(lines) == 2 and lines[1] != 'issuer proof failed'):
|
||||
raise ValueError()
|
||||
keys.add('failure')
|
||||
else:
|
||||
raise ValueError()
|
||||
if set(data) != keys:
|
||||
raise ValueError()
|
||||
return data
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
result = sanitize(sys.stdin.read(8193))
|
||||
except Exception:
|
||||
print('No valid metadata-only issuer proof receipt.', file=sys.stderr)
|
||||
raise SystemExit(1) from None
|
||||
print(json.dumps(result, indent=2))
|
||||
raise SystemExit(0 if result['status'] == 'verified' else 1)
|
||||
117
scripts/render-upstream-issuer-probe.py
Normal file
117
scripts/render-upstream-issuer-probe.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render a bounded, metadata-only issuer probe for NetKingdom review.
|
||||
|
||||
No cluster mutation or secret lookup. The deployment owner creates the Job
|
||||
first, then re-renders children with its real UID before creating them. This
|
||||
makes every temporary route/policy/service follow the Job's cleanup lifetime.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_RE = re.compile(r'forgejo\.coulomb\.social/coulomb/key-cape@sha256:[0-9a-f]{64}')
|
||||
ISSUERS = ['https://auth.coulomb.social', 'http://auth.coulomb.social',
|
||||
'http://authelia.sso.svc.cluster.local:9091']
|
||||
|
||||
|
||||
def render(image, state, job_uid=None):
|
||||
if not IMAGE_RE.fullmatch(image):
|
||||
raise ValueError('an immutable KeyCape image digest is required')
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]{43}', state):
|
||||
raise ValueError('invalid state')
|
||||
decoded = base64.urlsafe_b64decode(state + '=')
|
||||
if len(decoded) != 32 or base64.urlsafe_b64encode(decoded).decode().rstrip('=') != state:
|
||||
raise ValueError('noncanonical state')
|
||||
if job_uid is not None and not re.fullmatch(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', job_uid):
|
||||
raise ValueError('invalid Job UID')
|
||||
name = 'keycape-issuer-proof-' + hashlib.sha256(state.encode()).hexdigest()[:12]
|
||||
labels = {'keycape.coulomb.social/issuer-proof': name}
|
||||
peer = {'podSelector': {'matchLabels': labels}}
|
||||
metadata = {'name': name, 'namespace': 'sso', 'labels': labels}
|
||||
args = ['probe-upstream-issuer', '--config=/etc/keycape/config.yaml',
|
||||
'--listen=:8081', '--lifetime=10m', '--allowed-issuers=' + ','.join(ISSUERS)]
|
||||
job = {
|
||||
'apiVersion': 'batch/v1', 'kind': 'Job', 'metadata': dict(metadata),
|
||||
'spec': {'backoffLimit': 0, 'activeDeadlineSeconds': 600, 'ttlSecondsAfterFinished': 300,
|
||||
'template': {'metadata': {'labels': labels}, 'spec': {
|
||||
'restartPolicy': 'Never', 'automountServiceAccountToken': False,
|
||||
'securityContext': {'runAsNonRoot': True, 'runAsUser': 65534, 'runAsGroup': 65534,
|
||||
'fsGroup': 65534, 'seccompProfile': {'type': 'RuntimeDefault'}},
|
||||
'containers': [{'name': 'probe', 'image': image, 'args': args,
|
||||
'env': [{'name': 'KEYCAPE_ISSUER_PROBE_STATE', 'value': state}],
|
||||
'ports': [{'name': 'probe', 'containerPort': 8081}],
|
||||
'securityContext': {'allowPrivilegeEscalation': False,
|
||||
'readOnlyRootFilesystem': True,
|
||||
'capabilities': {'drop': ['ALL']}},
|
||||
'resources': {'requests': {'cpu': '25m', 'memory': '32Mi'},
|
||||
'limits': {'cpu': '200m', 'memory': '128Mi'}},
|
||||
'readinessProbe': {'httpGet': {'path': '/healthz', 'port': 8081},
|
||||
'periodSeconds': 2},
|
||||
'volumeMounts': [{'name': 'config', 'mountPath': '/etc/keycape', 'readOnly': True}]}],
|
||||
'volumes': [{'name': 'config', 'secret': {'secretName': 'keycape-config',
|
||||
'items': [{'key': 'config.yaml', 'path': 'config.yaml', 'mode': 288}]}}]
|
||||
}}}}
|
||||
children_metadata = dict(metadata)
|
||||
if job_uid:
|
||||
children_metadata['ownerReferences'] = [{'apiVersion': 'batch/v1', 'kind': 'Job',
|
||||
'name': name, 'uid': job_uid, 'blockOwnerDeletion': False}]
|
||||
# Backticks belong to Traefik's rule language, not shell execution.
|
||||
rule = ('Host(`kc.coulomb.social`) && (Path(`/upstream-issuer-proof/' + state +
|
||||
'`) || (Path(`/authorize/callback`) && Query(`state`, `' + state + '`)))')
|
||||
children = [
|
||||
{'apiVersion': 'v1', 'kind': 'Service', 'metadata': dict(children_metadata),
|
||||
'spec': {'selector': labels, 'ports': [{'port': 8081, 'targetPort': 8081}]}},
|
||||
{'apiVersion': 'traefik.io/v1alpha1', 'kind': 'IngressRoute', 'metadata': dict(children_metadata),
|
||||
'spec': {'entryPoints': ['websecure'], 'tls': {'secretName': 'kc-tls'},
|
||||
'routes': [{'kind': 'Rule', 'match': rule, 'priority': 10000,
|
||||
'middlewares': [{'name': 'keycape-rate-limit'}, {'name': 'keycape-hsts'}],
|
||||
'observability': {'accessLogs': False, 'metrics': False, 'tracing': False},
|
||||
'services': [{'name': name, 'port': 8081}]}]}},
|
||||
{'apiVersion': 'networking.k8s.io/v1', 'kind': 'NetworkPolicy', 'metadata': dict(children_metadata),
|
||||
'spec': {'podSelector': {'matchLabels': labels}, 'policyTypes': ['Ingress', 'Egress'],
|
||||
'ingress': [{'from': [{'namespaceSelector': {'matchLabels': {'kubernetes.io/metadata.name': 'kube-system'}},
|
||||
'podSelector': {'matchLabels': {'app.kubernetes.io/name': 'traefik'}}}],
|
||||
'ports': [{'port': 8081, 'protocol': 'TCP'}]}],
|
||||
'egress': [{'to': [{'podSelector': {'matchLabels': {'app.kubernetes.io/name': 'authelia'}}}],
|
||||
'ports': [{'port': 9091, 'protocol': 'TCP'}]},
|
||||
{'to': [{'namespaceSelector': {'matchLabels': {'kubernetes.io/metadata.name': 'kube-system'}},
|
||||
'podSelector': {'matchLabels': {'k8s-app': 'kube-dns'}}}],
|
||||
'ports': [{'port': 53, 'protocol': protocol} for protocol in ['TCP', 'UDP']]}]}},
|
||||
{'apiVersion': 'networking.k8s.io/v1', 'kind': 'NetworkPolicy',
|
||||
'metadata': dict(children_metadata, name=name + '-authelia'),
|
||||
'spec': {'podSelector': {'matchLabels': {'app.kubernetes.io/name': 'authelia'}},
|
||||
'policyTypes': ['Ingress'], 'ingress': [{'from': [peer], 'ports': [{'port': 9091, 'protocol': 'TCP'}]}]}}
|
||||
]
|
||||
return {'name': name, 'start_url': 'https://kc.coulomb.social/upstream-issuer-proof/' + state,
|
||||
'job': job, 'children': {'apiVersion': 'v1', 'kind': 'List', 'items': children}}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--image', required=True)
|
||||
parser.add_argument('--state', help='reuse the exact random state from the first rendering')
|
||||
parser.add_argument('--job-uid', help='actual UID returned by creating the reviewed Job')
|
||||
parser.add_argument('--output', type=Path, required=True, help='new private packet directory')
|
||||
args = parser.parse_args()
|
||||
state = args.state or secrets.token_urlsafe(32)
|
||||
packet = render(args.image, state, args.job_uid)
|
||||
args.output.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||
for name, data in [('job.json', packet['job']), ('children.json', packet['children']),
|
||||
('packet.json', {'state': state, 'image': args.image, 'job_uid': args.job_uid,
|
||||
'name': packet['name'], 'start_url': packet['start_url'],
|
||||
'children_bound_to_job': args.job_uid is not None})]:
|
||||
dest = args.output / name
|
||||
with dest.open('x') as stream:
|
||||
dest.chmod(0o600)
|
||||
json.dump(data, stream, indent=2)
|
||||
stream.write('\n')
|
||||
print(json.dumps({'packet_directory': str(args.output), 'job_name': packet['name'],
|
||||
'children_bound_to_job': args.job_uid is not None, 'cluster_changed': False}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
79
scripts/test_render_upstream_issuer_probe.py
Normal file
79
scripts/test_render_upstream_issuer_probe.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('probe_renderer', Path(__file__).with_name('render-upstream-issuer-probe.py'))
|
||||
renderer = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(renderer)
|
||||
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:' + 'a' * 64
|
||||
STATE = 'A' * 43
|
||||
UID = '12345678-1234-1234-1234-123456789abc'
|
||||
|
||||
|
||||
class RenderProbeTests(unittest.TestCase):
|
||||
def test_dedicated_route_and_pod_cannot_join_production_service(self):
|
||||
packet = renderer.render(IMAGE, STATE, UID)
|
||||
job = packet['job']
|
||||
pod = job['spec']['template']
|
||||
self.assertNotIn('app.kubernetes.io/name', pod['metadata']['labels'])
|
||||
self.assertFalse(pod['spec']['automountServiceAccountToken'])
|
||||
self.assertEqual(job['spec']['activeDeadlineSeconds'], 600)
|
||||
self.assertEqual(job['spec']['backoffLimit'], 0)
|
||||
self.assertEqual(job['spec']['ttlSecondsAfterFinished'], 300)
|
||||
children = packet['children']['items']
|
||||
self.assertEqual(len(children), 4)
|
||||
for child in children:
|
||||
self.assertEqual(child['metadata']['ownerReferences'][0]['uid'], UID)
|
||||
route = children[1]['spec']['routes'][0]
|
||||
self.assertIn('Query(`state`, `' + STATE + '`)', route['match'])
|
||||
self.assertIn('Path(`/authorize/callback`)', route['match'])
|
||||
self.assertNotIn('PathPrefix', route['match'])
|
||||
self.assertFalse(route['observability']['accessLogs'])
|
||||
self.assertFalse(route['observability']['tracing'])
|
||||
|
||||
def test_only_config_yaml_is_projected_without_signing_key_or_api_token(self):
|
||||
pod = renderer.render(IMAGE, STATE)['job']['spec']['template']['spec']
|
||||
self.assertEqual(len(pod['volumes']), 1)
|
||||
secret = pod['volumes'][0]['secret']
|
||||
self.assertEqual(secret['secretName'], 'keycape-config')
|
||||
self.assertEqual(secret['items'], [{'key': 'config.yaml', 'path': 'config.yaml', 'mode': 0o440}])
|
||||
security = pod['containers'][0]['securityContext']
|
||||
self.assertTrue(security['readOnlyRootFilesystem'])
|
||||
self.assertFalse(security['allowPrivilegeEscalation'])
|
||||
|
||||
def test_no_route_or_scope_injection_and_no_mutable_image(self):
|
||||
for state in ['', STATE + '`)', 'B' * 43, 'A' * 22]:
|
||||
with self.assertRaises(ValueError):
|
||||
renderer.render(IMAGE, state)
|
||||
with self.assertRaises(ValueError):
|
||||
renderer.render('forgejo.coulomb.social/coulomb/key-cape:latest', STATE)
|
||||
with self.assertRaises(ValueError):
|
||||
renderer.render(IMAGE, STATE, 'arbitrary-owner')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
class ReceiptTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import json
|
||||
self.json = json
|
||||
spec = importlib.util.spec_from_file_location('collector', Path(__file__).with_name('collect-upstream-issuer-proof.py'))
|
||||
self.collector = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(self.collector)
|
||||
self.receipt = {'schema': 'keycape.upstream-issuer-proof.v1', 'status': 'verified',
|
||||
'observed_at': '2026-09-08T22:00:00Z', 'issuer': 'https://auth.coulomb.social',
|
||||
'tokens_retained': False, 'downstream_credential_issued': False,
|
||||
'signature_verified': True, 'audience_verified': True,
|
||||
'validity_window_verified': True, 'nonce_verified': True}
|
||||
|
||||
def test_verified_receipt_is_preserved(self):
|
||||
self.assertEqual(self.collector.sanitize(self.json.dumps(self.receipt)), self.receipt)
|
||||
|
||||
def test_extra_claims_unverified_issuers_and_untyped_success_are_refused(self):
|
||||
for patch in [{'sub': 'private-user'}, {'issuer': 'https://unreviewed.example'},
|
||||
{'signature_verified': 1}, {'tokens_retained': True}]:
|
||||
with self.assertRaises(ValueError):
|
||||
self.collector.sanitize(self.json.dumps(dict(self.receipt, **patch)))
|
||||
with self.assertRaises(ValueError):
|
||||
self.collector.sanitize(self.json.dumps(self.receipt) + '\nprivate-token')
|
||||
Loading…
Add table
Add a link
Reference in a new issue