net-kingdom/sso-mfa/k8s/privacyidea/mfa_state.py
tegwick 96feef46ab
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
security: replace retired MFA diagnostic credential discovery with scoped checks
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
2026-10-13 22:24:35 +02:00

204 lines
8.6 KiB
Python

#!/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())