net-kingdom/tools/bootstrap-recovery/isolated-privacyidea.py
tegwick 1ab5d53bd7 Prepare isolated privacyIDEA token and audit recovery checks
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-16 21:00:36 +02:00

309 lines
16 KiB
Python

#!/usr/bin/env python3
"""Private native privacyIDEA token-material and audit recovery checks.
This does not authenticate a user's MFA code. It restores only a disposable
database, decrypts token material internally and verifies stored audit rows.
"""
import argparse
import ast
import base64
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
import uuid
# Import the already reviewed local target/transport boundary without changing
# the accepted LLDAP executor or its source digest.
import importlib.util
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location('isolated_lldap', HERE / 'isolated-lldap.py')
local = importlib.util.module_from_spec(spec)
spec.loader.exec_module(local)
bundle = local.bundle
IMAGE = local.CLIENT
POSTGRES = local.verify.pg.IMAGE
def run(args, **kwargs):
return local.command(args, **kwargs)
def preflight():
target = local.preflight()
target['images'].append({'reference': POSTGRES,
'image_id': run(['docker', 'image', 'inspect', '--format', '{{.Id}}', POSTGRES]).decode().strip()})
return target
def isolation(name, expected_network, user):
info = json.loads(run(['docker', 'container', 'inspect', name, '--format',
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}']))
host = info['host']
if (host['NetworkMode'] != expected_network or not host['ReadonlyRootfs'] or host['Privileged']
or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', [])
or 'no-new-privileges' not in host.get('SecurityOpt', []) or info['user'] != user
or any(m['Type'] != 'tmpfs' for m in info['mounts'])):
raise bundle.Refused()
def cleanup(names):
for name in names:
present = subprocess.run(['docker', 'container', 'inspect', name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
if present.returncode == 0:
run(['docker', 'rm', '--force', '--volumes', name])
absent = subprocess.run(['docker', 'container', 'inspect', name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
run(['docker', 'info', '--format', '{{.ID}}'])
if absent.returncode == 0:
raise bundle.Refused()
def start_database(name):
run(['docker', 'run', '--detach', '--pull=never', '--name', name, '--network', 'none',
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
'--user', '999:999', '--memory', '2g', '--cpus', '2', '--pids-limit', '128',
'--tmpfs', '/var/lib/postgresql/data:rw,nosuid,nodev,noexec,size=1073741824,uid=999,gid=999,mode=0700',
'--tmpfs', '/var/run/postgresql:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999',
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999',
'--env', 'POSTGRES_HOST_AUTH_METHOD=trust', '--env', 'POSTGRES_DB=recovery',
'--entrypoint', '/usr/bin/timeout', POSTGRES, '--kill-after=15s', '1800s',
'/usr/local/bin/docker-entrypoint.sh', 'postgres', '-c', 'listen_addresses=127.0.0.1'])
isolation(name, 'none', '999:999')
for _ in range(80):
# The entrypoint's temporary init server has TCP disabled. Require TCP
# readiness so we cannot mistake it for the final local-only server.
ready = subprocess.run(['docker', 'exec', name, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres', '-d', 'recovery'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
if ready.returncode == 0:
return
time.sleep(.25)
raise bundle.Refused()
def start_worker(name, database, root):
secret = bundle.resource(root, 'mfa', 'secret', 'privacyidea-config')['data']
config = {'SQLALCHEMY_DATABASE_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery',
'PI_AUDIT_SQL_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery',
'SECRET_KEY': base64.b64decode(secret['PI_SECRET_KEY'], validate=True).decode(),
'PI_PEPPER': base64.b64decode(secret['PI_PEPPER'], validate=True).decode(),
'PI_ENCFILE': '/recovery/enckey', 'PI_AUDIT_KEY_PRIVATE': '/recovery/private.pem',
'PI_AUDIT_KEY_PUBLIC': '/recovery/public.pem', 'PI_AUDIT_NO_SIGN': False,
'PI_CHECK_OLD_SIGNATURES': legacy_setting(root), 'PI_NO_RESPONSE_SIGN': True,
'PI_LOGFILE': '/recovery/runtime.log', 'PI_LOGLEVEL': 50,
'PI_UUID_FILE': '/recovery/node-uuid',
'PI_TRUSTED_JWT': []}
# This deliberately does not execute archived pi.cfg or reuse its live DB URI.
payload = {'pi.cfg': '\n'.join(k + '=' + repr(v) for k, v in config.items()).encode()}
for key in ('enckey', 'private.pem', 'public.pem'):
payload[key] = (root / 'data/privacyidea' / key).read_bytes()
run(['docker', 'run', '--detach', '--pull=never', '--name', name,
'--network', 'container:' + database, '--read-only', '--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
'--memory', '1g', '--cpus', '2', '--pids-limit', '128',
'--tmpfs', '/recovery:rw,nosuid,nodev,noexec,size=33554432,uid=1000,gid=1000,mode=0700',
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000',
'--tmpfs', '/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700',
'--env', 'PRIVACYIDEA_CONFIGFILE=/recovery/pi.cfg',
'--entrypoint', 'python3', IMAGE, '-c', 'import time;time.sleep(1800)'])
# Docker normalizes container:<name> to container:<full ID> in HostConfig.
database_id = run(['docker', 'container', 'inspect', database, '--format', '{{.Id}}']).decode().strip()
isolation(name, 'container:' + database_id, '1000:1000')
run(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/recovery'],
input=local.archive_files(payload))
def legacy_setting(root):
path = root / 'resources/mfa/configmap/privacyidea-cfg.json'
if not path.exists():
return False # Only the lower-level synthetic fixture omits ConfigMaps.
raw = bundle.resource(root, 'mfa', 'configmap', 'privacyidea-cfg')['data']['pi.cfg']
selected = False
for node in ast.parse(raw).body:
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'PI_CHECK_OLD_SIGNATURES' for t in node.targets):
if not isinstance(node.value, ast.Constant) or type(node.value.value) is not bool:
raise bundle.Refused()
selected = node.value.value
return selected
PROBE = r'''
import contextlib,io,json,logging,os,sys
logging.disable(logging.CRITICAL)
os.umask(0o077)
result={'status':'failed'}
stage='network_isolation'
try:
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
from pathlib import Path
assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'}
assert len(Path('/proc/net/route').read_text().splitlines())==1
stage='native_application_configuration'
from privacyidea.app import create_app
app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True)
from privacyidea.models import db,Token
from privacyidea.lib.security.default import DefaultSecurityModule
from privacyidea.lib.auditmodules.sqlaudit import Audit,LogEntry
from privacyidea.lib.crypto import Sign
import binascii
with app.app_context():
assert str(db.engine.url)=='postgresql+psycopg2://postgres@127.0.0.1:5432/recovery'
stage='retained_token_decryption'
security=DefaultSecurityModule({'file':'/recovery/enckey'})
wrong_path=Path('/recovery/wrong-enckey');wrong_path.write_bytes(os.urandom(96))
wrong=DefaultSecurityModule({'file':str(wrong_path)})
total=Token.query.count()
assert 0<total<=10000
checked=0;negative=0
for token in Token.query.yield_per(100):
if not token.key_enc:continue
encrypted=binascii.unhexlify(token.key_enc);iv=binascii.unhexlify(token.key_iv)
material=security.decrypt(encrypted,iv)
assert isinstance(material,bytes) and 0<len(material)<=4096
try:
invalid=wrong.decrypt(encrypted,iv)
assert invalid!=material
except (ValueError,binascii.Error):pass
negative+=1;checked+=1
del material
assert checked>0
result.update(retained_tokens_total=total,retained_tokens_with_native_decryption=checked,
different_key_cannot_reproduce_material_count=negative,
retained_token_material_decrypted_inside_process=True,existing_token_mfa_proven=False)
stage='historical_audit_verification'
audit=Audit(app.config)
allow_legacy=bool(app.config['PI_CHECK_OLD_SIGNATURES'])
historical=audit.session.query(LogEntry).count()
assert 0<historical<=2000000
signed=0;unsigned=0;invalid=0;legacy=0
for entry in audit.session.query(LogEntry).yield_per(1000):
if not entry.signature:
unsigned+=1;continue
signed+=1
if not str(entry.signature).startswith(Sign.sig_ver+':'):legacy+=1
if not audit.sign_object.verify(audit._log_to_string(entry),entry.signature,allow_legacy):invalid+=1
result.update(historical_audit_rows=historical,historical_signed_rows=signed,
historical_unsigned_rows=unsigned,historical_invalid_signatures=invalid,
historical_legacy_signatures=legacy,all_stored_audit_rows_checked=True,
legacy_signature_verification_enabled=allow_legacy,
event_completeness_claimed=False)
stage='new_isolated_audit_entry'
audit.log({'action':'NK49 isolated recovery verification','success':True,
'info':'disposable restored database only','client':'127.0.0.1'})
audit.finalize_log()
fresh=Audit(app.config)
assert fresh.session.query(LogEntry).count()==historical+1
entry=fresh.session.query(LogEntry).order_by(LogEntry.id.desc()).first()
message=fresh._log_to_string(entry)
assert fresh.sign_object.verify(message,entry.signature,False)
assert not fresh.sign_object.verify(message+' tampered',entry.signature,False)
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
public=rsa.generate_private_key(public_exponent=65537,key_size=2048).public_key().public_bytes(
serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
assert not Sign(public_key=public).verify(message,entry.signature,False)
result.update(new_audit_entry_signed_verified=True,tampered_entry_refused=True,
wrong_audit_public_key_refused=True,
audit_signature_continuity_proven=signed==historical and invalid==0,
only_loopback_interface_verified=True,external_routes_absent=True,
status='native_checks_completed')
except Exception as error:
result.update(status='failed',failed_stage=stage,failure_type=type(error).__name__)
print(json.dumps(result))
'''
def native_checks(root, receipt):
stem = 'nk49-pi-check-' + uuid.uuid4().hex
database = stem + '-db'
worker = stem + '-worker'
try:
start_database(database)
with (root / 'databases/privacyidea.dump').open('rb') as dump:
run(['docker', 'exec', '-i', database, 'pg_restore', '-U', 'postgres', '-d', 'recovery',
'--no-owner', '--no-privileges', '--exit-on-error'], stdin=dump, timeout=600)
start_worker(worker, database, root)
receipt['container_isolation_verified_before_private_import'] = True
result = json.loads(run(['docker', 'exec', worker, 'python3', '-c', PROBE], timeout=600))
receipt.update(result)
if result.get('status') != 'native_checks_completed':
raise bundle.Refused()
finally:
cleanup([worker, database])
receipt['container_cleanup_verified'] = True
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--transfer-receipt', required=True)
parser.add_argument('--receipt', required=True)
args = parser.parse_args()
os.umask(0o077)
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
result = {'schema': 'netkingdom.isolated-privacyidea-recovery.v1', 'status': 'failed',
'production_contacted': False, 'secret_values_exported': False,
'existing_token_mfa_proven': False, 'audit_signature_continuity_proven': False,
'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False,
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
keyfd = None
work = None
stage = 'target_preflight'
try:
result['target'] = preflight()
stage = 'transfer_validation'
transfer = json.loads(Path(args.transfer_receipt).read_text())
local.offline.validate(transfer)
if transfer.get('independent_owner_retrieval_verified') is not True:
raise bundle.Refused()
path = Path(transfer['fetched_path'])
bundle.private(path, max_bytes=bundle.LIMIT)
if path.stat().st_size != transfer['ciphertext_bytes'] or local.offline.offsite.digest(path) != transfer['ciphertext_sha256']:
raise bundle.Refused()
keyfd = local.verify.custody.private_key_fd()
if run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip() != bundle.profile()['public_recipient'].encode():
raise bundle.Refused()
with tempfile.TemporaryDirectory(prefix='nk49-pi-recovery-', dir='/dev/shm') as temporary:
work = Path(temporary)
archive = work / 'archive.tar'
stage = 'archive_decryption'
with archive.open('xb') as output:
p = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)],
stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180)
if p.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024:
raise bundle.Refused()
restored = work / 'restored';restored.mkdir(mode=0o700)
stage = 'archive_validation'
manifest, _ = bundle.restore(archive, restored)
result.update(ciphertext_sha256=transfer['ciphertext_sha256'],
manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True)
stage = 'native_privacyidea_checks'
native_checks(restored, result)
result['status'] = 'isolated_privacyidea_checked'
except (Exception, KeyboardInterrupt):
result.update(status='failed', error='isolated_privacyidea_check_failed_private_diagnostics_suppressed')
result.setdefault('failed_stage', stage)
finally:
if keyfd is not None:os.close(keyfd)
if work is not None and not work.exists():result['temporary_plaintext_cleanup_verified'] = True
result['finished_at'] = datetime.now(timezone.utc).isoformat()
with os.fdopen(fd, 'w') as output:
json.dump(result, output, indent=2);output.write('\n')
if result['status'] == 'failed':
print('ERROR: isolated privacyIDEA check failed; review safe receipt. No production change.')
return 1
if not result['audit_signature_continuity_proven']:
print('REVIEW: token-material checks completed; historical audit exceptions require owner review. Cleanup passed; see safe receipt.')
return 2
print('OK: retained token material decrypted internally; historical/new audit signatures and negative checks passed; cleanup passed. Existing-token MFA remains separate.')
return 0
if __name__ == '__main__':
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):signal.signal(sig, local.offline.interrupted)
raise SystemExit(main())