coulomb-social/scripts/native_recovery/probe.py
tegwick f763379882
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m5s
Verify native reset authority across account classes
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-31 13:40:10 +01:00

193 lines
11 KiB
Python

"""Synthetic native peers only; stdin is private, stdout is bounded metadata."""
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def run(v):
assert {p.name for p in Path('/sys/class/net').iterdir()} == {'lo'}
assert len(Path('/proc/net/route').read_text().splitlines()) == 1
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
cookie = ''
target = v.get('target', 'alice')
def call(port, path, data=None, *, token=None, auth=False):
nonlocal cookie
assert port in {17170, 9091} and path.startswith('/')
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = 'Bearer ' + token
if auth:
headers.update({'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'auth.recovery.test',
'X-Forwarded-For': '127.0.0.1', 'Cookie': cookie})
request = urllib.request.Request(
f'http://127.0.0.1:{port}' + path,
data=json.dumps(data).encode() if data is not None else None, headers=headers)
try:
response = opener.open(request, timeout=10)
except urllib.error.HTTPError as error:
response = error
with response:
if auth and response.headers.get('Set-Cookie'):
cookie = response.headers['Set-Cookie'].split(';', 1)[0]
raw = response.read()
return response.status, ({} if path == '/health' or response.status >= 400
else json.loads(raw or b'{}'))
def ready(port, path):
for _ in range(120):
try:
if call(port, path, auth=port == 9091)[0] == 200:
return
except (OSError, ValueError):
pass
time.sleep(.25)
raise RuntimeError('peer_startup_' + str(port))
def login(user, password):
code, body = call(17170, '/auth/simple/login', {'username': user, 'password': password})
return body.get('token') if code == 200 else None
def graphql(token, query, variables=None):
code, body = call(17170, '/api/graphql', {'query': query, 'variables': variables or {}},
token=token)
assert code == 200 and not body.get('errors'), 'directory_mutation_refused'
return body['data']
ready(17170, '/health')
admin = login('admin', v['admin_password'])
assert admin, 'synthetic_admin_login'
if v['mode'] == 'bootstrap':
for user in (['alice', 'svc-auth', 'bob', 'operator', 'appoperator', 'svc-other',
'victim'] if v.get('authority_matrix') else ['alice', 'svc-auth']):
graphql(admin, 'mutation($u:CreateUserInput!){createUser(user:$u){id}}', {
'u': {'id': user, 'email': user + '@example.test', 'displayName': user}})
if v.get('authority_matrix'):
groups = graphql(admin, '{groups{id displayName}}')['groups']
admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin')
for user, group in [('operator', admin_id), ('svc-other', next(
g['id'] for g in groups if g['displayName'] == 'lldap_strict_readonly'))]:
graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup('
'userId:$u,groupId:$g){ok}}', {'u': user, 'g': group})
app_id = graphql(admin, 'mutation{createGroup(name:"application-admins"){id}}')
graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup('
'userId:$u,groupId:$g){ok}}',
{'u': 'appoperator', 'g': app_id['createGroup']['id']})
graphql(admin, 'mutation{createGroup(name:"victim-group"){id}}')
return {'status': 'prepared', 'admin_token': admin}
if v['mode'] == 'bind_role':
groups = graphql(admin, '{groups{id displayName}}')['groups']
memberships = {g['id'] for g in graphql(admin,
'query($u:String!){user(userId:$u){groups{id}}}',
{'u': 'svc-auth'})['user']['groups']}
for group in ['lldap_strict_readonly', 'lldap_password_manager']:
group_id = next(g['id'] for g in groups if g['displayName'] == group)
if (group == v['role']) == (group_id in memberships):
continue
operation = 'addUserToGroup' if group == v['role'] else 'removeUserFromGroup'
graphql(admin, 'mutation($u:String!,$g:Int!){' + operation
+ '(userId:$u,groupId:$g){ok}}', {'u': 'svc-auth', 'g': group_id})
service = login('svc-auth', v['bind_password'])
assert service, 'service_directory_login'
code, body = call(17170, '/api/graphql', {
'query': 'mutation{createGroup(name:"unexpected-admin-rights"){id}}'}, token=service)
assert code >= 400 or body.get('errors'), 'directory_group_creation_refused'
return {'status': 'role_set', 'directory_group_creation_refused': True}
if v['mode'] == 'authority_context':
service = login('svc-auth', v['bind_password'])
assert service, 'service_directory_login'
return {'status': 'authority_context', 'service_token': service,
'admin_token': admin}
if v['mode'] == 'directory_authentication':
return {'old_password_accepted': bool(login(v['target'], v['old_password'])),
'new_password_accepted': bool(login(v['target'], v['new_password']))}
if v['mode'] == 'write_boundaries':
service = login('svc-auth', v['bind_password'])
assert service, 'service_directory_login'
# Positive controls distinguish an authorization refusal from invalid queries.
create_user = 'mutation($u:CreateUserInput!){createUser(user:$u){id}}'
update_user = 'mutation($u:UpdateUserInput!){updateUser(user:$u){ok}}'
delete_user = 'mutation($u:String!){deleteUser(userId:$u){ok}}'
create_group = 'mutation($n:String!){createGroup(name:$n){id}}'
delete_group = 'mutation($g:Int!){deleteGroup(groupId:$g){ok}}'
add = 'mutation($u:String!,$g:Int!){addUserToGroup(userId:$u,groupId:$g){ok}}'
remove = 'mutation($u:String!,$g:Int!){removeUserFromGroup(userId:$u,groupId:$g){ok}}'
graphql(admin, create_user, {'u': {'id': 'control', 'email': 'control@example.test'}})
graphql(admin, update_user, {'u': {'id': 'control', 'email': 'changed@example.test'}})
gid = graphql(admin, create_group, {'n': 'control-group'})['createGroup']['id']
graphql(admin, add, {'u': 'control', 'g': gid})
graphql(admin, remove, {'u': 'control', 'g': gid})
graphql(admin, delete_group, {'g': gid})
graphql(admin, delete_user, {'u': 'control'})
snapshot_query = '{users{id email displayName groups{id}} groups{id displayName}}'
before = graphql(admin, snapshot_query)
groups = before['groups']
admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin')
victim_id = next(g['id'] for g in groups if g['displayName'] == 'victim-group')
mutations = [
('create_user', create_user, {'u': {'id': 'unexpected', 'email': 'x@example.test'}}),
('edit_other_user', update_user, {'u': {'id': 'victim', 'email': 'x@example.test'}}),
('delete_user', delete_user, {'u': 'victim'}),
('create_group', create_group, {'n': 'unexpected'}),
('delete_group', delete_group, {'g': victim_id}),
('self_promote_admin', add, {'u': 'svc-auth', 'g': admin_id}),
('remove_admin_membership', remove, {'u': 'operator', 'g': admin_id})]
denials = []
for label, query, variables in mutations:
code, body = call(17170, '/api/graphql', {'query': query, 'variables': variables},
token=service)
assert code >= 400 or body.get('errors'), 'unexpected_directory_write_' + label
denials.append(label)
assert graphql(admin, snapshot_query) == before, 'directory_metadata_changed'
return {'positive_controls': len(mutations), 'refused_mutations': denials,
'directory_metadata_unchanged': True}
ready(9091, '/api/health')
if v['mode'] == 'reset_finish':
code, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True)
assert code != 200 or body.get('status') == 'KO', 'reset_without_challenge_refused'
code, body = call(9091, '/api/reset-password/identity/finish',
{'token': v['reset_token']}, auth=True)
assert code == 200 and body.get('status') == 'OK', 'identity_finish'
replay, body = call(9091, '/api/reset-password/identity/finish',
{'token': v['reset_token']}, auth=True)
assert replay != 200 or body.get('status') != 'OK', 'identity_replay_refused'
reset, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True)
return {'status': ('reset_completed' if reset == 200 and body.get('status') in {None, 'OK'}
else 'reset_refused'),
'reset_http_status': reset, 'reset_response_status': body.get('status'),
'native_identity_finish': True,
'reset_without_verified_challenge_refused': True,
'consumed_identity_token_replay_refused': True,
'old_password_accepted_after': bool(login(target, v['old_password'])),
'new_password_accepted_after': bool(login(target, v['new_password']))}
assert login(target, v['old_password']), 'initial_password'
assert not login(target, v['new_password']), 'replacement_not_initially_accepted'
code, body = call(9091, '/api/firstfactor', {
'username': 'svc-auth', 'password': v['bind_password'], 'keepMeLoggedIn': False}, auth=True)
assert code != 200 or body.get('status') != 'OK', 'interactive_service_login_refused'
code, body = call(9091, '/api/firstfactor', {
'username': target, 'password': v['old_password'], 'keepMeLoggedIn': False}, auth=True)
assert code == 200 and body.get('status') == 'OK', 'native_first_factor'
cookie = ''
code, _ = call(9091, '/api/reset-password/identity/start', {'username': target}, auth=True)
assert code == 200, 'reset_start'
return {'status': 'reset_requested', 'only_loopback_interface': True,
'external_routes_absent': True, 'old_password_initially_accepted': True,
'new_password_initially_refused': True, 'native_first_factor': True,
'interactive_service_login_refused': True}
if __name__ == '__main__':
try:
print(json.dumps(run(json.load(sys.stdin))))
except Exception as exc:
print(json.dumps({'status': 'probe_failed', 'failure_type': type(exc).__name__,
'stage': (str(exc) if isinstance(exc, (AssertionError, RuntimeError))
else 'native_probe')}))
raise SystemExit(1)