net-kingdom/sso-mfa/k8s/keycape/create-secrets.py
tegwick 7d4bb1653e
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
fix(security): reconcile KeyCape bootstrap with scoped factor custody
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
2026-10-13 19:46:53 +02:00

137 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Create first-bootstrap config with private inputs and scoped factor delivery.
Never replace an existing Secret, mint a provider token or rotate a signing key.
Child output is captured, including Kubernetes error bodies containing Secret data.
"""
import base64
import contextlib
import json
import os
from pathlib import Path
import re
import secrets
import stat
import subprocess
import sys
import yaml
class Refused(Exception):
pass
def private(info, directory=False):
kind = stat.S_ISDIR if directory else stat.S_ISREG
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise Refused()
if not directory and info.st_nlink != 1:
raise Refused()
@contextlib.contextmanager
def custody(root):
root = Path(os.path.abspath(root))
if any(p.is_symlink() for p in (root, *root.parents)):
raise Refused()
descriptors = []
try:
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
fd = os.open(root, flags); descriptors.append(fd)
private(os.fstat(fd), True)
children = {}
for name in ('lldap', 'authelia', 'keycape'):
child = os.open(name, flags, dir_fd=fd); descriptors.append(child)
private(os.fstat(child), True)
children[name] = child
yield children
finally:
for fd in reversed(descriptors): os.close(fd)
def read_file(parent, name):
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
try:
private(os.fstat(fd))
raw = os.read(fd, 65537)
if len(raw) > 65536: raise Refused()
return raw
finally:
os.close(fd)
def env_value(parent, name):
values = [line.split('=', 1)[1] for line in read_file(parent, 'secrets.env').decode().splitlines()
if line.startswith(name + '=')]
# Consume the generator's literal format, never execute a shell env file.
if len(values) != 1 or not re.fullmatch(r'[A-Za-z0-9]{32,128}', values[0]):
raise Refused()
return values[0]
def run(args, payload=None):
result = subprocess.run(args, input=payload, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, timeout=60, check=False)
if result.returncode or len(result.stdout) > 65536: raise Refused()
return result.stdout
def signing_key(parent):
try:
key = read_file(parent, 'key.pem')
except FileNotFoundError:
key = run(['openssl', 'genrsa', '2048'])
run(['openssl', 'rsa', '-check', '-noout'], key)
# Publish atomically without replacement, so concurrent creation cannot
# overwrite a pre-existing key. Keep this private key after API failure.
temp = '.key-' + secrets.token_hex(12)
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
try:
with os.fdopen(fd, 'wb') as file:
file.write(key); file.flush(); os.fsync(file.fileno())
os.link(temp, 'key.pem', src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
os.fsync(parent)
finally:
os.unlink(temp, dir_fd=parent)
run(['openssl', 'rsa', '-check', '-noout'], key)
return key
def main():
if len(sys.argv) > 2: raise Refused()
here = Path(__file__).resolve().parent
root = sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets'
with custody(root) as dirs:
bind_password = env_value(dirs['lldap'], 'LLDAP_LDAP_USER_PASS')
client_secret = env_value(dirs['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET')
config = json.loads((here / 'bootstrap-config.json').read_text())
config['lldap']['bindPW'] = bind_password
config['authelia']['clientSecret'] = client_secret
# Existence is not an update grant. API create also closes the race
# between this preflight and publication; AlreadyExists stays a failure.
existing = run(['kubectl', 'get', 'secret', 'keycape-config', '-n', 'sso', '--ignore-not-found', '-o', 'name'])
if existing.strip(): raise Refused()
ready = run(['kubectl', 'get', 'externalsecret', 'keycape-factor-read', '-n', 'sso',
'-o', 'jsonpath={.status.conditions[?(@.type=="Ready")].status}'])
factor = run(['kubectl', 'get', 'secret', 'keycape-factor-read', '-n', 'sso', '-o', 'name'])
if ready.strip() != b'True' or factor.strip() != b'secret/keycape-factor-read': raise Refused()
key = signing_key(dirs['keycape'])
data = {'config.yaml': yaml.safe_dump(config, sort_keys=False).encode(), 'key.pem': key}
manifest = {'apiVersion': 'v1', 'kind': 'Secret', 'type': 'Opaque',
'metadata': {'name': 'keycape-config', 'namespace': 'sso'},
'data': {name: base64.b64encode(value).decode() for name, value in data.items()}}
response = run(['kubectl', 'create', '-o', 'name', '-f', '-'], json.dumps(manifest).encode())
if response.strip() != b'secret/keycape-config': raise Refused()
print('KeyCape bootstrap Secret created with mounted factor credential configuration.')
print('Preserve the private signing key in approved custody. Native login and factor acceptance remain required.')
if __name__ == '__main__':
try:
main()
except (Refused, OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
print('ERROR: KeyCape bootstrap refused or failed. Existing configuration is never replaced; '
'check private inputs and RPF-WP-0040 delivery using metadata-only diagnostics. '
'If creation was attempted, its remote outcome must be reconciled before retry.', file=sys.stderr)
sys.exit(1)