isolated-privacyidea-mfa.py restores the privacyIDEA database in the accepted network-none stage and runs privacyIDEA's own check_serial_pass three times in separate processes: wrong key, wrong code, the owner's code. The serial list is terminal-only; receipts carry booleans, categories and a serial hash. A first fixture run was vacuous (PRIVACYIDEA_CONFIGFILE overrode the wrong-key config); the probe now fixes the variable and asserts the key path. Eight native fixture tests pass (HOTP, TOTP with PIN, negatives, encrypted bundle). The user-to-token resolver is not proven here and is recorded as such. T19 awaits the founder run. 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
259 lines
13 KiB
Python
Executable file
259 lines
13 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Owner-run native privacyIDEA existing-token MFA check on the restored database.
|
|
|
|
The owner selects one retained token and types its PIN+OTP privately. The restored
|
|
token material is checked by privacyIDEA's own validation code inside a loopback-only
|
|
disposable container: first with a wrong encryption key, then with a wrong code, then
|
|
with the code as typed. Serials and codes never enter receipts, argv or environment.
|
|
"""
|
|
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
|
|
SERIAL = re.compile(r'[A-Za-z0-9_.:-]{1,64}')
|
|
|
|
INVENTORY = 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.models import Token
|
|
app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True)
|
|
with app.app_context():
|
|
tokens=[{'serial':t.serial,'type':t.tokentype,'active':bool(t.active),'locked':bool(t.locked),
|
|
'revoked':bool(t.revoked),'otplen':t.otplen} for t in Token.query.order_by(Token.serial).limit(200)]
|
|
result={'status':'ok','tokens':tokens}
|
|
except Exception as error:result={'status':'failed','failure_type':type(error).__name__}
|
|
print(json.dumps(result))
|
|
'''
|
|
|
|
# One check per process: privacyIDEA loads its encryption module once per app.
|
|
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')
|
|
from privacyidea.lib.token import check_serial_pass
|
|
from privacyidea.models import Token
|
|
token=Token.query.filter_by(serial=v['serial']).first()
|
|
assert token is not None
|
|
stage='native_validation'
|
|
accepted=False;refusal='none'
|
|
try:
|
|
accepted,reply=check_serial_pass(v['serial'],v['passcode'],options={})
|
|
accepted=bool(accepted)
|
|
message=str((reply or {}).get('message','')).lower()
|
|
refusal='none' if accepted else ('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,'token_type':token.tokentype,
|
|
'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))
|
|
'''
|
|
|
|
|
|
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 restore_and_check(root, receipt, ask):
|
|
"""ask(inventory) -> (serial, passcode) after the containers are contained."""
|
|
stem = 'nk49-pi-mfa-' + uuid.uuid4().hex
|
|
database, worker = stem + '-db', 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 = 'worker_launch'
|
|
pi.start_worker(worker, database, root)
|
|
receipt['container_isolation_verified_before_private_import'] = True
|
|
stage = 'token_inventory'
|
|
inventory = worker_json(worker, INVENTORY)
|
|
if inventory.get('status') != 'ok' or not inventory['tokens']:
|
|
raise bundle.Refused()
|
|
tokens = {t['serial']: t for t in inventory['tokens']}
|
|
receipt['retained_tokens_inventoried'] = len(tokens)
|
|
stage = 'owner_selection'
|
|
serial, passcode = ask(inventory['tokens'])
|
|
if serial not in tokens or not passcode or len(passcode) > 256:
|
|
raise bundle.Refused()
|
|
chosen = tokens[serial]
|
|
if not chosen['active'] or chosen['locked'] or chosen['revoked']:
|
|
raise bundle.Refused()
|
|
receipt.update(selected_token_serial_sha256=hashlib.sha256(serial.encode()).hexdigest(),
|
|
selected_token_type=chosen['type'])
|
|
steps = (('wrong_key', passcode), ('wrong_code', different_code(passcode)), ('correct', passcode))
|
|
outcomes = {}
|
|
for mode, code in steps:
|
|
stage = 'check_' + mode
|
|
outcome = worker_json(worker, CHECK, {'mode': mode, 'serial': serial, 'passcode': code})
|
|
if outcome.get('status') != 'completed':
|
|
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()))
|
|
# Order matters: a replay-protected code is spent only by the last, accepted check.
|
|
proven = (receipt['wrong_key_refused'] and receipt['wrong_code_refused']
|
|
and receipt['existing_token_native_check_accepted'])
|
|
receipt['existing_token_mfa_proven'] = proven
|
|
if not proven:
|
|
stage = 'acceptance'
|
|
raise bundle.Refused()
|
|
except Exception:
|
|
receipt.setdefault('failed_stage', stage)
|
|
raise
|
|
finally:
|
|
pi.cleanup([worker, database])
|
|
receipt['container_cleanup_verified'] = True
|
|
|
|
|
|
def owner_choice(tokens):
|
|
"""Terminal-only: serials are shown to the owner, never written to a receipt."""
|
|
from_tty = sys.stdin.isatty() and sys.stderr.isatty()
|
|
if not from_tty:
|
|
raise bundle.Refused()
|
|
print('Retained tokens (shown only on this terminal):')
|
|
for t in tokens:
|
|
flags = ','.join(x for x, on in (('inactive', not t['active']), ('locked', t['locked']),
|
|
('revoked', t['revoked'])) if on) or 'usable'
|
|
print(' %-24s %-8s otplen=%s %s' % (t['serial'], t['type'], t['otplen'], flags))
|
|
serial = input('Serial of the token whose code you will enter: ').strip()
|
|
if not SERIAL.fullmatch(serial):
|
|
raise bundle.Refused()
|
|
passcode = local.offline.private_prompt('Token PIN + current code, as typed at login (hidden): ')
|
|
return serial, passcode
|
|
|
|
|
|
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'] = pi.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_choice)
|
|
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())
|