#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CVIN 登记表独立重放验证脚本(零依赖,Python 3.8+) 「任何人可独立重放验证」的可执行版本。本脚本不信任 cvin.org: 1. 下载公开账本 registry.jsonl(append-only 观测记录) 2. 逐条重算 canonical JSON → sha256(字段级口径与链上完全一致) 3. 校验设备链:每条记录的 prev_hash 必须指向同一 CVIN 的上一条 4. 校验全局链:global_hash 链必须无断点(防止整段记录被删除/调换) 5. 校验 seq 唯一性(append-only 单写者下 seq 应严格递增不撞号) 6. 校验观测类型白名单 用法: python3 cvin_replay.py # 下载 https://cvin.org/registry.jsonl 并验证 python3 cvin_replay.py ./registry.jsonl # 验证本地文件(完全离线,自备账本) 退出码: 0 = 链完整(允许已披露的 WARN);1 = 发现未披露的断点/不一致;2 = 无法获取账本 哈希口径(字节级定义,与 lib/chain.js 一致): canon(v): - null/标量: JSON 原生表示(UTF-8, ensure_ascii=False 等价于 JS 的 UTF-16 字符串按 UTF-8 编码—— 本登记表内容均为 ASCII/中文 BMP 字符,两者一致) - 数组: '[' + 逐项 canon 逗号连接 + ']' - 对象: '{' + 键按字典序(UTF-16 code unit 序)排序后 `JSON.stringify(键) + ':' + canon(值)` 逗号连接 + '}' hash_payload(rec) = canon({cvin, obs_type, source_url, observed_at, attrs, prev_hash}) —— 不含 hash / synthetic / seq / global_hash 等元数据字段 record_hash = sha256(utf8(hash_payload)) global_hash(n) = sha256(canon({prev: global_hash(n-1) 或 null, rec: record_hash(n)})) """ import hashlib import json import sys import urllib.request BASE = "https://cvin.org" ALLOWED_TYPES = {"listing", "rental", "auction", "telemetry", "declaration", "promotion", "attestation", "market_quote"} # ============================================================================ # 已知历史缺陷区(诚实披露,不静默放行;按记录哈希指纹精确定位,不按 seq—— # 因为缺陷区内 seq 本身就撞号,按 seq 会误伤正常记录或漏掉缺陷记录)。 # # 背景:2026-11-01 上线自测窗口,两个写入路径并发追加账本: # (a) 自测块 11 条(declaration/attestation/promotion/listing,seq 626-636, # 多数 prev_hash=null,未接入全局链与设备链锚点); # (b) 行情采集器滞留批 6 条(market_quote,seq 626-629 与自测块撞号、637-638, # 链锚定正确但 seq 为陈旧值,导致文件序不等于链序)。 # 后果: # - 5 条 declaration 因写入层把 undefined 值的键(region/cluster_size)带进哈希 # 参与字段、落盘 JSON 又丢弃这些键,hash 无法从落盘内容重算 → 见 UNREPLAYABLE; # - seq 626-629 各出现两次(自测块与采集批撞号); # - 文件序的设备链/全局链在该 17 条区间内不可重放(区间外的锚点完整无缺)。 # 修复:2026-11-04 写入口收敛为串行队列(store.append 经 _queue 串行化, # seq 于追加时分配)+ cleanAttrs(truthy 字段才入哈希),无复发路径。 # 区间记录按 append-only 原则原样保留(删改即真断链); # 重放时对区间记录跳过链比较、如实计数并逐条披露原因。 # ============================================================================ # 缺陷区 17 条记录的哈希指纹(账本文件序第 626-642 行,append-only 下位置永久不变) DEFECT_REGION_HASHES = { "2a2d2aab7f9a9095136fb203b7f94d8a5ba767d2e78b4fef1dbdf1fc265544a3", # seq=626 declaration(链锚点 null,自测块起点) "b2048bcdbc4a1c719395dec3ea9d96f66c5986f13f387ae4e4540610e7dee7b6", # seq=627 declaration(哈希不可重放) "b88abd4b4b61955bfa75537ec658a850812e63705f8570590ec179cdc754dd6a", # seq=628 attestation "c55c9caef7fdd10cda73c4c2ca6894336f611e9c974db859e0a16ab9dc63176c", # seq=629 declaration(哈希不可重放) "30bb60bc029f6c5c1c9e731ccb25fa4ef9eb3dcf5943c9b6ba5c567ab2038b63", # seq=630 declaration(哈希不可重放) "14effc510f62582eec33bee80968a93df5dd7d6a124ce3c55caff956650ecf16", # seq=631 promotion "7a9682d09daa525e18ea125f993fabaef2f6df307fa2feb3812066c538773dea", # seq=632 declaration(哈希不可重放) "d8bbd0ba537dc9c61140b1f05fbdb507efc4369adc9e9a84f2e15f0c8c85f9af", # seq=633 declaration(哈希不可重放) "aaca91ef503533c0b0fdc8c451d1dc199793bedbbcb099b8f46df41f35e002f6", # seq=634 listing(自测块) "47a751363f10a3046b541b53e4293ab99fb742c0a08bd143a8216a98b9cb0609", # seq=635 listing(自测块) "5592b545c61d549b2c16ef81f1998d5198c6c569820ee3253f5103d9231103a2", # seq=636 listing(自测块) "2ca5f88ddc567e7d57c76ff8905733d3711a0a822fa9ccf23fed56c0a8b7847c", # seq=637 market_quote(采集批,锚点回跳) "493d0735454d416dbe9bfe8495bd000350c2dd14f82654bc98065be3bb6c6637", # seq=638 market_quote(采集批) "0047784713a9f97f9afe0ec65746e08ba0da1119d73e2e6741cd01f7361ea931", # seq=626 market_quote(撞号;采集滞留批) "02d4c2926a5ea860e245a070f81e67039af087b75955beee949032701f650162", # seq=627 market_quote(撞号) "df23073e042cee80e948d941c2de8b3f8c76cc7fb4bd74f45de7e2b9b98fc906", # seq=628 market_quote(撞号) "60eb8a452acb52587b7412f584b98d9979d4d5893d5e1380ac734133a26a0cf7", # seq=629 market_quote(撞号;缺陷区终点) } # 其中 5 条 declaration 的哈希无法从落盘内容重算(undefined 键参与哈希、落盘被丢弃) UNREPLAYABLE_HASHES = { "b2048bcdbc4a1c719395dec3ea9d96f66c5986f13f387ae4e4540610e7dee7b6", "c55c9caef7fdd10cda73c4c2ca6894336f611e9c974db859e0a16ab9dc63176c", "30bb60bc029f6c5c1c9e731ccb25fa4ef9eb3dcf5943c9b6ba5c567ab2038b63", "7a9682d09daa525e18ea125f993fabaef2f6df307fa2feb3812066c538773dea", "d8bbd0ba537dc9c61140b1f05fbdb507efc4369adc9e9a84f2e15f0c8c85f9af", } DEFECT_REGION_NOTE = ("历史缺陷区(2026-11-01 并发写入窗口,账本第 626-642 行共 17 条):" "自测块未接入链锚点(prev_hash=null)+ 采集器滞留批 seq 撞号(626-629 各两次)," "文件序链在该区间不可重放。写入口已于 2026-11-04 收敛为串行队列 + cleanAttrs,无复发路径;" "区间记录按 append-only 原则原样保留。") def canon(v): """确定性序列化:键递归字典序,数组保序——与 lib/chain.js canon() 字节一致。""" if v is None or not isinstance(v, (dict, list)): return json.dumps(v, ensure_ascii=False, separators=(",", ":")) if isinstance(v, list): return "[" + ",".join(canon(x) for x in v) + "]" keys = sorted(v.keys()) # Python sort 按 code point;本表键均为 ASCII,与 JS UTF-16 序一致 return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canon(v[k]) for k in keys) + "}" def sha256(s): return hashlib.sha256(s.encode("utf-8")).hexdigest() def record_hash(rec): payload = canon({ "cvin": rec.get("cvin"), "obs_type": rec.get("obs_type"), "source_url": rec.get("source_url"), "observed_at": rec.get("observed_at"), "attrs": rec.get("attrs") or {}, "prev_hash": rec.get("prev_hash"), }) return sha256(payload) def main(): # ---- 获取账本 ---- if len(sys.argv) > 1: with open(sys.argv[1], "r", encoding="utf-8") as f: lines = [l for l in f.read().splitlines() if l.strip()] print("账本来源: 本地文件 %s" % sys.argv[1]) else: url = BASE + "/registry.jsonl" try: print("下载 %s ..." % url) raw = urllib.request.urlopen(url, timeout=60).read().decode("utf-8") lines = [l for l in raw.splitlines() if l.strip()] except Exception as e: print("[FAIL] 无法获取账本: %s" % e) return 2 print("账本来源: %s" % url) records = [] bad_lines = 0 for i, l in enumerate(lines): try: records.append(json.loads(l)) except Exception: bad_lines += 1 print(" [WARN] 第 %d 行不是合法 JSON,按坏行跳过" % (i + 1)) print("共 %d 条记录(坏行 %d)" % (len(records), bad_lines)) # ---- 1/5 逐条重算哈希 ---- hash_mismatch = 0 known_defect = 0 for r in records: if "hash" not in r: continue # 极早期记录可能没有 hash 字段 if record_hash(r) != r.get("hash"): if r.get("hash") in UNREPLAYABLE_HASHES: known_defect += 1 # 已披露的历史缺陷,不作为断链,但也如实计数 else: hash_mismatch += 1 print(" [FAIL] seq %s 记录哈希不一致(内容被改动)" % r.get("seq")) if known_defect: print(" [WARN] %d 条为已披露历史缺陷(哈希不可重放,按指纹逐条核对),不计入失败。" % known_defect) print("1/5 逐条哈希重算: %s" % ("通过" if hash_mismatch == 0 else "不一致 %d 条" % hash_mismatch)) # ---- 2/5 设备链(同 CVIN prev_hash 链) ---- # 缺陷区记录跳过比较但继续推进锚点:区间后的正常记录锚定在区间内最后一条同设备记录上。 chain_breaks = 0 region_skipped = 0 by_cvin = {} for r in records: by_cvin.setdefault(r.get("cvin"), []).append(r) for c, recs in by_cvin.items(): prev = None for r in recs: # 文件内即链序(append-only) if r.get("hash") in DEFECT_REGION_HASHES: region_skipped += 1 prev = r.get("hash") # 区间内不校验,但携带锚点供区间后衔接 continue if r.get("prev_hash") != prev: chain_breaks += 1 print(" [FAIL] %s seq %s prev_hash 断链" % (c, r.get("seq"))) prev = r.get("hash") print("2/5 设备链重放: %s(%d 个 CVIN)" % ("通过" if chain_breaks == 0 else "断点 %d 处" % chain_breaks, len(by_cvin))) # ---- 3/5 全局链 ---- # 缺陷区记录跳过比较但携带其存储的 global_hash,使区间后第一条正常记录能衔接校验。 global_breaks = 0 prev_global = None for r in records: in_region = r.get("hash") in DEFECT_REGION_HASHES expect = sha256(canon({"prev": prev_global, "rec": r.get("hash")})) if "global_hash" in r and expect != r["global_hash"] and not in_region: global_breaks += 1 print(" [FAIL] seq %s 全局链不一致(可能被删除或调换过记录)" % r.get("seq")) prev_global = r.get("global_hash", expect) print("3/5 全局链重放: %s" % ("通过" if global_breaks == 0 else "断点 %d 处" % global_breaks)) # ---- 4/5 seq 唯一性 ---- by_seq = {} for r in records: if "hash" in r: by_seq.setdefault(r.get("seq"), []).append(r) dup_seqs = {s: g for s, g in by_seq.items() if len(g) > 1} seq_bad = 0 seq_known_groups = [] for s in sorted(dup_seqs): if all(r.get("hash") in DEFECT_REGION_HASHES for r in dup_seqs[s]): seq_known_groups.append(s) else: seq_bad += 1 print(" [FAIL] seq %s 出现 %d 次(且不全在已披露缺陷区)" % (s, len(dup_seqs[s]))) if seq_known_groups: print(" [WARN] seq 撞号 %d 组(seq %s)均为已披露缺陷区记录(2026-11-01 并发写入),不计入失败。" % ( len(seq_known_groups), seq_known_groups)) seq_known = len(seq_known_groups) print("4/5 seq 唯一性: %s" % ("通过" if not dup_seqs else ("已披露撞号 %d 组" % seq_known if seq_bad == 0 else "未披露撞号 %d 组" % seq_bad))) # ---- 5/5 观测类型白名单 ---- bad_types = [r for r in records if r.get("obs_type") not in ALLOWED_TYPES] print("5/5 观测类型白名单: %s" % ("通过" if not bad_types else "未知类型 %d 条" % len(bad_types))) # ---- 结论 ---- synthetic = sum(1 for r in records if r.get("synthetic")) ok = (hash_mismatch + chain_breaks + global_breaks + seq_bad) == 0 print("\n结论: %s" % ("链完整,可独立复算——登记表与公开口径一致。" if ok else "发现不一致,见上方 [FAIL] 行。")) print("披露: %d 条记录带 synthetic:true(合成种子),占比 %.1f%%;synthetic 标记本身参与重放核验。" % ( synthetic, 100.0 * synthetic / max(1, len(records)))) if region_skipped or known_defect or seq_known: print("披露: %d 条为已披露的历史缺陷区记录(2026-11-01 并发写入窗口,账本第 626-642 行)," % ( region_skipped)) print(" 其中 %d 条哈希不可重放、%d 组 seq 撞号;区间外锚点完整。%s" % ( known_defect, seq_known, DEFECT_REGION_NOTE)) return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())