net-kingdom/sso-mfa/k8s/keycape/create-secrets.py
tegwick 145ca8e08c
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Identity provider journey acceptance / provider (push) Failing after 12s
Build and Publish identity-provisioner / build-and-push (push) Successful in 50s
NK-WP-0054-T05 phase 2b: each directory consumer binds with its own account
Custody gains LLDAP_SVC_{AUTHELIA,KEYCAPE,PRIVACYIDEA,PROVISIONER}_PASS and
LLDAP_LDAP_USER_PASS becomes ld-admin's. Authelia, KeyCape, the privacyIDEA
resolver and identity-provisioner (new Secret identity-provisioner-ldap) switch
to svc-* accounts; LLDAP's configured super-admin is ld-admin. The LLDAP image
pin waits for the running digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
2026-10-28 20:33:04 +01: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_SVC_KEYCAPE_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)