94 lines
5.5 KiB
Python
94 lines
5.5 KiB
Python
"""Token-free dynamic evidence validation shared by the reusable site adapter."""
|
|
import hashlib
|
|
import ipaddress
|
|
import re
|
|
import json
|
|
import time
|
|
|
|
|
|
def digest(value):
|
|
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
|
|
|
|
|
|
def snapshot(nodes):
|
|
if not isinstance(nodes, list) or not nodes:
|
|
raise ValueError('Empty authorized node set')
|
|
fields = ('id', 'tag', 'relay', 'relay_port', 'sni', 'expected_exit', 'country')
|
|
result = []
|
|
for node in nodes:
|
|
if node.get('type') != 'anytls' or any(node.get(k) in (None, '') for k in fields):
|
|
raise ValueError('Unsupported protocol or incomplete independently registered expectations')
|
|
if type(node['id']) is not int or node['id']<1 or type(node['relay_port']) is not int or not 1<=node['relay_port']<=65535 or not isinstance(node['country'],str) or not re.fullmatch('[A-Z]{2}',node['country']):
|
|
raise ValueError('Invalid authorized node identity or expectation')
|
|
address=ipaddress.ip_address(node['expected_exit'])
|
|
if getattr(address,'scope_id',None):raise ValueError('Scoped exit expectation unsupported')
|
|
result.append({k: node[k] for k in fields})
|
|
for key in ('id', 'tag'):
|
|
if len({n[key] for n in result}) != len(result):
|
|
raise ValueError('Duplicate authorized node identity')
|
|
if len({(n['relay'], n['relay_port'], n['sni']) for n in result}) != len(result):
|
|
raise ValueError('Ambiguous authorized endpoint')
|
|
return sorted(result, key=lambda n: n['id'])
|
|
|
|
|
|
def audit(before, after, report_bytes, qa_bytes, binding, plan_sha, expected_qas, now=None, ttl=900):
|
|
now = time.time() if now is None else now
|
|
before, after = snapshot(before), snapshot(after)
|
|
if before != after or binding.get('snapshot_sha256') != digest(before):
|
|
raise ValueError('Authorized node set changed; regenerate and verify the complete set')
|
|
if binding.get('plan_sha256') != plan_sha:
|
|
raise ValueError('Wrong plan binding')
|
|
for label, raw in [('report', report_bytes), ('qa_rejections', qa_bytes)]:
|
|
if binding.get(label + '_sha256') != hashlib.sha256(raw).hexdigest():
|
|
raise ValueError('Evidence bytes changed')
|
|
report, qas = json.loads(report_bytes), json.loads(qa_bytes)
|
|
for stamp in (binding.get('verified_at'), report.get('verified_at'), qas.get('captured_at')):
|
|
if type(stamp) not in (int, float) or not 0 <= now-stamp <= ttl:
|
|
raise ValueError('Evidence expired or future dated')
|
|
if report.get('state') != 'live_verified' or len(report.get('results', [])) != len(before):
|
|
raise ValueError('Incomplete live verification')
|
|
expected = {(n['tag'], n['relay'], n['relay_port'], n['sni'], n['expected_exit'], n['country']) for n in before}
|
|
actual = set()
|
|
for n in report['results']:
|
|
if n.get('live') is not True or n.get('strict_tls') is not True:
|
|
raise ValueError('Live strict TLS required for every node')
|
|
actual.add(tuple(n.get(k) for k in ('tag', 'relay', 'relay_port', 'sni', 'exit_ip', 'country')))
|
|
if actual != expected:
|
|
raise ValueError('Node/endpoint/exit/country mismatch')
|
|
if qas.get('plan_sha256') != plan_sha or set(qas.get('users', {})) != set(expected_qas):
|
|
raise ValueError('QA evidence identity mismatch')
|
|
for key, identity in expected_qas.items():
|
|
q = qas['users'][key]
|
|
if q.get('id') != identity or q.get('http_status') != 403 or q.get('banned') is not True or q.get('excluded') is not True:
|
|
raise ValueError('QA account not fully revoked')
|
|
return {'nodes': len(before), 'snapshot_sha256': digest(before), 'verified_at': now}
|
|
|
|
|
|
def service_change(before, current):
|
|
"""Process movement is investigatory; never silently accept/rebase it."""
|
|
required = ('unit_sha256', 'binary_sha256', 'config_sha256', 'listeners')
|
|
if any(not before.get(k) or not current.get(k) for k in required):
|
|
return 'unknown'
|
|
if any(before[k] != current[k] for k in required) or current.get('active') is not True:
|
|
return 'conflict'
|
|
if any(before.get(k) != current.get(k) for k in ('pid', 'started', 'restarts')):
|
|
return 'investigate_restart'
|
|
return 'ready'
|
|
|
|
|
|
def provider_state(observed, node, now=None):
|
|
"""Canonical observations from the registered provider UI/API, never HTML substrings."""
|
|
now=time.time() if now is None else now
|
|
stamp=observed.get('captured_at')
|
|
if type(stamp) not in (int,float) or not 0<=now-stamp<=900:
|
|
raise ValueError('Provider observation expired')
|
|
if not isinstance(observed.get('dns'),list) or not isinstance(observed.get('relays'),list):
|
|
raise ValueError('Canonical DNS and relay observations required')
|
|
dns=[d for d in observed['dns'] if d.get('name')==node['tls_domain']]
|
|
relays=[r for r in observed['relays'] if r.get('host')==node['relay_host'] and r.get('port')==node['relay_port']]
|
|
if dns and (len(dns)!=1 or dns[0].get('type')!='A' or dns[0].get('address')!=node['node_address'] or dns[0].get('proxy') is not False):
|
|
raise ValueError('DNS provider resource conflict')
|
|
if relays and (len(relays)!=1 or relays[0].get('target')!=node['node_address'] or relays[0].get('listen_port')!=node['listen_port'] or relays[0].get('name')!='dpl-'+node['key']+'-anytls' or relays[0].get('proxy_protocol_in') is not False or relays[0].get('proxy_protocol_out') is not False):
|
|
raise ValueError('Relay provider resource conflict')
|
|
return {'dns':dns,'relays':relays}
|