"""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', 'type', '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}