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

@ -0,0 +1,92 @@
import base64, importlib.util, json, os, secrets, socket, subprocess, tempfile, time
from pathlib import Path
from unittest.mock import patch
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import yaml
import http.server, ssl, threading, urllib.request, urllib.error, datetime, ipaddress
from cryptography import x509
from cryptography.x509.oid import NameOID
import argparse
parser=argparse.ArgumentParser(description='Exercise the pinned image and verifier over loopback HTTPS using synthetic credentials only.')
parser.add_argument('--receipt', required=True, type=Path)
args=parser.parse_args()
spec=importlib.util.spec_from_file_location('rollout',Path(__file__).with_name('approval-clients-rollout.py'))
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
m.verify_artifact()
def call(args):
p=subprocess.run(args,capture_output=True,timeout=45)
if p.returncode: raise RuntimeError('contained_docker_command_failed')
return p
sock=socket.socket(); sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]; sock.close()
class Proxy(http.server.BaseHTTPRequestHandler):
def log_message(self, *args): pass
def handle_proxy(self):
body = self.rfile.read(int(self.headers.get('Content-Length', 0))) if self.command == 'POST' else None
req = urllib.request.Request(f'http://127.0.0.1:{port}'+self.path, data=body,
headers={k:self.headers[k] for k in ['Content-Type','Authorization'] if k in self.headers})
try:
response=urllib.request.urlopen(req,timeout=20)
except urllib.error.HTTPError as error: response=error
with response:
self.send_response(response.code)
self.send_header('Content-Type', response.headers.get('Content-Type','application/json'))
self.end_headers(); self.wfile.write(response.read())
do_GET=handle_proxy
do_POST=handle_proxy
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Proxy)
https_port=server.server_address[1]
name='keycape-approval-exercise-'+secrets.token_hex(5)
with tempfile.TemporaryDirectory(prefix='keycape-private-exercise-') as temp:
root=Path(temp)
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
(root/'key.pem').write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
os.chmod(root/'key.pem',0o600)
name_attr=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'KeyCape local exercise')])
cert=(x509.CertificateBuilder().subject_name(name_attr).issuer_name(name_attr).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(minutes=1))
.not_valid_after(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]),False)
.add_extension(x509.BasicConstraints(ca=True,path_length=None),True))
from cryptography.hazmat.primitives import hashes
(root/'cert.pem').write_bytes(cert.sign(key,hashes.SHA256()).public_bytes(serialization.Encoding.PEM))
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(root/'cert.pem',root/'key.pem')
server.socket=context.wrap_socket(server.socket,server_side=True)
threading.Thread(target=server.serve_forever,daemon=True).start()
config=yaml.safe_load((m.KEYCAPE/'config/dev-config.yaml').read_text())
config.update(issuer=f'https://127.0.0.1:{https_port}')
config['authelia']['issuer']='https://auth.coulomb.social'
config['clients'].extend(m.registrations())
(root/'config.yaml').write_text(yaml.safe_dump(config,sort_keys=False))
os.chmod(root/'config.yaml',0o600)
values={c['clientId']:secrets.token_urlsafe(48) for c in m.registrations()}
(root/'env').write_text('KEYCAPE_CONFIG=/etc/keycape/config.yaml\n'+''.join(c['secretRef'].removeprefix('env:')+'='+values[c['clientId']]+'\n' for c in m.registrations()))
os.chmod(root/'env',0o600)
created=False
try:
call(['docker','run','-d','--rm','--name',name,'--user',str(os.getuid()),'--publish',f'127.0.0.1:{port}:8080',
'--env-file',str(root/'env'),'--mount','type=bind,source='+temp+',target=/etc/keycape,readonly',m.IMAGE])
created=True
with patch.object(m,'ISSUER',config['issuer']), patch.dict(os.environ,{'SSL_CERT_FILE':str(root/'cert.pem')}):
for _ in range(30):
try:
status,_=m.http('/.well-known/openid-configuration')
if status==200: break
except Exception: pass
time.sleep(.5)
else: raise RuntimeError('scratch_keycape_not_ready')
def fake_get(kube,kind,name):
index=m.SECRET_NAMES.index(name)
return {'data': {'client-secret':base64.b64encode(values[m.IDS[index]].encode()).decode()}}
receipt={}
with patch.object(m,'get',side_effect=fake_get):
m.acceptance([],receipt)
receipt.update(target='disposable pinned KeyCape image; synthetic keys and clients only',
pinned_native_cli_verification='passed against loopback HTTPS with synthetic credentials',cleanup_complete=False)
finally:
if created: call(['docker','stop','--time=5',name])
server.shutdown(); server.server_close()
receipt['cleanup_complete']=True
fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
with os.fdopen(fd,'w') as out: out.write(json.dumps(receipt,indent=2)+'\n')
print('Pinned-image scratch acceptance passed; synthetic credentials removed.')