net-kingdom/sso-mfa/k8s/privacyidea/key-custody.py
tegwick 1cbe00fd78
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
security: preserve privacyIDEA key custody and restore the actual PVC
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
2026-10-13 21:46:41 +02:00

254 lines
12 KiB
Python

#!/usr/bin/env python3
"""Captured key export/restore for the admitted privacyIDEA PVC profile."""
import argparse
import base64
import importlib.util
import json
import os
from pathlib import Path
import re
import secrets
import sys
HERE = Path(__file__).resolve().parent
def load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
common = load('service_custody', HERE.parents[1] / 'bootstrap/apply-service-secrets.py')
remote = load('key_custody_remote', HERE / 'key-custody-remote.py')
Refused = common.Refused
LOCAL = {'enckey': 'pi.enc', 'private.pem': 'private.pem', 'public.pem': 'public.pem'}
TARGETS = {'privacyidea-enckey': ('enckey',), 'privacyidea-auditkeys': ('private.pem', 'public.pem')}
RECOVERY_POD = 'privacyidea-key-recovery'
SLEEP = ['python3', '-c', 'import time; time.sleep(3600)']
IMAGE = re.compile(r'ghcr\.io/gpappsoft/privacyidea-docker@sha256:[a-f0-9]{64}\Z')
UID = re.compile(r'[a-zA-Z0-9-]{1,128}\Z')
def get(kind, name=None, missing=False):
args = ['kubectl', 'get', kind]
if name:
args.append(name)
args += ['-n', 'mfa', '-o', 'json']
if missing:
args.append('--ignore-not-found')
raw = common.run(args)
return json.loads(raw) if raw.strip() else None
def meta(obj, kind, name=None):
if not isinstance(obj, dict) or obj.get('kind') != kind:
raise Refused()
m = obj.get('metadata', {})
if (m.get('namespace') != 'mfa' or (name and m.get('name') != name)
or not UID.fullmatch(str(m.get('uid', ''))) or m.get('deletionTimestamp')):
raise Refused()
return m
def mounts_data(spec, container):
volumes = {v['name']: v for v in spec.get('volumes', [])}
return any(v.get('mountPath') == '/etc/privacyidea' and not v.get('subPath')
and not v.get('readOnly') and volumes.get(v['name'], {}).get('persistentVolumeClaim', {}).get('claimName') == 'privacyidea-data'
for v in container.get('volumeMounts', []))
def pod_uses_data(pod):
return any(v.get('persistentVolumeClaim', {}).get('claimName') == 'privacyidea-data'
for v in pod.get('spec', {}).get('volumes', []))
def context(action, expected_uid=None):
deployment = get('deployment', 'privacyidea')
dm = meta(deployment, 'Deployment', 'privacyidea')
template = deployment['spec']['template']['spec']
container = next(c for c in template['containers'] if c['name'] == 'privacyidea')
if template.get('securityContext', {}).get('fsGroup') != 999 or not mounts_data(template, container):
raise Refused()
pvc = get('pvc', 'privacyidea-data')
pm = meta(pvc, 'PersistentVolumeClaim', 'privacyidea-data')
if pvc.get('status', {}).get('phase') != 'Bound' or (expected_uid and pm['uid'] != expected_uid):
raise Refused()
listing = get('pods')
if listing.get('kind') not in ('PodList', 'List'):
raise Refused()
users = [pod for pod in listing['items'] if pod_uses_data(pod)]
if len(users) != 1:
raise Refused()
pod = users[0]; podmeta = meta(pod, 'Pod')
spec = pod['spec']
if pod.get('status', {}).get('phase') != 'Running':
raise Refused()
if action == 'restore':
if (deployment['spec'].get('replicas', 1) != 0
or any(deployment.get('status', {}).get(k, 0) for k in ('replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas'))
or deployment.get('status', {}).get('observedGeneration', 0) < dm.get('generation', 1)):
raise Refused()
if (podmeta['name'] != RECOVERY_POD or podmeta.get('annotations', {}).get('net-kingdom/recovery-pvc-uid') != expected_uid
or spec.get('automountServiceAccountToken') is not False
or any(spec.get(k) for k in ('hostNetwork', 'hostPID', 'hostIPC', 'initContainers', 'ephemeralContainers'))
or len(spec.get('containers', [])) != 1 or len(spec.get('volumes', [])) != 1):
raise Refused()
c = spec['containers'][0]
if (c.get('name') != 'recovery' or not IMAGE.fullmatch(c.get('image', ''))
or c.get('command') != SLEEP or c.get('args') or c.get('env') or c.get('envFrom')
or c.get('securityContext', {}).get('privileged')
or not mounts_data(spec, c)):
raise Refused()
else:
c = next(c for c in spec['containers'] if c['name'] == 'privacyidea')
if not mounts_data(spec, c):
raise Refused()
owners = podmeta.get('ownerReferences', [])
if len(owners) != 1 or owners[0].get('kind') != 'ReplicaSet' or not owners[0].get('controller'):
raise Refused()
rs = get('replicaset', owners[0]['name']); rm = meta(rs, 'ReplicaSet', owners[0]['name'])
if rm['uid'] != owners[0]['uid'] or not any(o.get('uid') == dm['uid'] and o.get('kind') == 'Deployment' and o.get('controller') for o in rm.get('ownerReferences', [])):
raise Refused()
if not any(s.get('name') == c['name'] and s.get('ready') for s in pod['status'].get('containerStatuses', [])):
raise Refused()
return {'pod': podmeta['name'], 'pod_uid': podmeta['uid'], 'pvc_uid': pm['uid'],
'deployment_uid': dm['uid'], 'container': c['name']}
def invoke(ctx, request):
source = (HERE / 'key-custody-remote.py').read_text()
raw = common.run(['kubectl', 'exec', '-i', '-n', 'mfa', ctx['pod'], '-c', ctx['container'],
'--', 'python3', '-c', source], json.dumps(request).encode())
return json.loads(raw)
def read_backups():
result = {}
for name in TARGETS:
obj = get('secret', name, missing=True)
result[name] = common.decode_secret(json.dumps(obj), 'mfa', name) if obj is not None else None
return result
def verify_backups(backups, values):
for name, keys in TARGETS.items():
old = backups[name]
if old is not None and any(old['data'].get(k) != base64.b64encode(values[k]).decode() for k in keys):
raise Refused()
def local_values(directory):
result = {name: common.read_private(directory, local) for name, local in LOCAL.items()}
remote.validate(result)
return result
def save_local(directory, values):
# All differences are checked before creating any file. Existing copies survive.
missing = []
for name, local in LOCAL.items():
try:
if common.read_private(directory, local) != values[name]:
raise Refused()
except FileNotFoundError:
missing.append((name, local))
for name, local in missing:
temporary = '.key-export-' + secrets.token_hex(12)
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(values[name]); stream.flush(); os.fsync(stream.fileno())
os.link(temporary, local, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False)
os.unlink(temporary, dir_fd=directory); os.fsync(directory)
finally:
try:
os.unlink(temporary, dir_fd=directory)
except FileNotFoundError:
pass
if local_values(directory) != values:
raise Refused()
def perform(action, root, expected_uid=None, initialize=False):
if action == 'restore' and (not expected_uid or not UID.fullmatch(expected_uid)):
raise Refused()
with common.custody(root, ('privacyidea',)) as directories:
directory = directories['privacyidea']
values = local_values(directory) if action == 'restore' else None
ctx = context(action, expected_uid)
if action == 'restore':
result = invoke(ctx, {'action': 'restore', 'keys': {k: base64.b64encode(v).decode() for k, v in values.items()}})
if result != {'result': 'restored'} or context(action, expected_uid) != ctx:
raise Refused()
else:
backups = read_backups()
if initialize:
if any(value is not None for value in backups.values()):
raise Refused()
# Independent local recovery copies also prohibit first-bootstrap creation.
for name in LOCAL.values():
try:
os.stat(name, dir_fd=directory, follow_symlinks=False)
except FileNotFoundError:
continue
raise Refused()
if invoke(ctx, {'action': 'initialize-audit'}) != {'result': 'initialized'}:
raise Refused()
encoded = invoke(ctx, {'action': 'export'})
values = {k: base64.b64decode(v, validate=True) for k, v in encoded.items()}
remote.validate(values)
if context(action) != ctx:
raise Refused()
verify_backups(backups, values)
save_local(directory, values)
for name, keys in TARGETS.items():
common.publish('mfa', name, {k: values[k] for k in keys}, backups[name], set())
print('Key custody operation verified. Retain independent recovery copies; native service acceptance remains required.')
def render(image, pvc_uid):
if not image or not IMAGE.fullmatch(image) or not pvc_uid or not UID.fullmatch(pvc_uid):
raise Refused()
return {'apiVersion': 'v1', 'kind': 'Pod',
'metadata': {'name': RECOVERY_POD, 'namespace': 'mfa',
'annotations': {'net-kingdom/recovery-pvc-uid': pvc_uid}},
'spec': {'restartPolicy': 'Never', 'automountServiceAccountToken': False,
'securityContext': {'runAsUser': 0, 'runAsGroup': 999, 'fsGroup': 999},
'containers': [{'name': 'recovery', 'image': image, 'command': SLEEP,
'securityContext': {'allowPrivilegeEscalation': False, 'readOnlyRootFilesystem': True,
'capabilities': {'drop': ['ALL'], 'add': ['CHOWN', 'DAC_OVERRIDE', 'FOWNER']}},
'resources': {'requests': {'cpu': '10m', 'memory': '64Mi'}, 'limits': {'cpu': '100m', 'memory': '128Mi'}},
'volumeMounts': [{'name': 'data', 'mountPath': '/etc/privacyidea'}]}],
'volumes': [{'name': 'data', 'persistentVolumeClaim': {'claimName': 'privacyidea-data'}}]}}
def main():
class QuietParser(argparse.ArgumentParser):
def error(self, message):
raise Refused()
parser = QuietParser(description=__doc__)
parser.add_argument('action', choices=['export', 'restore', 'render-pod'])
parser.add_argument('root', nargs='?')
parser.add_argument('--pvc-uid')
parser.add_argument('--image')
parser.add_argument('--initialize-audit-keys', action='store_true')
args = parser.parse_args()
if args.action == 'render-pod':
if args.root or args.initialize_audit_keys:
raise Refused()
print(json.dumps(render(args.image, args.pvc_uid), indent=2))
else:
if not args.root or args.image or (args.action == 'restore' and args.initialize_audit_keys) or (args.action == 'export' and args.pvc_uid):
raise Refused()
perform(args.action, args.root, args.pvc_uid, args.initialize_audit_keys)
if __name__ == '__main__':
try:
main()
except Exception:
print('ERROR: key custody refused or failed; diagnostics suppressed. A failed write may have partial effects. '
'Retain copies and reconcile target state before retrying.', file=sys.stderr)
sys.exit(1)