net-kingdom/tools/bootstrap-recovery/isolated-privacyidea-mfa.py
tegwick 5008e34f1a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0049-T42: rebuild the MFA stage around the restored directory; check by user and realm
The founder's first real run stopped at ResolverError on all checks: real tokens
belong to an LDAP user and the archived resolver names the in-cluster directory.
Restored LLDAP now joins the database's network-free namespace, archived resolver
addresses are repointed to loopback in the disposable copy only (any other address
is refused), and the checks call POST /validate/check by user and realm as KeyCape
does. Fifteen native fixture tests pass (HOTP and TOTP+PIN, negatives, bundle).
Also covers the user-to-token resolver and the encrypted bind password.

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

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 328925@bnt-lap001
Assistant-Session: 14b795fd-c632-4c01-8d28-e7e490c7b0ed
2026-10-24 18:42:35 +02:00

368 lines
19 KiB
Python
Executable file

#!/usr/bin/env python3
"""Owner-run native privacyIDEA existing-token MFA check on the restored database.
Restored LLDAP, the restored database and a privacyIDEA worker share one network-free
namespace. The owner names their login and types a token's PIN+OTP privately. The call
is POST /validate/check by user and realm, as KeyCape makes it, through the restored LDAP
resolver: first with a wrong encryption key, then with a wrong code, then as typed.
Codes never enter receipts, argv or environment; the login is stored only as a hash.
"""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import tempfile
import uuid
from datetime import datetime, timezone
HERE = Path(__file__).resolve().parent
# Reused unedited: container, isolation and cleanup helpers of the accepted stage.
spec = importlib.util.spec_from_file_location('isolated_privacyidea', HERE / 'isolated-privacyidea.py')
pi = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pi)
local, bundle, run = pi.local, pi.bundle, pi.run
LOGIN = re.compile(r'[A-Za-z0-9_.@-]{1,128}')
REALM = re.compile(r'[A-Za-z0-9_.-]{1,64}')
CLUSTER_LDAP = 'ldap://lldap.sso.svc.cluster.local:3890'
LOOPBACK_LDAP = 'ldap://127.0.0.1:3890'
REALMS = r"""
import contextlib,io,json,logging
logging.disable(logging.CRITICAL)
result={'status':'failed'}
try:
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
from privacyidea.app import create_app
from privacyidea.lib.realm import get_realms,get_default_realm
app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True)
with app.app_context():
names=sorted(get_realms());default=get_default_realm()
result={'status':'ok','realms':names,'default':default}
except Exception as error:result={'status':'failed','failure_type':type(error).__name__}
print(json.dumps(result))
"""
# Looks the user up through the restored resolver and the restored directory.
RESOLVE = r"""
import contextlib,io,json,logging,sys
logging.disable(logging.CRITICAL)
v=json.load(sys.stdin)
result={'status':'failed'}
stage='native_application_configuration'
try:
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
from privacyidea.app import create_app
app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True)
with app.app_context():
from privacyidea.lib.user import User
from privacyidea.lib.token import get_tokens
stage='user_lookup_through_restored_resolver'
user=User(v['login'],v['realm'])
found=bool(user.exist())
tokens=[]
if found:
stage='user_token_listing'
tokens=[{'type':t.type,'active':bool(t.is_active()),'locked':bool(t.token.locked),
'revoked':bool(t.token.revoked)} for t in get_tokens(user=user)]
result={'status':'ok','user_found':found,'tokens':tokens}
except Exception as error:result={'status':'failed','failed_stage':stage,'failure_type':type(error).__name__}
print(json.dumps(result))
"""
# One check per process: privacyIDEA loads its encryption module once per app.
# The call is the endpoint KeyCape uses, so stored policies and the resolver both apply.
CHECK = r"""
import contextlib,io,json,logging,os,sys
logging.disable(logging.CRITICAL)
v=json.load(sys.stdin)
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
config='/recovery/pi.cfg'
if v['mode']=='wrong_key':
stage='wrong_key_configuration'
original=Path(config).read_text()
assert '/recovery/enckey' in original
Path('/recovery/wrong-enckey').write_bytes(os.urandom(96))
Path('/recovery/pi-wrongkey.cfg').write_text(original.replace('/recovery/enckey','/recovery/wrong-enckey'))
config='/recovery/pi-wrongkey.cfg'
stage='native_application_configuration'
# create_app applies PRIVACYIDEA_CONFIGFILE after config_file, so it must name the file in use.
os.environ['PRIVACYIDEA_CONFIGFILE']=config
from privacyidea.app import create_app
app=create_app(config_name='testing',config_file=config,silent=True)
with app.app_context():
# A wrong-key run that silently used the original key would be vacuous.
assert app.config['PI_ENCFILE']==('/recovery/wrong-enckey' if v['mode']=='wrong_key' else '/recovery/enckey')
stage='native_validation'
accepted=False;refusal='none';code=None
try:
response=app.test_client().post('/validate/check',data={'user':v['login'],'realm':v['realm'],'pass':v['passcode']})
body=response.get_json(silent=True) or {}
outcome=body.get('result') or {}
accepted=response.status_code==200 and outcome.get('status') is True and outcome.get('value') is True
code=(outcome.get('error') or {}).get('code')
message=str((body.get('detail') or {}).get('message','')).lower()
if accepted:refusal='none'
elif code is not None:refusal='error_'+str(code)
else:refusal='wrong_pin' if 'pin' in message else 'wrong_value' if ('otp' in message or 'wrong' in message) else 'other_refusal'
except Exception as error:
accepted=False;refusal='exception_'+type(error).__name__[:40]
result={'status':'completed','accepted':accepted,'refusal':refusal,
'only_loopback_interface_verified':True,'external_routes_absent':True}
except Exception as error:result={'status':'failed','failed_stage':stage,'failure_type':type(error).__name__}
print(json.dumps(result))
"""
WAIT_LDAP = r"""
import socket,sys,time
for _ in range(240):
try:
with socket.create_connection(('127.0.0.1',3890),timeout=1):sys.exit(0)
except OSError:time.sleep(.25)
sys.exit(1)
"""
def preflight():
"""The launcher calls this before any key prompt."""
return pi.preflight()
def worker_json(worker, script, values=None, timeout=300):
args = ['docker', 'exec'] + (['-i'] if values is not None else []) + [worker, 'python3', '-c', script]
raw = run(args, input=None if values is None else json.dumps(values).encode(), timeout=timeout)
return json.loads(raw)
def different_code(passcode):
"""Same shape, different final digit: still a well-formed but wrong code."""
last = passcode[-1]
return passcode[:-1] + ('0' if last != '0' else '1')
def psql(database, statement):
out = run(['docker', 'exec', database, 'psql', '-U', 'postgres', '-d', 'recovery', '-v', 'ON_ERROR_STOP=1',
'-At', '-c', statement])
return out.decode().strip()
def rewrite_resolvers(database):
"""Point archived LDAP resolvers at the restored directory, in the disposable copy only.
Only the archived in-cluster address is accepted; anything else is an owner decision.
The encrypted bind password is left untouched so the original key must decrypt it.
"""
rows = psql(database, 'SELECT c."Value" FROM resolverconfig c JOIN resolver r ON r.id=c.resolver_id '
"WHERE r.rtype='ldapresolver' AND c.\"Key\"='LDAPURI'").splitlines()
if not rows or any(value != CLUSTER_LDAP for value in rows):
raise bundle.Refused()
changed = psql(database, 'UPDATE resolverconfig SET "Value"=\'' + LOOPBACK_LDAP + '\' WHERE "Key"=\'LDAPURI\' '
"AND resolver_id IN (SELECT id FROM resolver WHERE rtype='ldapresolver') "
"AND \"Value\"='" + CLUSTER_LDAP + "'")
if changed != 'UPDATE ' + str(len(rows)):
raise bundle.Refused()
return len(rows)
def start_directory(name, database, root):
"""Restored LLDAP joins the database's network-free namespace (loopback only)."""
config, jwt, admin_password, _ = local.configuration(root)
database_id = run(['docker', 'container', 'inspect', database, '--format', '{{.Id}}']).decode().strip()
run(['docker', 'run', '--detach', '--pull=never', '--name', name,
'--network', 'container:' + database_id, '--read-only', '--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
'--memory', '256m', '--cpus', '1', '--pids-limit', '128',
'--tmpfs', '/data:rw,nosuid,nodev,noexec,size=268435456,uid=1000,gid=1000,mode=0700',
'--tmpfs', '/run/recovery:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700',
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000',
'--env', 'LLDAP_JWT_SECRET_FILE=/run/recovery/jwt',
'--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/recovery/admin-password',
'--entrypoint', '/bin/sh', local.IMAGE, '-c', 'sleep 1800'])
pi.isolation(name, 'container:' + database_id, '1000:1000')
payload = {p.relative_to(root / 'data/lldap').as_posix(): p.read_bytes()
for p in (root / 'data/lldap').rglob('*') if p.is_file()}
run(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/data'], input=local.archive_files(payload))
run(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/recovery'],
input=local.archive_files({'lldap.toml': config, 'jwt': jwt, 'admin-password': admin_password}))
run(['docker', 'exec', '--detach', name, '/bin/sh', '-c',
'exec /app/lldap run --config-file /run/recovery/lldap.toml > /dev/null 2>&1'])
def restore_and_check(root, receipt, choose_user, choose_code):
"""choose_user(realms, default) -> (login, realm); choose_code(summary) -> PIN+code."""
stem = 'nk49-pi-mfa-' + uuid.uuid4().hex
database, directory, worker = stem + '-db', stem + '-dir', stem + '-worker'
stage = 'database_restore'
try:
pi.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)
stage = 'resolver_rewrite'
receipt['ldap_resolvers_rewritten_to_loopback'] = rewrite_resolvers(database)
stage = 'directory_launch'
start_directory(directory, database, root)
stage = 'worker_launch'
pi.start_worker(worker, database, root)
receipt['container_isolation_verified_before_private_import'] = True
stage = 'directory_startup'
run(['docker', 'exec', worker, 'python3', '-c', WAIT_LDAP], timeout=90)
stage = 'realm_listing'
realms = worker_json(worker, REALMS)
if realms.get('status') != 'ok' or not realms['realms']:
raise bundle.Refused()
stage = 'owner_user_selection'
login, realm = choose_user(realms['realms'], realms.get('default'))
if not LOGIN.fullmatch(login) or realm not in realms['realms']:
raise bundle.Refused()
stage = 'user_lookup_through_restored_resolver'
user = worker_json(worker, RESOLVE, {'login': login, 'realm': realm})
if user.get('status') != 'ok':
receipt['failed_stage'] = user.get('failed_stage', stage)
raise bundle.Refused()
receipt['user_resolved_through_restored_resolver_and_directory'] = bool(user['user_found'])
if not user['user_found']:
raise bundle.Refused()
usable = [t for t in user['tokens'] if t['active'] and not t['locked'] and not t['revoked']]
receipt.update(selected_user_sha256=hashlib.sha256(login.encode()).hexdigest(),
user_token_count=len(user['tokens']), user_usable_token_count=len(usable),
user_usable_token_types=sorted({t['type'] for t in usable}))
stage = 'owner_code_entry'
if not usable:
raise bundle.Refused()
passcode = choose_code({'tokens': user['tokens'], 'usable': len(usable)})
if not passcode or len(passcode) > 256:
raise bundle.Refused()
outcomes = {}
for mode, code in (('wrong_key', passcode), ('wrong_code', different_code(passcode)), ('correct', passcode)):
stage = 'check_' + mode
outcome = worker_json(worker, CHECK, {'mode': mode, 'login': login, 'realm': realm, 'passcode': code})
if outcome.get('status') != 'completed':
receipt['failed_stage'] = outcome.get('failed_stage', stage)
raise bundle.Refused()
outcomes[mode] = outcome
receipt.update(
wrong_key_refused=not outcomes['wrong_key']['accepted'], wrong_key_refusal=outcomes['wrong_key']['refusal'],
wrong_code_refused=not outcomes['wrong_code']['accepted'], wrong_code_refusal=outcomes['wrong_code']['refusal'],
existing_token_native_check_accepted=outcomes['correct']['accepted'],
only_loopback_interface_verified=all(o['only_loopback_interface_verified'] for o in outcomes.values()),
external_routes_absent=all(o['external_routes_absent'] for o in outcomes.values()))
# Only the last step can spend a counter or replay-protected code.
proven = (receipt['wrong_key_refused'] and receipt['wrong_code_refused']
and receipt['existing_token_native_check_accepted'])
receipt['existing_token_mfa_proven'] = proven
receipt['user_to_token_resolver_proven'] = proven
if not proven:
stage = 'acceptance'
raise bundle.Refused()
except Exception:
receipt.setdefault('failed_stage', stage)
raise
finally:
pi.cleanup([worker, directory, database])
receipt['container_cleanup_verified'] = True
def owner_user(realms, default):
"""Terminal-only prompts; the login is hashed in receipts."""
if not (sys.stdin.isatty() and sys.stderr.isatty()):
raise bundle.Refused()
print('Realms in the restored database: ' + ', '.join(realms) + (' (default: %s)' % default if default else ''))
login = input('Your login name (the user your token belongs to): ').strip()
realm = default or realms[0]
if len(realms) > 1:
realm = input('Realm [%s]: ' % realm).strip() or realm
return login, realm
def owner_code(summary):
print('Found %d token(s) for that user, %d usable. Any one of them may be used.' % (len(summary['tokens']), summary['usable']))
return local.offline.private_prompt('Token PIN + current code, as typed at login (hidden): ')
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-mfa.v1', 'status': 'failed',
'production_contacted': False, 'secret_values_exported': False,
'existing_token_mfa_proven': False, 'user_to_token_resolver_proven': False,
'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False,
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'reused_source_sha256': {name: hashlib.sha256((HERE / name).read_bytes()).hexdigest()
for name in ('isolated-privacyidea.py', 'isolated-lldap.py',
'identity-bundle.py', 'verify-identity.py',
'retrieve-identity-offline.py')}}
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_mfa_check'
restore_and_check(restored, result, owner_user, owner_code)
if work.exists():
raise bundle.Refused()
result.update(status='isolated_privacyidea_mfa_verified', temporary_plaintext_cleanup_verified=True)
except (Exception, KeyboardInterrupt):
result.update(status='failed', error='isolated_privacyidea_mfa_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')
ok = result['status'] == 'isolated_privacyidea_mfa_verified'
print('OK: restored token decrypted and passed the native MFA check; wrong key and wrong code were refused; cleanup passed.'
if ok else 'ERROR: isolated privacyIDEA MFA check refused; review the safe receipt. No production change.')
return 0 if ok else 1
if __name__ == '__main__':
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
signal.signal(sig, local.offline.interrupted)
raise SystemExit(main())