Some checks failed
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
137 lines
5.7 KiB
Python
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)
|