#!/usr/bin/env python3 """verify_hof.py — verificador independiente del Salon de la Fama criptografico de wow-colombia (algoritmos wco-hof-merkle-v1 y wco-hof-merkle-v2). Solo biblioteca estandar. Uso: python3 verify_hof.py dump.json # recomputa y compara con lo que trae el dump python3 verify_hof.py dump.json --root # ademas compara con el root publicado (Discord/web) python3 verify_hof.py --proof proof.json # verifica una prueba de pertenencia (/rest/hof/proof/...) python3 verify_hof.py dump.json --proof p.json # ademas cruza la prueba contra los roots del dump Salida: "OK" y exit 0 si todo coincide; "FAIL ..." y exit 1 si no; exit 2 = "OK parcial" (solo --proof sin dump: la prueba es autoconsistente pero NO esta anclada a un season_root). Como se calcula (identico a arena_scheduler/hof_merkle.py): canonical(obj) = JSON con claves ordenadas, separadores "," ":" y UTF-8 sin escapar leaf = SHA256(0x00 || canonical(team)) node = SHA256(0x01 || left || right); nivel impar => se duplica el ultimo nodo bracket vacio => root = SHA256(0x00) season_root = SHA256(0x02 || canonical({"meta": meta, "roots": {bracket: root_hex}})) meta.hof_sha256 / meta.revocations_sha256 = SHA256(0x03 || canonical(lista)) — se recomputan desde las secciones "hof" y "revocations" del dump (un podio alterado cambia el season_root aunque el ladder sea el mismo) Versiones del algoritmo: v2 (formato actual de los miembros de cada equipo, ordenados por nombre) y v1 (formato anterior). La verificacion es identica en ambas — se hashea el objeto tal como viene en el dump — asi que un dump v1 descargado antes sigue validandose con este script. """ import argparse import hashlib import json import sys ALGORITHMS = ("wco-hof-merkle-v1", "wco-hof-merkle-v2") BRACKETS = ("2v2", "3v3", "5v5", "soloq") DOM_LEAF, DOM_NODE, DOM_SEASON, DOM_SECTION = b"\x00", b"\x01", b"\x02", b"\x03" def canonical_bytes(obj): return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def leaf_hash(obj): return hashlib.sha256(DOM_LEAF + canonical_bytes(obj)).hexdigest() def node(left, right): return hashlib.sha256(DOM_NODE + left + right).digest() def bracket_root(leaf_hexes): if not leaf_hexes: return hashlib.sha256(DOM_LEAF).hexdigest() level = [bytes.fromhex(h) for h in leaf_hexes] while len(level) > 1: if len(level) % 2 == 1: level.append(level[-1]) level = [node(level[i], level[i + 1]) for i in range(0, len(level), 2)] return level[0].hex() def section_hash(items): return hashlib.sha256(DOM_SECTION + canonical_bytes(list(items or []))).hexdigest() def season_root(meta, roots): return hashlib.sha256(DOM_SEASON + canonical_bytes({"meta": meta, "roots": roots})).hexdigest() def verify_proof(leaf_hex, proof, root_hex): cur = bytes.fromhex(leaf_hex) for side, sib_hex in proof: sib = bytes.fromhex(sib_hex) if side == "L": cur = node(sib, cur) elif side == "R": cur = node(cur, sib) else: return False return cur.hex() == root_hex def check_dump(path, expected_root=None): with open(path, "rb") as f: raw = f.read() dump = json.loads(raw.decode("utf-8")) problems = [] if dump.get("algorithm") not in ALGORITHMS: problems.append("algoritmo desconocido: %r" % dump.get("algorithm")) roots = {} for br in BRACKETS: section = (dump.get("brackets") or {}).get(br) or {} teams = section.get("teams") or [] root = bracket_root([leaf_hash(t) for t in teams]) roots[br] = root if section.get("root") != root: problems.append("bracket %s: root declarado %s != recomputado %s" % ( br, section.get("root"), root)) if int(section.get("leaf_count", -1)) != len(teams): problems.append("bracket %s: leaf_count %s != %d equipos" % ( br, section.get("leaf_count"), len(teams))) print(" %-5s %6d equipos root %s" % (br, len(teams), root)) meta = dict(dump.get("meta") or {}) for sec in ("hof", "revocations"): if sec not in dump: problems.append("seccion %s ausente del dump" % sec) recomputed = section_hash(dump.get(sec) or []) if meta.get(sec + "_sha256") != recomputed: problems.append("seccion %s: sello declarado %s != recomputado %s" % ( sec, meta.get(sec + "_sha256"), recomputed)) meta[sec + "_sha256"] = recomputed print(" hof %d filas, revocations %d" % (len(dump.get("hof") or []), len(dump.get("revocations") or []))) sroot = season_root(meta, roots) print(" season_root recomputado %s" % sroot) if dump.get("season_root") != sroot: problems.append("season_root declarado %s != recomputado %s" % (dump.get("season_root"), sroot)) if expected_root and expected_root.lower() != sroot: problems.append("season_root publicado %s != recomputado %s" % (expected_root, sroot)) # el fichero publicado es byte-identico a su forma canonica; un reformateo no # altera los hashes (se calculan sobre los objetos), asi que es solo un aviso if canonical_bytes(dump) != raw.strip(): print("WARN el JSON no esta en forma canonica (fue reformateado?); los hashes no cambian") return problems, sroot, roots def check_proof(path, roots=None, sroot=None): with open(path, "rb") as f: p = json.load(f) problems = [] if not verify_proof(p["leaf_sha256"], p["proof"], p["bracket_root"]): problems.append("la prueba no reconstruye bracket_root") if "team" in p and leaf_hash(p["team"]) != p["leaf_sha256"]: problems.append("leaf_sha256 no corresponde al objeto team incluido") # la prueba solo ata la hoja a UN bracket_root: sin el dump, ese root no esta # contrastado con ningun season_root (una prueba fabricada autoconsistente daria OK) if roots is None: print("WARN prueba sin dump: bracket_root %s NO contrastado con un season_root " "(pasa tambien el dump para cruzarlo)" % p["bracket_root"]) else: br = p.get("bracket") if roots.get(br) != p["bracket_root"]: problems.append("bracket_root de la prueba (%s) != recomputado del dump para %s (%s)" % ( p["bracket_root"], br, roots.get(br))) if p.get("season_root") and sroot and p["season_root"] != sroot: problems.append("season_root de la prueba %s != recomputado del dump %s" % ( p["season_root"], sroot)) return problems def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("dump", nargs="?", help="dump JSON descargado de /rest/hof/dump/{season}/{version}") ap.add_argument("--root", help="season_root publicado (Discord / web) para comparar") ap.add_argument("--proof", help="JSON de /rest/hof/proof/... a verificar") a = ap.parse_args() problems = [] roots, sroot = None, None if a.dump: pr, sroot, roots = check_dump(a.dump, a.root) problems += pr if a.proof: problems += check_proof(a.proof, roots, sroot) if not a.dump and not a.proof: ap.error("indica un dump y/o --proof") if problems: for p in problems: print("FAIL " + p) return 1 if a.proof and not a.dump: print("OK parcial: prueba autoconsistente pero SIN anclaje a un season_root (exit 2)") return 2 print("OK") return 0 if __name__ == "__main__": sys.exit(main())