diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index d1f25a5..269a8e3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -291,6 +291,7 @@ | task | NK-WP-0049-T39 | done | — | workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md | | task | NK-WP-0049-T40 | done | — | workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md | | task | NK-WP-0049-T41 | done | — | workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md | +| task | NK-WP-0049-T42 | done | — | workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md | | task | NK-WP-0050-T01 | done | — | workplans/NK-WP-0050-playwright-consumer-registration.md | | task | NK-WP-0050-T02 | progress | — | workplans/NK-WP-0050-playwright-consumer-registration.md | | task | NK-WP-0050-T03 | wait | — | workplans/NK-WP-0050-playwright-consumer-registration.md | diff --git a/docs/identity-service-restore-packet.md b/docs/identity-service-restore-packet.md index b2ecb38..d8ca438 100644 --- a/docs/identity-service-restore-packet.md +++ b/docs/identity-service-restore-packet.md @@ -255,6 +255,52 @@ T20/T31 are done. Source/ciphertext/target bindings and the safe receipt are in `evidence/2026-10-16-identity-outage-recovery/actual-isolated-privacyidea-review.json`. Existing-token MFA remains T19; event completeness is not claimed. +## Prepared owner step: isolated existing-token MFA (T19) + +Prepared and fixture-tested 2026-10-24 under T42; not yet run on the actual +backup. Execute in your own terminal with a verified transfer receipt (the T23 +procedure) and your authenticator ready: + +```bash +bash tools/bootstrap-recovery/run-isolated-privacyidea-mfa.sh /path/to/verified/transfer.json +``` + +Prompts, in order: the `railiance-backup` age key (hidden); a token serial chosen +from the list printed on your terminal (serial, type, usable flag; the list never +enters the receipt); then that token's PIN plus current code exactly as typed at +login (hidden). Pick a token you hold and whose PIN you know. A TOTP code is valid +for tens of seconds, so enter it only when asked and keep the authenticator open. + +The restore is the privacyIDEA stage's: restored database and original keys in +`--network none` containers, read-only, capabilities dropped, user 1000, tmpfs +only, isolation inspected before private import. Three checks then run through +privacyIDEA's own `check_serial_pass`, each in a separate process: + +1. **Wrong key.** The application is pointed at a random key file; the run asserts + the configuration really names it. The typed code must be refused (native + decryption raises). This is the discriminating step: the same code is accepted + with the original key in step 3. +2. **Wrong code.** The final digit of what you typed is changed; it must be refused. +3. **Your code.** Accepted by the original key and retained token material. + +The order is deliberate. Only the last step can spend a counter or replay-protected +code. A first fixture run showed why: when the wrong-key step silently used the +original key, it accepted the code and the real check was then refused. The +configuration assertion now prevents that vacuous result, and the receipt only +claims proof when all three outcomes line up. + +The receipt carries booleans, bounded refusal categories, token type, a SHA-256 of +the serial, counts and source hashes only. No serial, PIN or code is recorded, and +the disposable copy is destroyed. Fixture tests cover HOTP and TOTP-with-PIN, a +wrong code alone, an unknown serial, a malformed original key and an encrypted +bundle through `main()`. + +**Not proven here:** the user-to-token path. KeyCape calls `/validate/check` with +user, realm and pass, so the restored LDAP resolver, its encrypted bind password +and the LLDAP lookup are exercised only by a chained run (T18 phase B). A policy +such as `otppin=userstore` would not apply to a serial-based check. The receipt +therefore records `user_to_token_resolver_proven: false`. + ## Unresolved inputs before execution - Named immutable disposable target, approved private browser route if required, diff --git a/tests/test_isolated_privacyidea_mfa.py b/tests/test_isolated_privacyidea_mfa.py new file mode 100644 index 0000000..d036e56 --- /dev/null +++ b/tests/test_isolated_privacyidea_mfa.py @@ -0,0 +1,163 @@ +"""Existing-token MFA on a restored synthetic privacyIDEA database (disposable fixtures only).""" +import importlib.util +import json +import os +from pathlib import Path + +import pytest +from test_identity_bundle import stage # noqa: F401 (fixture reuse) +from test_isolated_privacyidea_recovery import retained_state # noqa: F401 (fixture reuse) + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('pi_mfa', ROOT / 'tools/bootstrap-recovery/isolated-privacyidea-mfa.py') +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +CODE = r''' +import contextlib,io,json,logging +logging.disable(logging.CRITICAL) +with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + from privacyidea.app import create_app + from privacyidea.lib.token import get_tokens + app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True) + with app.app_context(): + out=[] + for t in get_tokens(): + res,pin,otp,_=t.get_otp() + out.append({'serial':t.token.serial,'otp':otp}) +print(json.dumps(out)) +''' + + +def test_wrong_code_keeps_shape_but_changes_value(): + assert m.different_code('123456') == '123450' + assert m.different_code('123450') == '123451' + assert len(m.different_code('pin123456')) == len('pin123456') + + +def test_serial_pattern_refuses_shell_and_path_characters(): + for bad in ('', 'a b', '../x', 'a;b', 'x' * 65): + assert not m.SERIAL.fullmatch(bad) + assert m.SERIAL.fullmatch('HOTP0001A2B3') + + +def retained_codes(worker): + return {x['serial']: x['otp'] for x in json.loads(m.run(['docker', 'exec', worker, 'python3', '-c', CODE]))} + + +def test_native_existing_token_passes_and_negatives_are_refused(retained_state): + root, _, worker = retained_state + codes = retained_codes(worker) + serial, otp = sorted(codes.items())[0] + result = {} + m.restore_and_check(root, result, lambda tokens: (serial, otp)) + assert result['existing_token_mfa_proven'] is True, result + assert result['wrong_key_refused'] and result['wrong_code_refused'] + assert result['existing_token_native_check_accepted'] + assert result['retained_tokens_inventoried'] == 2 and result['selected_token_type'] == 'hotp' + assert result['container_cleanup_verified'] and result['container_isolation_verified_before_private_import'] + assert result['only_loopback_interface_verified'] and result['external_routes_absent'] + text = json.dumps(result) + assert serial not in text and otp not in text + + +def test_native_wrong_code_alone_never_proves_mfa(retained_state): + root, _, worker = retained_state + serial, otp = sorted(retained_codes(worker).items())[0] + result = {} + with pytest.raises(m.bundle.Refused): + m.restore_and_check(root, result, lambda tokens: (serial, m.different_code(otp))) + assert result['failed_stage'] == 'acceptance' + assert result['existing_token_mfa_proven'] is False and not result['existing_token_native_check_accepted'] + assert result['container_cleanup_verified'] + + +def test_native_unknown_serial_is_refused_before_any_check(retained_state): + root, _, _ = retained_state + result = {} + with pytest.raises(m.bundle.Refused): + m.restore_and_check(root, result, lambda tokens: ('NOSUCHTOKEN', '123456')) + assert result['failed_stage'] == 'owner_selection' + assert 'wrong_key_refused' not in result and result['container_cleanup_verified'] + + +def test_native_malformed_original_key_does_not_prove_mfa(retained_state): + root, _, worker = retained_state + serial, otp = sorted(retained_codes(worker).items())[0] + (root / 'data/privacyidea/enckey').write_bytes(b'synthetic-malformed-key') + result = {} + with pytest.raises(m.bundle.Refused): + m.restore_and_check(root, result, lambda tokens: (serial, otp)) + assert result['existing_token_mfa_proven'] is False and result['container_cleanup_verified'] + + +def test_encrypted_bundle_to_native_mfa_and_tmpfs_cleanup(retained_state, stage, tmp_path, monkeypatch, capsys): # noqa: F811 + import shutil + import sys + root, _, worker = retained_state + serial, otp = sorted(retained_codes(worker).items())[0] + for key in ('enckey', 'private.pem', 'public.pem'): + shutil.copyfile(root / 'data/privacyidea' / key, stage / 'data/privacyidea' / key) + shutil.copyfile(root / 'databases/privacyidea.dump', stage / 'databases/privacyidea.dump') + resource = stage / 'resources/mfa/secret/privacyidea-config.json' + value = json.loads(resource.read_text()) + value['data'] = json.loads((root / 'resources/mfa/secret/privacyidea-config.json').read_text())['data'] + resource.write_text(json.dumps(value)) + configmap = stage / 'resources/mfa/configmap/privacyidea-cfg.json' + value = json.loads(configmap.read_text()) + value['data'] = {'pi.cfg': 'PI_CHECK_OLD_SIGNATURES = False\n'} + configmap.write_text(json.dumps(value)) + key = tmp_path / 'synthetic-age.key' + m.run(['age-keygen', '-o', str(key)]) + recipient = m.run(['age-keygen', '-y', str(key)]).decode().strip() + profile = m.bundle.profile() + profile['public_recipient'] = recipient + monkeypatch.setattr(m.bundle, 'profile', lambda: profile) + ciphertext = tmp_path / 'net-kingdom-identity-20261016T063159Z-0123456789ab.tar.age' + m.bundle.seal(stage, ciphertext, {'synthetic_native_fixture': True}) + transfer = tmp_path / 'transfer.json' + transfer.write_text(json.dumps({'schema': 'netkingdom.identity-offsite.v1', 'status': 'readback_verified', + 'provider': 'Storage Share', 'folder': 'Backup/railiance-backups/net-kingdom-identity', + 'artifact': ciphertext.name, 'ciphertext_bytes': ciphertext.stat().st_size, + 'ciphertext_sha256': m.local.offline.offsite.digest(ciphertext), 'etag': '"synthetic"', + 'fetched_path': str(ciphertext), 'independent_owner_retrieval_verified': True})) + monkeypatch.setattr(m.local.verify.custody, 'private_key_fd', lambda: os.open(key, os.O_RDONLY)) + monkeypatch.setattr(m, 'owner_choice', lambda tokens: (serial, otp)) + # main() binds restore_and_check's callback by name at call time. + receipt = tmp_path / 'receipt.json' + monkeypatch.setattr(sys, 'argv', ['isolated-privacyidea-mfa', '--transfer-receipt', str(transfer), + '--receipt', str(receipt)]) + assert m.main() == 0, receipt.read_text() + result = json.loads(receipt.read_text()) + assert result['status'] == 'isolated_privacyidea_mfa_verified' and result['existing_token_mfa_proven'] + assert result['user_to_token_resolver_proven'] is False + assert result['container_cleanup_verified'] and result['temporary_plaintext_cleanup_verified'] + assert not result['production_contacted'] and not result['secret_values_exported'] + text = capsys.readouterr().out + receipt.read_text() + assert serial not in text and otp not in text + + +ADD_TOTP = r''' +import contextlib,io,json,logging +logging.disable(logging.CRITICAL) +with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + from privacyidea.app import create_app + from privacyidea.lib.token import init_token + app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True) + with app.app_context(): + token=init_token({'type':'totp','genkey':1,'pin':'synthetic-pin','serial':'TOTPFIXTURE01'}) +print(json.dumps({'serial':'TOTPFIXTURE01'})) +''' + + +def test_native_totp_token_with_pin_passes_within_the_check_sequence(retained_state): + root, database, worker = retained_state + m.run(['docker', 'exec', worker, 'python3', '-c', ADD_TOTP]) + (root / 'databases/privacyidea.dump').write_bytes(m.run( + ['docker', 'exec', database, 'pg_dump', '-U', 'postgres', '-Fc', 'recovery'])) + code = retained_codes(worker)['TOTPFIXTURE01'] + result = {} + m.restore_and_check(root, result, lambda tokens: ('TOTPFIXTURE01', 'synthetic-pin' + code)) + assert result['selected_token_type'] == 'totp' and result['retained_tokens_inventoried'] == 3 + assert result['existing_token_mfa_proven'] is True, result + assert result['wrong_code_refusal'] in ('wrong_value', 'wrong_pin', 'other_refusal') diff --git a/tools/bootstrap-recovery/README.md b/tools/bootstrap-recovery/README.md index 50b7b21..d79ede5 100644 --- a/tools/bootstrap-recovery/README.md +++ b/tools/bootstrap-recovery/README.md @@ -38,6 +38,13 @@ wrong-password and wrong-client-secret refusal. Notifier, NTP and healthcheck writes are confined; nothing leaves loopback. It imports `isolated-lldap.py` unedited. Synthetic native tests and the actual owner run (2026-10-24) passed; T17 is done. +`run-isolated-privacyidea-mfa.sh TRANSFER_RECEIPT` is the existing-token MFA step (T19, +prepared under T42). After the age key it shows a token list on the terminal, then asks +for a serial and the PIN+code you would type at login. `isolated-privacyidea-mfa.py` checks +a wrong key, a wrong code and your code through privacyIDEA's own validation on the +restored database. It reuses `isolated-privacyidea.py` unedited and does not prove the +user-to-token resolver. Fixture tests pass; the actual owner run is still open. + `run-isolated-privacyidea.sh TRANSFER_RECEIPT` is the token-material/audit recovery step. It asks only for the existing backup age key. `isolated-privacyidea.py` restores the linked dump into a loopback-only PostgreSQL/container namespace, reads diff --git a/tools/bootstrap-recovery/isolated-privacyidea-mfa.py b/tools/bootstrap-recovery/isolated-privacyidea-mfa.py new file mode 100755 index 0000000..db79cbc --- /dev/null +++ b/tools/bootstrap-recovery/isolated-privacyidea-mfa.py @@ -0,0 +1,259 @@ +#!/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()) diff --git a/tools/bootstrap-recovery/run-isolated-privacyidea-mfa.sh b/tools/bootstrap-recovery/run-isolated-privacyidea-mfa.sh new file mode 100755 index 0000000..92d67cb --- /dev/null +++ b/tools/bootstrap-recovery/run-isolated-privacyidea-mfa.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Owner-run existing-token MFA check. No key, PIN or code belongs in arguments. +set +x +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd -- "$ROOT" +umask 077 +[[ $# == 1 ]] || { printf '%s\n' 'Usage: run-isolated-privacyidea-mfa.sh VERIFIED_TRANSFER_RECEIPT'; exit 1; } +[[ -f "$1" && ! -L "$1" ]] || { printf '%s\n' 'ERROR: provide the existing verified transfer receipt.'; exit 1; } +[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; } +for tool in python3 age age-keygen openssl docker; do + command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; } +done +python3 - <<'PY' +import importlib.util +from pathlib import Path +p=Path('tools/bootstrap-recovery/isolated-privacyidea-mfa.py') +spec=importlib.util.spec_from_file_location('isolated',p) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +try: + m.preflight() +except Exception: + raise SystemExit('ERROR: exact local Docker target, pinned images or tmpfs preflight failed; no key requested.') +print('OK: exact bnt-lap001 recovery engine, pinned images and tmpfs available.') +PY +NK49_PI_RUN="$(mktemp -d /tmp/nk49-isolated-privacyidea-mfa-XXXXXXXX)" +printf 'Safe evidence directory: %s\n' "$NK49_PI_RUN" +printf '%s\n' 'This restores the linked database and original keys only into disposable network-isolated containers.' +printf '%s\n' 'Prompts, in order: the backup age key (hidden), then a token serial from the list shown on this terminal,' +printf '%s\n' 'then that token PIN+current code as you type it at login (hidden). Have the authenticator ready first.' +printf '%s\n' 'Checks: wrong key refused, wrong code refused, your code accepted. The restored copy is disposable; production is untouched.' +if bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \ + --prompt -- python3 tools/bootstrap-recovery/isolated-privacyidea-mfa.py \ + --transfer-receipt "$1" --receipt "$NK49_PI_RUN/recovery.json"; then + printf 'Safe receipt: %s/recovery.json\n' "$NK49_PI_RUN" +else + NK49_PI_EXIT=$? + printf 'Check stopped or needs review. Safe receipt: %s/recovery.json\n' "$NK49_PI_RUN" + exit "$NK49_PI_EXIT" +fi +printf '%s\n' 'Return only the outcome and evidence directory. The user-to-token resolver and the KeyCape consumer run remain separate.' diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index a401903..246057b 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -566,8 +566,8 @@ id: NK-WP-0049-T19 status: wait priority: high assignee: codex -depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T31] -blocking_reason: "LLDAP T16, token-material T31 and audit T20 passed; await the exact isolated MFA adapter and owner-selected retained-token/factor inputs." +depends_on: [NK-WP-0049-T15, NK-WP-0049-T16, NK-WP-0049-T31, NK-WP-0049-T42] +blocking_reason: "LLDAP T16, token-material T31 and audit T20 passed; T42 stage prepared and fixture-tested 2026-10-24. Await the founder-attended run: run-isolated-privacyidea-mfa.sh with a verified transfer receipt, the age key and a token PIN+code." state_hub_task_id: "6f8af1a7-2a3f-5fa8-8202-83103ebd55b2" ``` @@ -1536,6 +1536,37 @@ sources are unedited; `isolated-keycape.py` still hashes to the T38 pin `86f37add`. The packet section "Prepared owner step: isolated Authelia recovery (T17)" gives the exact command. +## Prepare and fixture-test the isolated existing-token MFA stage + +```task +id: NK-WP-0049-T42 +status: done +priority: high +assignee: claude-code +depends_on: [NK-WP-0049-T31, NK-WP-0049-T41] +``` + +**Accountable worker:** claude-code; the founder holds the token, its PIN and the +age key, and runs the T19 stage. + +Completed 2026-10-24. `isolated-privacyidea-mfa.py`, `run-isolated-privacyidea-mfa.sh` +and `tests/test_isolated_privacyidea_mfa.py` add the T19 stage: the privacyIDEA +stage's restore and containment helpers (reused unedited), a terminal-only token list, +and three native `check_serial_pass` runs in separate processes (wrong key, wrong +code, owner's code). Eight opt-in native fixture tests pass on the pinned images, +including HOTP, TOTP with a PIN, a wrong code alone, an unknown serial, a malformed +original key and an encrypted-bundle run through `main()`. + +A first fixture run was vacuous and the acceptance gate caught it: privacyIDEA +applies `PRIVACYIDEA_CONFIGFILE` after the `config_file` argument, so the wrong-key +run used the original key, accepted the code and spent the HOTP counter. The probe +now sets the variable to the file in use and asserts the intended `PI_ENCFILE`. + +Residual, recorded in the receipt as `user_to_token_resolver_proven: false`: KeyCape +validates by user and realm, so the restored LDAP resolver, its encrypted bind +password and the LLDAP user lookup need a chained run (T18 phase B). This prepares +T19; it is not T19 acceptance and touches no production service. + ## Accept remaining recovery and caller evidence and close the G08 handoff ```task