#!/usr/bin/env python3 """Private service Secret creation, exact-match verification and named rotation. No credential is accepted on argv, evaluated as shell code, or printed. This helper is not an authority grant or a multi-service rotation transaction. """ import base64 import contextlib import json import os from pathlib import Path import re import secrets import stat import subprocess import sys class Refused(Exception): pass TARGETS = { 'postgresql': ('databases', 'net-kingdom-pg-privacyidea-app', ('privacyidea',)), 'privacyidea': ('mfa', 'privacyidea-config', ('privacyidea',)), 'lldap': ('sso', 'lldap-secrets', ('lldap',)), 'authelia': ('sso', 'authelia-secrets', ('authelia', 'lldap')), 'identity-provisioner': ('sso', 'identity-provisioner-ldap', ('lldap',)), } ROTATIONS = { 'postgresql': {'PI_DB_PASSWORD': 'password'}, 'privacyidea': {'PI_SECRET_KEY': 'PI_SECRET_KEY', 'PI_DB_PASSWORD': 'PI_SQLALCHEMY_DATABASE_URI'}, 'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS': 'LLDAP_LDAP_USER_PASS'}, 'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret', 'LLDAP_SVC_AUTHELIA_PASS': 'ldap_password'}, } ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration' 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, components): 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 component in components: child = os.open(component, flags, dir_fd=fd) descriptors.append(child); private(os.fstat(child), True) children[component] = child yield children finally: for fd in reversed(descriptors): os.close(fd) def read_private(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 read_env(parent): values = {} for line in read_private(parent, 'secrets.env').decode().splitlines(): if not line.strip() or line.lstrip().startswith('#'): continue name, separator, value = line.partition('=') if not separator or not re.fullmatch(r'[A-Z][A-Z0-9_]*', name) or name in values: raise Refused() values[name] = value return values def literal(values, name): value = values.get(name, '') if not re.fullmatch(r'[A-Za-z0-9]{32,128}', value): raise Refused() return value def run(args, payload=None): completed = subprocess.run(args, input=payload, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=60, check=False) if completed.returncode or len(completed.stdout) > 2 * 1024 * 1024: raise Refused() return completed.stdout def decode_secret(raw, namespace, name): obj = json.loads(raw) if not isinstance(obj, dict) or obj.get('kind') != 'Secret' or obj.get('apiVersion') != 'v1': raise Refused() meta = obj.get('metadata') if not isinstance(meta, dict) or meta.get('name') != name or meta.get('namespace') != namespace: raise Refused() if (not isinstance(meta.get('uid'), str) or not isinstance(meta.get('resourceVersion'), str) or not re.fullmatch(r'[a-zA-Z0-9-]{1,128}', meta['uid']) or not re.fullmatch(r'[0-9]{1,128}', meta['resourceVersion'])): raise Refused() if (meta.get('ownerReferences') or obj.get('immutable') or obj.get('type') not in (('Opaque', 'kubernetes.io/basic-auth') if name == 'net-kingdom-pg-privacyidea-app' else ('Opaque',))): raise Refused() annotations = meta.get('annotations', {}) labels = meta.get('labels', {}) if not isinstance(annotations, dict) or not isinstance(labels, dict): raise Refused() if any(not isinstance(v, str) for v in (*annotations.values(), *labels.values())): raise Refused() if any(k.startswith(('external-secrets.io/', 'reconcile.external-secrets.io/')) for k in labels): raise Refused() if not isinstance(obj.get('data'), dict) or not obj['data']: raise Refused() for key, value in obj['data'].items(): if not isinstance(key, str) or not isinstance(value, str): raise Refused() base64.b64decode(value, validate=True) return obj def read_key(parent, configured, existing): if configured: path = Path(configured) if not path.is_absolute() or any(p.is_symlink() for p in (path, *path.parents)): raise Refused() fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: private(os.fstat(fd), True) key = read_private(fd, path.name) finally: os.close(fd) else: try: key = read_private(parent, 'oidc_private_key.pem') except FileNotFoundError: if existing is not None: key = base64.b64decode(existing['data']['oidc_issuer_private_key'], validate=True) else: key = run(['openssl', 'genrsa', '2048']) run(['openssl', 'rsa', '-check', '-noout'], key) temp = '.issuer-' + 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, 'oidc_private_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.decode() def service_values(component, values): # Validate all required literals before any cluster call or local key creation. if component == 'postgresql': return {'username': 'privacyidea', 'password': literal(values['privacyidea'], 'PI_DB_PASSWORD')} if component == 'privacyidea': env = values['privacyidea']; password = literal(env, 'PI_DB_PASSWORD') return {'PI_SECRET_KEY': literal(env, 'PI_SECRET_KEY'), 'PI_PEPPER': literal(env, 'PI_PEPPER'), 'PI_SQLALCHEMY_DATABASE_URI': 'postgresql://privacyidea:' + password + '@net-kingdom-pg-rw.databases.svc.cluster.local:5432/privacyidea_db'} if component == 'lldap': return {key: literal(values['lldap'], key) for key in ('LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS')} if component == 'identity-provisioner': return {'username': 'svc-provisioner', 'password': literal(values['lldap'], 'LLDAP_SVC_PROVISIONER_PASS')} env = values['authelia'] return {'jwt_secret': literal(env, 'AUTHELIA_JWT_SECRET'), 'session_secret': literal(env, 'AUTHELIA_SESSION_SECRET'), 'storage_encryption_key': literal(env, 'AUTHELIA_STORAGE_ENCRYPTION_KEY'), 'oidc_hmac_secret': literal(env, 'AUTHELIA_OIDC_HMAC_SECRET'), 'ldap_password': literal(values['lldap'], 'LLDAP_SVC_AUTHELIA_PASS')} def publish(namespace, name, desired, old, allowed): encoded = {k: base64.b64encode(v.encode() if isinstance(v, str) else v).decode() for k, v in desired.items()} if old is None: if allowed: raise Refused() # Rotation may not silently create missing state. obj = {'apiVersion': 'v1', 'kind': 'Secret', 'type': 'Opaque', 'metadata': {'namespace': namespace, 'name': name}, 'data': encoded} result = run(['kubectl', 'create', '-o', 'json', '-f', '-'], json.dumps(obj).encode()) expected = encoded; outcome = 'created' else: if not set(encoded).issubset(old['data']): raise Refused() different = {k for k, value in encoded.items() if old['data'][k] != value} if different - allowed: raise Refused() annotations = old['metadata'].get('annotations', {}) if not different and ANNOTATION not in annotations: return 'verified' patch = [{'op': 'test', 'path': '/metadata/uid', 'value': old['metadata']['uid']}, {'op': 'test', 'path': '/metadata/resourceVersion', 'value': old['metadata']['resourceVersion']}, {'op': 'test', 'path': '/data', 'value': old['data']}] patch.extend({'op': 'replace', 'path': '/data/' + key, 'value': encoded[key]} for key in sorted(different)) if ANNOTATION in annotations: patch.append({'op': 'remove', 'path': '/metadata/annotations/kubectl.kubernetes.io~1last-applied-configuration'}) result = run(['kubectl', 'patch', 'secret', name, '-n', namespace, '--type=json', '--patch-file=/dev/stdin', '-o', 'json'], json.dumps(patch).encode()) expected = {**old['data'], **{k: encoded[k] for k in different}} outcome = 'updated' readback = decode_secret(result, namespace, name) if readback['data'] != expected or ANNOTATION in readback['metadata'].get('annotations', {}): raise Refused() if old is not None: if readback['metadata']['uid'] != old['metadata']['uid'] or readback['type'] != old['type']: raise Refused() expected_annotations = {k: v for k, v in old['metadata'].get('annotations', {}).items() if k != ANNOTATION} if readback['metadata'].get('annotations', {}) != expected_annotations: raise Refused() if readback['metadata'].get('labels', {}) != old['metadata'].get('labels', {}): raise Refused() return outcome def main(): if len(sys.argv) not in (2, 3, 5): raise Refused() component = sys.argv[1] if component not in TARGETS: raise Refused() namespace, name, inputs = TARGETS[component] root = sys.argv[2] if len(sys.argv) >= 3 else Path(__file__).resolve().parent / 'secrets' rotation = None if len(sys.argv) == 5: if sys.argv[3] != '--rotate-key' or sys.argv[4] not in ROTATIONS[component]: raise Refused() rotation = ROTATIONS[component][sys.argv[4]] with custody(root, inputs) as directories: values = {c: read_env(directories[c]) for c in inputs} desired = service_values(component, values) if component == 'authelia': import bcrypt client = literal(values['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET').encode() if len(client) > 72: raise Refused() raw = run(['kubectl', 'get', 'secret', name, '-n', namespace, '--ignore-not-found', '-o', 'json']) old = decode_secret(raw, namespace, name) if raw.strip() else None if rotation and old is None: raise Refused() if component == 'authelia': if old is None: hashed = bcrypt.hashpw(client, bcrypt.gensalt(rounds=12)) else: hashed = base64.b64decode(old['data']['keycape_client_secret_hash'], validate=True) if not bcrypt.checkpw(client, hashed): raise Refused() desired['keycape_client_secret_hash'] = hashed.decode() desired['oidc_issuer_private_key'] = read_key(directories['authelia'], values['authelia'].get('AUTHELIA_OIDC_PRIVATE_KEY_FILE', ''), old) outcome = publish(namespace, name, desired, old, {rotation} if rotation else set()) print('Service Secret ' + outcome + '; native consumer acceptance remains required.') if __name__ == '__main__': try: main() except (Refused, OSError, ValueError, KeyError, TypeError, ImportError, subprocess.SubprocessError): print('ERROR: service Secret operation refused or failed. Check private inputs, current ownership ' 'and the named rotation through approved metadata-only diagnostics. A failed write has an ' 'unverified remote outcome; reconcile before retry. Local recovery keys are retained.', file=sys.stderr) sys.exit(1)