Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
204 lines
8.6 KiB
Python
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())
|