NK-WP-0049-T42: isolated existing-token MFA stage, fixture-tested
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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
This commit is contained in:
tegwick 2026-10-24 17:37:57 +02:00
parent 1959a184cb
commit 1f04c23392
7 changed files with 551 additions and 2 deletions

View file

@ -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 |

View file

@ -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,

View file

@ -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')

View file

@ -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

View file

@ -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())

View file

@ -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.'

View file

@ -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