security: replace retired MFA diagnostic credential discovery with scoped checks
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
tegwick 2026-10-13 22:24:35 +02:00
parent 1cbe00fd78
commit 96feef46ab
9 changed files with 635 additions and 94 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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