NK-WP-0049-T42: rebuild the MFA stage around the restored directory; check by user and realm
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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
This commit is contained in:
tegwick 2026-10-24 18:42:35 +02:00
parent 9a3a84c99c
commit 5008e34f1a
6 changed files with 365 additions and 200 deletions

View file

@ -38,12 +38,12 @@ 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-mfa.sh TRANSFER_RECEIPT` is the existing-token MFA step (T19, prepared under
T42). After the age key it asks for your login name, then the PIN+code you would type at login.
`isolated-privacyidea-mfa.py` restores LLDAP, the database and the original keys into one network-free
namespace, repoints the archived LDAP resolver to the restored directory in the disposable copy only, and calls
`/validate/check` by user and realm, as KeyCape does: wrong key refused, wrong code refused, your code accepted.
It reuses `isolated-privacyidea.py` and `isolated-lldap.py` unedited. 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

View file

@ -1,10 +1,11 @@
#!/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.
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
@ -26,27 +27,57 @@ spec = importlib.util.spec_from_file_location('isolated_privacyidea', HERE / 'is
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}')
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'
INVENTORY = r'''
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.models import Token
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():
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}
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.
CHECK = r'''
# 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)
@ -73,25 +104,34 @@ try:
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'
accepted=False;refusal='none';code=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')
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,'token_type':token.tokentype,
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():
@ -111,54 +151,118 @@ def different_code(passcode):
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."""
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, worker = stem + '-db', stem + '-worker'
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 = 'token_inventory'
inventory = worker_json(worker, INVENTORY)
if inventory.get('status') != 'ok' or not inventory['tokens']:
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()
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:
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()
chosen = tokens[serial]
if not chosen['active'] or chosen['locked'] or chosen['revoked']:
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()
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:
for mode, code in (('wrong_key', passcode), ('wrong_code', different_code(passcode)), ('correct', passcode)):
stage = 'check_' + mode
outcome = worker_json(worker, CHECK, {'mode': mode, 'serial': serial, 'passcode': code})
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'],
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.
# 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()
@ -166,25 +270,25 @@ def restore_and_check(root, receipt, ask):
receipt.setdefault('failed_stage', stage)
raise
finally:
pi.cleanup([worker, database])
pi.cleanup([worker, directory, 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:
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('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
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():
@ -236,7 +340,7 @@ def main():
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)
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)

View file

@ -25,10 +25,10 @@ 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.'
printf '%s\n' 'This restores the privacyIDEA database, its original keys and the LLDAP directory into disposable network-free containers.'
printf '%s\n' 'Prompts, in order: the backup age key (hidden), your login name, then a token PIN+current code as typed at login (hidden).'
printf '%s\n' 'Have your authenticator open first. Checks: wrong key refused, wrong code refused, your code accepted, by user and realm.'
printf '%s\n' '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 \
@ -39,4 +39,4 @@ else
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.'
printf '%s\n' 'Return only the outcome and evidence directory. The KeyCape consumer run remains separate.'