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

@ -265,41 +265,53 @@ procedure) and your authenticator ready:
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
Prompts, in order: the `railiance-backup` age key (hidden); your login name (and
the realm, if more than one exists); then a token PIN plus current code exactly as
typed at login (hidden). Any one of your usable tokens works. 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:
**Why by user.** A first version checked a token by serial. On the real backup every
check stopped with `ResolverError`: real tokens belong to an LDAP user, and the
archived resolver `lldap-coulomb` points at the in-cluster address
`ldap://lldap.sso.svc.cluster.local:3890`, which does not exist in isolation. Fixture
tokens had no owner, so tests had not shown it, and the run correctly claimed no proof.
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.
**Topology.** The restored database container is the `--network none` anchor.
Restored LLDAP and the privacyIDEA worker join its namespace (`container:<anchor>`)
and talk over loopback only. All three are read-only, capabilities dropped, user
non-root, tmpfs-only, with isolation inspected before private import.
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.
**Resolver rewrite, disposable copy only.** After `pg_restore`, every `ldapresolver`
`LDAPURI` must equal the archived in-cluster address exactly, or the run is refused
as an owner decision. They are then repointed to `ldap://127.0.0.1:3890`. The stored
bind password is not touched, so the original encryption key must decrypt it and the
restored LLDAP must accept it before any user can be looked 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()`.
**Checks.** The call is `POST /validate/check` with user, realm and pass, through
privacyIDEA's own application, so stored policies and the resolver apply as for KeyCape:
**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`.
1. The user is found through the restored resolver and directory, and has a usable token.
2. **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; privacyIDEA returns
its own error 907.
3. **Wrong code.** The last digit of what you typed is changed; refused as a wrong value.
4. **Your code.** Accepted with the original key and the retained token material.
The order is deliberate: only the last step can spend a counter or replay-protected
code. An earlier fixture run in which the wrong-key step silently used the original key
showed why; the configuration assertion now prevents that vacuous result, and the
receipt claims proof only when all three outcomes line up.
The receipt carries booleans, bounded refusal categories (for example `error_907`,
`wrong_value`), token types, counts, a SHA-256 of the login and source hashes. No login,
PIN or code is recorded, and the disposable copy is destroyed. Fixture tests cover HOTP
and TOTP-with-PIN owned by an LDAP user, a wrong code alone, an unknown user, a malformed
original key, the rewrite refusing an unknown address, and an encrypted bundle through
`main()`.
**Limits.** Policies that call outside the namespace (for example event handlers that
send notifications) cannot succeed and are not exercised. Token ownership is checked for
the one user you name, not for every user.
## Unresolved inputs before execution