diff --git a/docs/attended-procedure-inventory.md b/docs/attended-procedure-inventory.md index d6808e1..8bb82b1 100644 --- a/docs/attended-procedure-inventory.md +++ b/docs/attended-procedure-inventory.md @@ -27,7 +27,7 @@ runbook or a dedicated procedure. Missing runbooks are listed separately. | `postgresql/create-secrets.sh` | [README.md](../sso-mfa/k8s/postgresql/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | | `privacyidea/bootstrap-admin.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown | | `privacyidea/bootstrap-realm.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | unknown | -| `privacyidea/check-user-mfa-state.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `privacyidea/check-user-mfa-state.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | source repaired: explicit admitted private JWT, scoped read-only counts; live diagnostic delivery not admitted by this repair | | `privacyidea/create-secrets.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | | `privacyidea/enckey-bootstrap.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown | | `privacyidea/reconcile-lldap-resolver-live.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | attempted by Bernd Worsch 2026-08-27; no completed PASS receipt | diff --git a/docs/smooth-bootstrap-guide.md b/docs/smooth-bootstrap-guide.md index 00ca72e..cac9810 100644 --- a/docs/smooth-bootstrap-guide.md +++ b/docs/smooth-bootstrap-guide.md @@ -80,7 +80,7 @@ See T03 retrospective for past realm drift bumps (now partially automated via ru - Create platform-root user in LLDAP (via create-user.sh or LLDAP admin UI at lldap.coulomb.social). - Command example (with KUBECTL fallback): `cd sso-mfa/k8s/lldap && ./create-user.sh platform-root ...` (no --admin for non-root tests; use --admin only for platform admins via king path). - Self-enroll TOTP in privacyIDEA. -- Verify MFA state: `cd ../privacyidea && ./check-user-mfa-state.sh platform-root`. +- Verify MFA state: `bash sso-mfa/k8s/privacyidea/check-user-mfa-state.sh platform-root --token-file /absolute/private/diagnostic.jwt` after owner-admitted protected input. See the [diagnostic contract](../sso-mfa/k8s/privacyidea/README.md#scoped-factor-state-diagnostic); the KeyCape workload credential is not a general diagnostic delivery lane. Counts do not verify user existence or an MFA login. - Verify OIDC/KeyCape path: `cd ../keycape && ./verify-openbao-client.sh`. - Groups: net-kingdom-admins for platform-root. - Evidence: identity_account_created, identity_group_confirmed, mfa_*, oidc_login_verified. diff --git a/sso-mfa/k8s/lldap/dry-run-nonroot-user.sh b/sso-mfa/k8s/lldap/dry-run-nonroot-user.sh index 1b2d272..b39a62a 100755 --- a/sso-mfa/k8s/lldap/dry-run-nonroot-user.sh +++ b/sso-mfa/k8s/lldap/dry-run-nonroot-user.sh @@ -171,7 +171,13 @@ for u in users: echo "" echo "MFA / KeyCape readiness (using existing check scripts)..." cd ../privacyidea || true -bash ./check-user-mfa-state.sh platform-root 2>&1 | tail -5 || true +if [[ -n "${MFA_DIAGNOSTIC_TOKEN_FILE:-}" ]]; then + if ! bash ./check-user-mfa-state.sh "$USERNAME" --token-file "$MFA_DIAGNOSTIC_TOKEN_FILE"; then + echo "[UNVERIFIED] Factor-state diagnostic failed; no MFA readiness inferred." + fi +else + echo "[UNVERIFIED] Factor-state diagnostic requires owner-admitted private input; see privacyIDEA README." +fi cd ../keycape || true bash ./verify-openbao-client.sh 2>&1 | tail -3 || true diff --git a/sso-mfa/k8s/privacyidea/README.md b/sso-mfa/k8s/privacyidea/README.md index d31cf3b..8197823 100644 --- a/sso-mfa/k8s/privacyidea/README.md +++ b/sso-mfa/k8s/privacyidea/README.md @@ -207,3 +207,47 @@ Secret and restarting cannot restore these files; the deployment does not mount the DR Secrets. The former unmounted helper-pod example is withdrawn. Native recovery acceptance requires the matching database backup, existing-token checks and audit-signature verification before closure. + +## Scoped factor-state diagnostic + +`check-user-mfa-state.sh` reports only factor counts in `coulomb`. It no longer +reads the retired `keycape-pi-token` Secret, queries administrative realm/user +catalogues, or guesses that the historic `netkingdom` realm is accessible. + +```bash +bash sso-mfa/k8s/privacyidea/check-user-mfa-state.sh platform-root \ + --token-file /absolute/private/diagnostic.jwt +``` + +The file must contain an owner-admitted, unexpired provider admin-role JWT with +`tokenlist` visibility in `coulomb`, optionally followed by one newline. It and +its parent must be owned/private (normally 0600/0700), unlinked, and outside Git. +The helper does not retrieve, mint, renew, export or cache credentials. Missing +input stops before any credential or network access; raw values are never argv. + +RPF-WP-0040's renewable factor-reader lane is delivered to **KeyCape**, not this +workstation helper. That contract is not permission to extract its Secret or +share its identity. An attended diagnostic needs its own explicitly admitted +owner delivery/execution arrangement; this source change neither creates nor +claims one. Use owner credential routing before requesting that arrangement. +The historical resolver/admin-token lanes remain separately blocked. + +The helper uses the canonical HTTPS provider with system CA verification, or an +explicit literal loopback URL for a reviewed local forward/fixture. It refuses +other origins, redirects and ambient proxies. HTTP loopback is only as trustworthy +as the reviewed local process/forward. There is no insecure-TLS flag. + +The existing disabled, unassigned `KCFACTORSCOPE01` token proves realm visibility +before and after the lookup. Provider denial, missing/mismatched proof, malformed +responses, unknown enrollment states or incomplete pages return `unverified`, +never a successful zero. The diagnostic requests at most 1000 factors and refuses +larger/incomplete results rather than publishing partial counts. The provider +API is paginated; see the [upstream token API](https://privacyidea.readthedocs.io/en/v3.12.1/modules/api/token.html). + +JSON output contains total, active-confirmed, active-pending and inactive counts. +It deliberately records `user_existence: not_checked` and `mfa_login: not_tested`: +a scoped zero-factor response does not prove that a user exists, and counts do +not prove an MFA login. No token serial, metadata, provider error body or credential +is emitted. Separate HTTP observations are not an atomic provider snapshot. +Actual login remains `verify-t06.sh`/the supported user journey; pending enrollment +must be confirmed before it counts as an active confirmed factor. diff --git a/sso-mfa/k8s/privacyidea/check-user-mfa-state.sh b/sso-mfa/k8s/privacyidea/check-user-mfa-state.sh index 649a4cb..8d62f2d 100755 --- a/sso-mfa/k8s/privacyidea/check-user-mfa-state.sh +++ b/sso-mfa/k8s/privacyidea/check-user-mfa-state.sh @@ -1,89 +1,5 @@ #!/usr/bin/env bash -# check-user-mfa-state.sh — non-secret privacyIDEA MFA state diagnostic. -# -# Reports realm presence and user/token counts for a user without printing -# passwords, OTP seeds, admin JWTs, token serials, or token metadata. -# -# Usage: -# bash check-user-mfa-state.sh [username] - +# Scoped read-only diagnostic; see README.md. No automatic credential retrieval. +set +x set -euo pipefail - -USERNAME="${1:-platform-root}" -PI_URL="${PI_URL:-https://pink.coulomb.social}" -SSO_NAMESPACE="${SSO_NAMESPACE:-sso}" -KUBECTL="${KUBECTL:-kubectl}" - -TOKEN="$( - "$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \ - -o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || true -)" -if [[ -z "$TOKEN" ]]; then - TOKEN="$( - "$KUBECTL" get secret keycape-pi-token -n "$SSO_NAMESPACE" \ - -o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || true - )" -fi - -if [[ -z "$TOKEN" || "$TOKEN" == "PENDING_create-pi-token.sh" ]]; then - echo "[FAIL] keycape-pi-token is missing or still a placeholder." - exit 1 -fi - -api_get() { - local path="$1" - curl -sk -H "Authorization: $TOKEN" "$PI_URL$path" -} - -json_field() { - python3 -c "$1" -} - -echo "privacyIDEA MFA state for user: $USERNAME" -echo "Endpoint: $PI_URL" -echo "" - -REALM_RESP="$(api_get "/realm/")" -echo "Realms:" -printf '%s' "$REALM_RESP" | json_field ' -import json -import sys - -data = json.load(sys.stdin) -if not data.get("result", {}).get("status", False): - print(" [WARN] realm query returned status=false") - err = data.get("result", {}).get("error", {}) - if err: - print(" [WARN] error=" + str(err.get("message", err))) -realms = data.get("result", {}).get("value", {}) -if not realms: - print(" [WARN] no realms returned") -for name in sorted(realms): - print(" - " + name + " default=" + str(bool(realms[name].get("default")))) -' - -for realm in coulomb netkingdom; do - echo "" - echo "Realm: $realm" - USER_RESP="$(api_get "/user/?realm=$realm&username=$USERNAME")" - TOKEN_RESP="$(api_get "/token/?realm=$realm&user=$USERNAME")" - - printf '%s' "$USER_RESP" | json_field ' -import json -import sys - -data = json.load(sys.stdin) -users = data.get("result", {}).get("value", {}).get("users", []) -print(f" users={len(users)}") -' - printf '%s' "$TOKEN_RESP" | json_field ' -import json -import sys - -data = json.load(sys.stdin) -value = data.get("result", {}).get("value", {}) -tokens = value.get("tokens", []) if isinstance(value, dict) else [] -active = sum(1 for token in tokens if token.get("active", True)) -print(f" tokens={len(tokens)} active={active}") -' -done +exec python3 "$(dirname -- "$0")/mfa_state.py" "$@" diff --git a/sso-mfa/k8s/privacyidea/mfa_state.py b/sso-mfa/k8s/privacyidea/mfa_state.py new file mode 100644 index 0000000..e4a5de8 --- /dev/null +++ b/sso-mfa/k8s/privacyidea/mfa_state.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Read-only, scoped factor diagnostic. No credential discovery or issuance.""" +import argparse +import base64 +import json +import os +from pathlib import Path +import re +import ssl +import stat +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +REALM = 'coulomb' +PROBE = 'KCFACTORSCOPE01' +MAX_BODY = 1024 * 1024 +MAX_FACTORS = 1000 + + +class Refused(Exception): + pass + + +class QuietParser(argparse.ArgumentParser): + def error(self, message): + raise Refused('arguments') + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused('response') + result[key] = value + return result + + +def endpoint(value): + if not isinstance(value, str) or not value.isascii() or any(ord(c) <= 32 for c in value): + raise Refused('endpoint') + parsed = urllib.parse.urlsplit(value) + if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or parsed.path not in ('', '/'): + raise Refused('endpoint') + if value.rstrip('/') in ('https://pink.coulomb.social', 'https://pink.coulomb.social:443'): + return value.rstrip('/') + # Literal loopback only: an explicitly selected local fixture/approved forward. + if (parsed.hostname not in ('127.0.0.1', '::1') or parsed.scheme not in ('http', 'https') + or parsed.port is None or not 1 <= parsed.port <= 65535): + raise Refused('endpoint') + expected = f'{parsed.scheme}://{("[::1]" if parsed.hostname == "::1" else parsed.hostname)}:{parsed.port}' + if value.rstrip('/') != expected: + raise Refused('endpoint') + return expected + + +def credential(path): + path = Path(os.path.abspath(path)) + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('credential') + if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() for p in path.parents): + raise Refused('credential') + parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + info = os.fstat(parent) + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Refused('credential') + fd = os.open(path.name, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=parent) + try: + before = os.fstat(fd) + if (not stat.S_ISREG(before.st_mode) or before.st_uid != os.getuid() + or before.st_mode & 0o077 or before.st_nlink != 1 or before.st_size > 16385): + raise Refused('credential') + raw = os.read(fd, 16386) + after = os.fstat(fd) + if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns): + raise Refused('credential') + finally: + os.close(fd) + finally: + os.close(parent) + token = raw.decode('ascii').removesuffix('\n') + if len(token) > 16384 or not re.fullmatch(r'[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+', token): + raise Refused('credential') + claims = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '==='), object_pairs_hook=unique_object) + # A structural preflight only. The provider must verify signature and expiry. + if (not isinstance(claims, dict) or claims.get('role') != 'admin' + or type(claims.get('exp')) not in (int, float) or claims['exp'] <= time.time()): + raise Refused('credential') + return token + + +class Client: + def __init__(self, base, token): + self.base = endpoint(base) + self.token = token + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # Use system trust, not ambient SSL_CERT_FILE/SSL_CERT_DIR overrides. + context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile) + self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), + urllib.request.HTTPSHandler(context=context)) + + def get(self, query): + req = urllib.request.Request(self.base + '/token/?' + urllib.parse.urlencode(query), + headers={'Authorization': self.token, 'Accept': 'application/json'}, method='GET') + try: + with self.opener.open(req, timeout=10) as response: + if response.status != 200: + raise Refused('provider') + raw = response.read(MAX_BODY + 1) + if len(raw) > MAX_BODY: + raise Refused('response') + document = json.loads(raw, object_pairs_hook=unique_object) + result = document.get('result') if isinstance(document, dict) else None + if not isinstance(result, dict) or result.get('status') is not True: + raise Refused('provider') + return result.get('value') + except urllib.error.HTTPError as exc: + exc.close() + raise Refused('provider') from None + except (urllib.error.URLError, OSError, ValueError): + raise Refused('provider') from None + + +def complete_page(value): + if not isinstance(value, dict): + raise Refused('response') + count, rows = value.get('count'), value.get('tokens') + if (type(count) is not int or not 0 <= count <= MAX_FACTORS or not isinstance(rows, list) + or count != len(rows) or type(value.get('current')) is not int or value['current'] != 1 + or value.get('next') is not None or value.get('prev') is not None + or any(not isinstance(row, dict) for row in rows)): + raise Refused('response') + serials = [row.get('serial') for row in rows] + if any(not isinstance(s, str) or not s or len(s) > 256 for s in serials) or len(set(serials)) != len(rows): + raise Refused('response') + return rows + + +def prove_scope(client): + rows = complete_page(client.get({'serial': PROBE, 'tokenrealm': REALM, 'page': 1, 'pagesize': 2})) + if (len(rows) != 1 or rows[0]['serial'] != PROBE or rows[0].get('active') is not False + or rows[0].get('user_id') != '' or rows[0].get('realms') != [REALM]): + raise Refused('visibility') + + +def diagnose(client, username): + # Avoid provider wildcard and user@realm expansion; support literal estate IDs. + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', username): + raise Refused('username') + prove_scope(client) + rows = complete_page(client.get({'user': username, 'realm': REALM, 'tokenrealm': REALM, + 'page': 1, 'pagesize': MAX_FACTORS, 'sortby': 'serial', 'sortdir': 'asc'})) + counts = {'total': len(rows), 'active_confirmed': 0, 'active_pending': 0, 'inactive': 0} + for row in rows: + if (row.get('username') != username or row.get('user_realm') != REALM + or not isinstance(row.get('realms'), list) or REALM not in row['realms'] + or type(row.get('active')) is not bool): + raise Refused('response') + if not row['active']: + counts['inactive'] += 1 + elif row.get('rollout_state') in ('', 'enrolled'): + counts['active_confirmed'] += 1 + elif row.get('rollout_state') in ('verify', 'clientwait', 'pending'): + counts['active_pending'] += 1 + else: + raise Refused('response') + # Do not turn a successful empty provider response after withdrawal into zero. + prove_scope(client) + return {'result': 'observed', 'realm': REALM, 'user': username, 'factors': counts, + 'reader_visibility': 'verified', 'user_existence': 'not_checked', 'mfa_login': 'not_tested'} + + +def main(): + phase = 'input' + try: + parser = QuietParser(description=__doc__) + parser.add_argument('username', nargs='?', default='platform-root') + parser.add_argument('--token-file', required=True, help='Owner-admitted private JWT file outside Git; no automatic retrieval') + parser.add_argument('--pi-url', default=os.environ.get('PI_URL', 'https://pink.coulomb.social')) + args = parser.parse_args() + base = endpoint(args.pi_url) + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', args.username): + raise Refused('username') + token = credential(args.token_file) + phase = 'diagnostic' + result = diagnose(Client(base, token), args.username) + print(json.dumps(result, sort_keys=True)) + return 0 + except Exception: + # Never render exception text, URLs, paths, raw provider errors or tokens. + print(json.dumps({'result': 'unverified', 'phase': phase}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/mfa_state_native_fixture.py b/tests/mfa_state_native_fixture.py new file mode 100644 index 0000000..4e71c4e --- /dev/null +++ b/tests/mfa_state_native_fixture.py @@ -0,0 +1,123 @@ +"""Disposable real-provider acceptance, invoked in a separate Python process.""" +import contextlib +import importlib.util +import io +import json +import logging +import os +from pathlib import Path +import sys +import tempfile +import threading +import time + + +def run(): + for name in list(os.environ): + if name.startswith('PRIVACYIDEA_'): del os.environ[name] + logging.disable(logging.CRITICAL) + root_repo = Path(__file__).resolve().parents[1] + spec = importlib.util.spec_from_file_location('mfa_state', root_repo/'sso-mfa/k8s/privacyidea/mfa_state.py') + m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) + from privacyidea.app import create_app + from privacyidea.models import db + from privacyidea.lib.policy import set_policy, enable_policy + from privacyidea.lib.resolver import save_resolver + from privacyidea.lib.realm import set_realm + from privacyidea.lib.auth import create_db_admin + from privacyidea.lib.resolvers.PasswdIdResolver import crypt_ctx + from privacyidea.lib.token import init_token + from privacyidea.lib.user import User + from werkzeug.serving import make_server + result = {} + with tempfile.TemporaryDirectory(prefix='mfa-native-') as directory: + root = Path(directory); (root/'enckey').write_bytes(os.urandom(96)) + cfg = root/'fixture.cfg' + config = dict(SQLALCHEMY_DATABASE_URI='sqlite:///'+str(root/'db.sqlite'), + SECRET_KEY='disposable-native-fixture', PI_PEPPER='disposable-native-pepper', + PI_NO_RESPONSE_SIGN=True, PI_AUDIT_NO_SIGN=True, PI_LOGFILE=str(root/'log'), + PI_ENCFILE=str(root/'enckey'), PI_TRUSTED_JWT=[], + PI_AUDIT_SQL_URI='sqlite:///'+str(root/'audit.sqlite')) + cfg.write_text('\n'.join(k+'='+repr(v) for k,v in config.items())) + app = create_app(config_name='testing', config_file=str(cfg), silent=True) + with app.app_context(): + assert str(db.engine.url) == config['SQLALCHEMY_DATABASE_URI']; db.create_all() + from privacyidea.lib.audit import getAudit + from privacyidea.lib.auditmodules.sqlaudit import LogEntry + LogEntry.__table__.create(getAudit(app.config).engine, checkfirst=True) + passwd = root/'users' + password = crypt_ctx.hash('fixture-user-password', scheme='sha512_crypt') + passwd.write_text('alice:'+password+':1001:1001:Fixture:/tmp:/bin/false\nbob:'+password+':1002:1002:Fixture:/tmp:/bin/false\n') + save_resolver({'resolver':'fixture-users','type':'passwdresolver','fileName':str(passwd)}) + set_realm('coulomb',[{'name':'fixture-users'}]) + create_db_admin('fixture-reader', password='fixture-reader-password') + set_policy(name='fixture-baseline',scope='admin',action='*',adminuser=['*','!fixture-reader']) + set_policy(name='fixture-reader',scope='admin',action='tokenlist',adminuser='fixture-reader',realm='coulomb') + probe = init_token({'serial':m.PROBE,'type':'hotp','genkey':1,'realm':'coulomb'}) + probe.token.active = False; probe.token.save() + for i in range(18): + init_token({'serial':'FIXTURE'+str(i),'type':'hotp','genkey':1},user=User('alice','coulomb')) + pending = init_token({'serial':'FIXTUREPENDING','type':'hotp','genkey':1},user=User('alice','coulomb')) + pending.token.rollout_state = 'verify'; pending.token.save() + inactive = init_token({'serial':'FIXTUREINACTIVE','type':'hotp','genkey':1},user=User('alice','coulomb')) + inactive.token.active = False; inactive.token.save() + native = app.test_client() + def login(user, password, realm=None): + args={'username':user,'password':password} + if realm: args['realm']=realm + response=native.post('/auth',json=args) + assert response.status_code==200 and response.get_json()['result']['status'] + return response.get_json()['result']['value']['token'] + token=login('fixture-reader','fixture-reader-password') + tokenfile=root/'token';tokenfile.write_text(token);tokenfile.chmod(0o600) + assert m.credential(tokenfile)==token + server=make_server('127.0.0.1',0,app); thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + client=m.Client(f'http://127.0.0.1:{server.server_port}',token) + counts=m.diagnose(client,'alice')['factors'] + assert counts=={'total':20,'active_confirmed':18,'active_pending':1,'inactive':1},counts + result['native_counts_beyond_default_page']=True + assert m.diagnose(client,'bob')['factors']['total']==0 + result['native_empty_with_visibility']=True + def refused(fn): + try: fn() + except m.Refused: return True + return False + with app.app_context(): enable_policy('fixture-reader',False) + raw=native.get('/token/?user=alice&realm=coulomb',headers={'Authorization':token}) + assert raw.status_code==200 and raw.get_json()['result']['value']['count']==0 + assert refused(lambda:m.diagnose(client,'alice')) + result['native_withdrawal_empty_success_refused']=True + with app.app_context(): enable_policy('fixture-reader',True) + assert m.diagnose(client,'alice')['factors']['total']==20 + result['native_permission_recovery']=True + usertoken=login('alice','fixture-user-password','coulomb') + assert refused(lambda:m.diagnose(m.Client(client.base,usertoken),'bob')) + result['native_self_service_cannot_prove_other_user_visibility']=True + assert refused(lambda:m.diagnose(m.Client(client.base,token[:-5]+'XXXXX'),'alice')) + result['native_tampered_signature_refused']=True + with app.app_context(): + set_policy(name='fixture-short-session',scope='webui',action='jwt_validity=2') + short=login('fixture-reader','fixture-reader-password') + import base64 + claims=json.loads(base64.urlsafe_b64decode(short.split('.')[1]+'===')) + remaining=claims['exp']-time.time();assert 0 < remaining < 3 + time.sleep(remaining+0.2) + assert refused(lambda:m.diagnose(m.Client(client.base,short),'alice')) + result['native_expired_provider_jwt_refused']=True + finally: + server.shutdown();thread.join();server.server_close() + return result + + +if __name__=='__main__': + try: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + result=run() + print(json.dumps(result,sort_keys=True)) + except Exception as exc: + # Fixture-only, sanitized: no database/HTTP body/credential traceback. + import traceback + frame = traceback.extract_tb(exc.__traceback__)[-1] + print(json.dumps({'result':'failed','type':type(exc).__name__, 'file':Path(frame.filename).name, 'line':frame.lineno, 'counts':exc.args[0] if exc.args and isinstance(exc.args[0],dict) and set(exc.args[0])=={'total','active_confirmed','active_pending','inactive'} else None})) + raise SystemExit(1) diff --git a/tests/test_mfa_state_diagnostic.py b/tests/test_mfa_state_diagnostic.py new file mode 100644 index 0000000..aea8ff4 --- /dev/null +++ b/tests/test_mfa_state_diagnostic.py @@ -0,0 +1,233 @@ +"""Diagnostic regressions: private inputs, actual HTTP transport, fail-closed counts.""" +import base64 +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +HELPERS = ROOT / 'sso-mfa/k8s/privacyidea' +spec = importlib.util.spec_from_file_location('mfa_state', HELPERS / 'mfa_state.py') +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +def jwt(**changes): + claims = {'role': 'admin', 'exp': time.time() + 300, **changes} + encoded = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b'=').decode() + return 'eyJhbGciOiJIUzI1NiJ9.' + encoded + '.syntheticSignature' + + +def page(rows): + return {'tokens': rows, 'count': len(rows), 'current': 1, 'next': None, 'prev': None} + + +def factor(serial='synthetic-factor', **changes): + return {'serial': serial, 'active': True, 'user_id': '1001', 'username': 'alice', + 'user_realm': 'coulomb', 'realms': ['coulomb'], 'rollout_state': 'enrolled', **changes} + + +def probe(**changes): + return factor(m.PROBE, active=False, user_id='', username='', **changes) + + +@pytest.fixture +def server(): + state = {'user': page([factor()]), 'proof': page([probe()]), 'requests': [], 'calls': 0} + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): pass + def do_GET(self): + state['requests'].append((self.path, dict(self.headers))) + state['calls'] += 1 + if state.get('status'): + self.send_response(state['status']) + self.send_header('Location', state.get('location', '/stolen')) + self.end_headers(); self.wfile.write(b'private-provider-message'); return + query = parse_qs(urlsplit(self.path).query) + value = state['proof'] if 'serial' in query else state['user'] + if state.get('withdraw_after_user') and state['calls'] >= 3: value = page([]) + body = json.dumps({'result': {'status': True, 'value': value}}).encode() + self.send_response(200); self.end_headers(); self.wfile.write(state.get('raw', body)) + s = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + t = threading.Thread(target=s.serve_forever, daemon=True); t.start() + try: yield f'http://127.0.0.1:{s.server_port}', state + finally: s.shutdown(); s.server_close(); t.join() + + +@pytest.fixture +def token_file(tmp_path): + private = tmp_path / 'private'; private.mkdir(mode=0o700) + path = private / 'input'; path.write_text(jwt() + '\n'); path.chmod(0o600) + return path + + +def invoke(token_file, url, *extra): + return subprocess.run(['bash', '-x', str(HELPERS / 'check-user-mfa-state.sh'), 'alice', + '--token-file', str(token_file), '--pi-url', url, *extra], + capture_output=True, text=True, timeout=15) + + +def test_real_http_cli_reports_complete_private_counts(server, token_file, monkeypatch): + url, state = server + state['user'] = page([factor(str(i)) for i in range(18)] + + [factor('pending', rollout_state='verify'), factor('inactive', active=False)]) + monkeypatch.setenv('http_proxy', 'http://127.0.0.1:1'); monkeypatch.setenv('HTTP_PROXY', 'http://127.0.0.1:1') + monkeypatch.setenv('no_proxy', ''); monkeypatch.setenv('NO_PROXY', '') + p = invoke(token_file, url) + assert p.returncode == 0, p.stderr + data = json.loads(p.stdout) + assert data['factors'] == {'total': 20, 'active_confirmed': 18, 'active_pending': 1, 'inactive': 1} + assert data['user_existence'] == 'not_checked' and data['mfa_login'] == 'not_tested' + assert len(state['requests']) == 3 + for path, headers in state['requests']: + assert urlsplit(path).path == '/token/' and 'Content-Type' not in headers + assert headers['Authorization'] == token_file.read_text().strip() + assert token_file.read_text().strip() not in p.stdout + p.stderr + assert str(token_file) not in p.stdout + p.stderr + assert 'synthetic-factor' not in p.stdout + + +@pytest.mark.parametrize('mutation', ['empty', 'assigned', 'active', 'realm', 'serial', 'count', 'withdraw']) +def test_scope_uncertainty_never_reports_zero(server, mutation): + url, state = server; state['user'] = page([]) + if mutation == 'empty': state['proof'] = page([]) + elif mutation == 'assigned': state['proof']['tokens'][0]['user_id'] = 'someone' + elif mutation == 'active': state['proof']['tokens'][0]['active'] = True + elif mutation == 'realm': state['proof']['tokens'][0]['realms'] = ['other'] + elif mutation == 'serial': state['proof']['tokens'][0]['serial'] = 'other' + elif mutation == 'count': state['proof']['count'] = 2 + elif mutation == 'withdraw': state['withdraw_after_user'] = True + with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), 'alice') + + +def test_empty_verified_result_is_not_proof_user_exists(server): + url, state = server; state['user'] = page([]) + data = m.diagnose(m.Client(url, jwt()), 'alice') + assert data['factors']['total'] == 0 and data['user_existence'] == 'not_checked' + + +@pytest.mark.parametrize('mutation', ['truncated', 'duplicate', 'next', 'count-bool', 'current-bool', 'wrong-user', 'wrong-realm', 'missing-active', 'missing-state', 'unknown-state']) +def test_malformed_or_incomplete_user_result_refused(server, mutation): + url, state = server; value = state['user']; row = value['tokens'][0] + if mutation == 'truncated': value['count'] = 20 + elif mutation == 'duplicate': value['tokens'].append(copy.deepcopy(row)); value['count'] = 2 + elif mutation == 'next': value['next'] = 2 + elif mutation == 'count-bool': value['count'] = True + elif mutation == 'current-bool': value['current'] = True + elif mutation == 'wrong-user': row['username'] = 'bob' + elif mutation == 'wrong-realm': row['user_realm'] = 'other' + elif mutation == 'missing-active': row.pop('active') + elif mutation == 'missing-state': row.pop('rollout_state') + elif mutation == 'unknown-state': row['rollout_state'] = 'new-unreviewed-state' + with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), 'alice') + + +@pytest.mark.parametrize('mode', ['public', 'symlink', 'hardlink', 'fifo', 'directory', 'public-parent', 'bad-jwt', 'self-service', 'expired', 'oversized', 'inside-git']) +def test_bad_input_precedes_any_network(server, token_file, mode): + url, state = server + if mode == 'public': token_file.chmod(0o644) + elif mode == 'symlink': token_file.rename(token_file.with_suffix('.real')); token_file.symlink_to(token_file.with_suffix('.real')) + elif mode == 'hardlink': os.link(token_file, token_file.with_suffix('.link')) + elif mode == 'fifo': token_file.unlink(); os.mkfifo(token_file, 0o600) + elif mode == 'directory': token_file.unlink(); token_file.mkdir(mode=0o700) + elif mode == 'public-parent': token_file.parent.chmod(0o755) + elif mode == 'bad-jwt': token_file.write_text('private-secret-not-a-jwt') + elif mode == 'self-service': token_file.write_text(jwt(role='user')) + elif mode == 'expired': token_file.write_text(jwt(exp=1)) + elif mode == 'oversized': token_file.write_bytes(b'x' * 16386) + elif mode == 'inside-git': (token_file.parent / '.git').mkdir(); (token_file.parent / '.git/HEAD').write_text('ref: refs/heads/main') + p = invoke(token_file, url) + assert p.returncode == 1 and not p.stdout and 'unverified' in p.stderr + assert 'private-secret' not in p.stderr and not state['requests'] + + +@pytest.mark.parametrize('url', ['https://attacker.invalid', 'http://pink.coulomb.social', 'http://localhost:8000', + 'http://127.0.0.1:8000/path', 'http://127.0.0.1:8000?x=1', + 'https://user:password@pink.coulomb.social', 'http://127.1:8000']) +def test_destination_is_checked_before_credential(url, tmp_path): + p = invoke(tmp_path/'absent', url) + assert p.returncode == 1 and not p.stdout and 'password' not in p.stderr + + +@pytest.mark.parametrize('status', [301, 302, 307, 308, 401, 403, 500]) +def test_no_redirect_or_provider_error_leak(server, token_file, status): + url, state = server; state['status'] = status + p = invoke(token_file, url) + assert p.returncode == 1 and not p.stdout + assert len(state['requests']) == 1 and 'private-provider-message' not in p.stderr + + +@pytest.mark.parametrize('raw', [b'{"result":{"status":false,"error":{"message":"private-provider-message"}}}', + b'{"result":{"status":true,"status":false}}', b'not-json', b'x'*(m.MAX_BODY+1)], ids=['error', 'duplicate', 'invalid', 'oversized']) +def test_bad_provider_body_is_sanitized(server, token_file, raw): + url, state = server; state['raw'] = raw + p = invoke(token_file, url) + assert p.returncode == 1 and not p.stdout and 'private-provider-message' not in p.stderr + + +def test_unknown_argument_is_not_echoed(server, token_file): + url, state = server + p = invoke(token_file, url, '--unknown=private-argument') + assert p.returncode == 1 and 'private-argument' not in p.stderr and not state['requests'] + + +@pytest.mark.parametrize('username', ['alice&realm=other', '*', 'alice@other', '../alice']) +def test_provider_expansion_is_refused_before_request(server, username): + url, state = server + with pytest.raises(m.Refused): m.diagnose(m.Client(url, jwt()), username) + assert not state['requests'] + + +def test_real_privacyidea_contract(): + pytest.importorskip('privacyidea') + p = subprocess.run([sys.executable, str(ROOT/'tests/mfa_state_native_fixture.py')], + capture_output=True, text=True, timeout=180) + assert p.returncode == 0, p.stdout + data = json.loads(p.stdout) + assert len(data) == 7 and all(v is True for v in data.values()) + + +def test_untrusted_https_is_not_accepted_via_ambient_ca(tmp_path, token_file, monkeypatch): + import datetime + import ipaddress + import ssl + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'fixture')]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=1)) + .not_valid_after(now+datetime.timedelta(hours=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), False) + .sign(key, hashes.SHA256())) + cert_path = tmp_path/'cert.pem'; key_path = tmp_path/'key.pem' + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) + key_path.chmod(0o600) + seen = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): pass + def do_GET(self): seen.append(dict(self.headers)); self.send_error(500) + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() + monkeypatch.setenv('SSL_CERT_FILE', str(cert_path)) + try: + p = invoke(token_file, f'https://127.0.0.1:{server.server_port}') + assert p.returncode == 1 and not p.stdout and not seen + assert token_file.read_text().strip() not in p.stderr + finally: + server.shutdown(); server.server_close(); thread.join() 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 a7ccccc..f109802 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -200,10 +200,10 @@ Remaining closure checklist (source review, not proof of current live use): repaired below, including private no-overwrite export and a real PVC recovery transport. Actual off-host backup lineage, reviewed image/cluster execution and native service/database/token/audit recovery acceptance remain T03 requirements. -- `privacyidea/check-user-mfa-state.sh`, linked by the smooth-bootstrap guide, - still reads retired `keycape-pi-token`, disables TLS verification, puts its token - in curl argv and prints provider error text. Reconcile with the current scoped - factor-reader owner contract and a bounded diagnostic interface. +- `privacyidea/check-user-mfa-state.sh` and its guide/dry-run callers are repaired + below: explicit private input, scoped counts and reader-visibility proof replace + retired Secret retrieval. Live diagnostic delivery/execution still requires + separate owner admission; KeyCape's workload lane is not silently shared. - `lldap/bootstrap-users.sh` still sources credentials, sends password/token in argv and can print auth responses. Inspect its group/user provisioning callers against the current User Engine owner path before replacing or retiring them. @@ -253,6 +253,21 @@ No production key, PVC, workload or Secret was read or changed for these tests. T02 remains progress for the remaining diagnostic/LDAP/realm callers and full inventory/lifecycle reconciliation. T03 remains the native recovery gate. +2026-10-13 scoped factor diagnostic repair: replaced retired admin-token Secret +retrieval with `mfa_state.py` and explicit private JWT input. The read-only helper +uses only `/token/` in `coulomb`, checks the existing disabled/unassigned realm +probe before and after lookup, refuses incomplete pages, and distinguishes +active-confirmed, active-pending and inactive counts. Empty results are not proof +of user existence or login. TLS verification, origin restriction, redirect/proxy +refusal, bounded responses and fixed errors prevent the original credential +argv/provider-error output paths. No automatic retrieval, issuance or permission +expansion is introduced. Guide and non-root dry-run now require explicit admitted +input; the latter checks its selected user rather than hard-coded platform-root. +Source/native disposable verification and limits are recorded in Custodian +`docs/evidence/2026-10-13-netkingdom-security/mfa-state-diagnostic.json`. +KeyCape-only RPF-WP-0040 delivery is not treated as authorization for this helper. +LDAP/realm callers, full inventory and T03 native recovery remain open. + ## Verify owner-approved recovery and current native callers ```task