#!/usr/bin/env python3 """АВТОНОМНЫЙ проверщик хеш-цепей MITR — для третьей стороны, без нашего кода. Зачем отдельный файл, когда пересчёт цепи есть внутри (`HonestyJournal.verify_chain`, `core.honesty.gate_receipts`). Потому что «проверено нашим кодом» и «проверяемо посторонним» — разные утверждения. Внутренний пересчёт отвечает на вопрос «сошлась ли цепь по мнению того же кода, который её писал»; проверяющему нужен ответ на вопрос «сойдётся ли она у МЕНЯ, если я скачаю файл и пересчитаю сам». База знаний проекта (модуль 04) до 18.09.2026 числила верификатор отсутствующим — буквально это было верно, по смыслу нет, и правильный выход не спорить о формулировке, а дать посторонннему инструмент. ЧТО ЭТОТ ФАЙЛ ОБЯЗАН СОБЛЮДАТЬ, иначе он не проверщик: * **ни одного импорта из `core`/`scripts`** — только стандартная библиотека. Проверщик, который тянет наш модуль, проверяет нас нашим же кодом (это стережёт проба `tests/test_standalone_verifier.py`); * **алгоритм описан словами** (`--explain`), чтобы его можно было переписать на любом языке и не зависеть от нас вовсе; * **молчание = не проверено.** Нет файла, пустой файл, нечитаемая строка — это отдельные исходы с кодом возврата, а НЕ «цепь в порядке». КОДЫ ВОЗВРАТА: 0 — цепь сошлась · 1 — цепь НЕ сошлась (назван номер записи) · 2 — проверить не удалось (нет файла, битая строка, неизвестный формат). Второе и третье различать обязательно: «подделка» и «прибор не отработал» — разные новости. Запуск: python3 verify_chain_standalone.py honesty_journal.jsonl python3 verify_chain_standalone.py gate_actions.jsonl --anchors anchors.jsonl python3 verify_chain_standalone.py --explain """ from __future__ import annotations import argparse import hashlib import json import sys from pathlib import Path GENESIS = "0" * 64 # Поля тела якоря: хешируются РОВНО они, в этом порядке неважном (ключи сортируются). ANCHOR_BODY_FIELDS = ( "anchor_version", "epoch", "ts", "ledger_count", "head_position", "head_hash", "genesis", "chain_ok", "prev_anchor_hash", ) # Три формата, которые встречаются в файлах MITR. Отличаются ДВУМЯ вещами: какие поля # исключаются из тела перед хешированием и с какими разделителями тело сериализуется. # Разница не косметическая: `{"a":1}` и `{"a": 1}` дают разные SHA-256. СХЕМЫ = { "journal": { "hash_field": "this_hash", "exclude": ("prev_hash", "this_hash"), "separators": (",", ":"), "описание": "журнал честности: тело = запись без обоих полей цепи, компактный JSON", }, "receipts": { "hash_field": "this_hash", "exclude": ("this_hash",), "separators": (", ", ": "), "описание": "квитанции шлюза допуска: тело = запись без this_hash (prev_hash ВХОДИТ), " "JSON с пробелами после разделителей", }, "ledger": { "hash_field": "entry_hash", "exclude": None, # не «всё кроме», а РОВНО перечисленные поля "keep": ("ts", "action", "r_class", "verdict", "model", "detail"), "separators": (",", ":"), "описание": "журнал решений: тело = ровно шесть полей записи, компактный JSON", }, } def _тело(rec: dict, схема: dict) -> str: if схема.get("keep"): body = {k: rec.get(k) for k in схема["keep"]} else: body = {k: v for k, v in rec.items() if k not in схема["exclude"]} return json.dumps(body, sort_keys=True, ensure_ascii=False, separators=схема["separators"]) def _хеш(prev: str, rec: dict, схема: dict) -> str: return hashlib.sha256((prev + _тело(rec, схема)).encode("utf-8")).hexdigest() def прочитать(path: Path) -> tuple[list[dict], str]: """Записи файла. Вторая часть — причина, если прочитать не удалось ЦЕЛИКОМ.""" if not path.exists(): return [], f"файла нет: {path}" out: list[dict] = [] for n, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1): line = line.strip() if not line: continue try: rec = json.loads(line) except json.JSONDecodeError as exc: return out, f"строка {n} не разбирается как JSON ({exc}) — файл повреждён или не тот" if not isinstance(rec, dict): return out, f"строка {n} не объект JSON — это не цепь" out.append(rec) if not out: return [], f"файл пуст: {path} — проверять нечего, и это НЕ «цепь в порядке»" return out, "" def это_якоря(records: list[dict]) -> bool: первая = records[0] return "anchor_hash" in первая and "epoch" in первая def проверить_якорную_цепь(anchors: list[dict]) -> dict: """Якоря сами по себе, без файла записей: пересчёт anchor_hash и сцепка эпох. Это первое, что скачает посторонний: тело якоря публично целиком, а цепь записей может быть закрыта. Проверяется ровно то, что якорь и обещает: хеш тела и сцепка с предыдущей эпохой. """ prev = GENESIS for i, a in enumerate(anchors): тело = {k: a.get(k) for k in ANCHOR_BODY_FIELDS} свой = hashlib.sha256( json.dumps(тело, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8") ).hexdigest() if свой != str(a.get("anchor_hash")): return {"ok": False, "broken_index": i, "почему": f"anchor_hash эпохи {a.get('epoch')} " f"не пересчитывается (пересчёт {свой[:16]}…, в файле " f"{str(a.get('anchor_hash'))[:16]}…) — тело якоря изменено"} if str(a.get("prev_anchor_hash") or GENESIS) != prev: return {"ok": False, "broken_index": i, "почему": f"эпоха {a.get('epoch')}: " "prev_anchor_hash не сцеплен с предыдущим якорем — эпоха вставлена или изъята"} prev = str(a.get("anchor_hash") or "") последний = anchors[-1] внешние = [w for w in (последний.get("witnesses") or []) if w.get("witness") != "file"] return {"ok": True, "broken_index": -1, "почему": "", "head": prev, "последняя_эпоха": последний.get("epoch"), "длина_заякоренной_цепи": последний.get("ledger_count"), "голова_заякоренной_цепи": последний.get("head_hash"), "внешний_свидетель": ([str(w.get("witness")) for w in внешние if w.get("ok")] or None), "метка_времени": метка_времени(внешние)} def метка_времени(свидетели: list[dict]) -> str | None: """Имя файла метки OpenTimestamps — из самой записи якоря (`ref` свидетеля), не по образцу. У цепей набора метки названы по-разному: у цепи шлюза — с приставкой `gate-`. Имя по образцу `anchor-epoch-N` для цепи шлюза указало бы на метку ДРУГОЙ цепи с тем же номером эпохи, `ots verify` её подтвердил бы, и посторонний счёл бы доказанным время не того якоря (27.09 так и было: для эпохи 5 шлюза подсказка вела на метку журнала решений). Нет `ref` — имени нет: проверщик его не придумывает. """ for w in свидетели: ref = str(w.get("ref") or "") if w.get("ok") and w.get("witness") == "opentimestamps" and ref.endswith(".ots"): return Path(ref).name return None def сверить_тело(путь: Path, якорь: dict) -> str: """Метка доказывает время ФАЙЛА тела якоря, пересчёт — сцепку записей якорей. Связывает их только равенство тела записи; без сверки это два доказательства о разных предметах.""" if not путь.is_file(): return f"тело якоря {путь.name} рядом с файлом якорей не найдено — связь метки с записью НЕ сверена" try: тело = json.loads(путь.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: return f"тело якоря {путь.name} не читается ({exc}) — связь метки с записью НЕ сверена" if not isinstance(тело, dict): return f"тело якоря {путь.name} — не объект JSON: связь метки с записью НЕ сверена" разные = [k for k in (*ANCHOR_BODY_FIELDS, "anchor_hash") if тело.get(k) != якорь.get(k)] if разные: return f"ТЕЛО ЯКОРЯ {путь.name} НЕ совпадает с последней записью: {', '.join(разные)}" return f"тело якоря {путь.name} совпадает с последней записью ({len(ANCHOR_BODY_FIELDS) + 1} полей)" def определить_схему(records: list[dict]) -> tuple[str, str]: """Имя схемы по ПЕРВОЙ записи: пробуем каждую и берём ту, чей пересчёт совпал. Угадывание по набору полей было бы гаданием; здесь схема ДОКАЗЫВАЕТСЯ совпадением хеша на первой записи, где prev_hash обязан быть genesis. """ первая = records[0] for имя, схема in СХЕМЫ.items(): поле = схема["hash_field"] if поле not in первая: continue if _хеш(str(первая.get("prev_hash") or GENESIS), первая, схема) == str(первая.get(поле)): return имя, "" поля = ", ".join(sorted(первая.keys())[:12]) return "", ("схема не опознана: ни один известный способ хеширования не даёт хеш первой " f"записи. Поля первой записи: {поля}") def проверить_цепь(records: list[dict], имя_схемы: str) -> dict: """Пересчёт цепи от начала файла. Возвращает первый разошедшийся индекс и ПОЧЕМУ.""" схема = СХЕМЫ[имя_схемы] поле = схема["hash_field"] prev = GENESIS for i, rec in enumerate(records): сохранённый_prev = str(rec.get("prev_hash") or GENESIS) сохранённый = str(rec.get(поле) or "") if сохранённый_prev != prev: return {"ok": False, "broken_index": i, "почему": "prev_hash записи не равен " f"this_hash предыдущей (ожидался {prev[:16]}…, в файле {сохранённый_prev[:16]}…) " "— запись вставлена, удалена или переставлена"} пересчёт = _хеш(prev, rec, схема) if пересчёт != сохранённый: return {"ok": False, "broken_index": i, "почему": "пересчитанный хеш не равен " f"сохранённому (пересчёт {пересчёт[:16]}…, в файле {сохранённый[:16]}…) " "— тело записи изменено после записи"} prev = сохранённый return {"ok": True, "broken_index": -1, "почему": "", "head": prev} def проверить_якоря(anchors: list[dict], head: str, count: int) -> list[str]: """Якоря: своя цепь + сверка с файлом записей. Возвращает список замечаний (пусто = чисто). Якорь фиксирует ДЛИНУ и ГОЛОВУ цепи на момент снятия. Поэтому он ловит то, чего не ловит пересчёт: усечение файла. Обрезанная цепь сама по себе остаётся валидной — «валидна» лишь значит, что уцелевшее не переписано, — а вот с якорем она уже не сходится. """ замечания: list[str] = [] prev_anchor = GENESIS for i, a in enumerate(anchors): тело = {k: a.get(k) for k in ANCHOR_BODY_FIELDS} свой = hashlib.sha256( json.dumps(тело, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8") ).hexdigest() if свой != str(a.get("anchor_hash")): замечания.append(f"якорь #{i} (эпоха {a.get('epoch')}): anchor_hash не пересчитывается") if str(a.get("prev_anchor_hash") or GENESIS) != prev_anchor: замечания.append(f"якорь #{i} (эпоха {a.get('epoch')}): prev_anchor_hash не сцеплен " "с предыдущим якорем") prev_anchor = str(a.get("anchor_hash") or "") if anchors: последний = anchors[-1] if int(последний.get("ledger_count") or -1) > count: замечания.append( f"якорь эпохи {последний.get('epoch')} фиксирует {последний.get('ledger_count')} " f"записей, в файле {count} — ЦЕПЬ УСЕЧЕНА (пересчёт этого не видит)") elif int(последний.get("ledger_count") or -1) == count and str(последний.get("head_hash")) != head: замечания.append( f"якорь эпохи {последний.get('epoch')}: голова {str(последний.get('head_hash'))[:16]}… " f"не равна голове файла {head[:16]}… при равной длине — подмена последней записи") внешние = [w for w in (последний.get("witnesses") or []) if w.get("witness") != "file"] если_ок = [w for w in внешние if w.get("ok")] if not внешние: замечания.append(f"якорь эпохи {последний.get('epoch')}: внешнего свидетеля в списке " "НЕТ — за якорем стоит только наш собственный файл") elif not если_ок: замечания.append(f"якорь эпохи {последний.get('epoch')}: внешний свидетель заявлен, но " f"неуспешен ({', '.join(str(w.get('witness')) for w in внешние)})") return замечания ОБЪЯСНЕНИЕ = """\ АЛГОРИТМ (чтобы переписать на любом языке и не зависеть от нас) 1. Цепь — текстовый файл, одна запись = одна строка JSON, только дописывание. 2. GENESIS = 64 нуля. Для первой записи предыдущий хеш равен GENESIS. 3. Для каждой записи по порядку: тело = запись БЕЗ полей цепи (какие именно — см. схему ниже), сериализованная в JSON с СОРТИРОВКОЙ КЛЮЧЕЙ и без escape не-ASCII; хеш = SHA-256( предыдущий_хеш + тело ), в шестнадцатеричном виде; сверка = хеш равен полю записи И поле prev_hash равно предыдущему хешу. 4. Голова цепи — хеш последней записи. Якорь фиксирует голову И длину: anchor_hash = SHA-256( JSON из ровно девяти полей тела якоря, ключи отсортированы ) Якоря сцеплены между собой полем prev_anchor_hash, поэтому последний якорь коммитит всю историю. СХЕМЫ (разделители важны: {"a":1} и {"a": 1} дают РАЗНЫЕ хеши) journal поле this_hash · тело = запись без prev_hash и this_hash · разделители "," ":" receipts поле this_hash · тело = запись без this_hash (prev_hash ВХОДИТ) · ", " ": " ledger поле entry_hash · тело = ровно ts, action, r_class, verdict, model, detail · "," ":" ЧЕГО ПЕРЕСЧЁТ НЕ ДОКАЗЫВАЕТ (читать обязательно) • Усечение. Обрезанный файл остаётся валидным: пересчёт говорит лишь, что уцелевшее не переписано. Длину и голову фиксирует ЯКОРЬ — сверяйте с ним (--anchors). • Своевременность. Хеш-цепь доказывает порядок, а не дату: подделать можно всю цепь целиком, задним числом. Дату доказывает только ВНЕШНИЙ свидетель. У нас это OpenTimestamps (Bitcoin): рядом с якорем лежат тело якоря и .ots к нему, проверяется чужим клиентом, не нашим. Имя файла метки — в поле ref свидетеля записи якоря; у цепей оно разное (у цепи шлюза — gate-anchor-epoch-N.json.ots), по номеру эпохи не угадывать: ots verify Метка доказывает время файла тела, поэтому тело сверяется с записью якоря (проверщик делает это сам, если тело лежит рядом с файлом якорей). • Честность содержимого. Цепь доказывает неизменность записанного, а не правдивость. Что записано — предмет отдельной проверки (вердикты, метрики, квитанции). """ def main() -> int: ap = argparse.ArgumentParser( description="Автономный пересчёт хеш-цепей MITR (только стандартная библиотека).") ap.add_argument("file", nargs="?", help="файл цепи (.jsonl)") ap.add_argument("--kind", choices=sorted(СХЕМЫ), help="схема; по умолчанию определяется сама") ap.add_argument("--anchors", help="файл якорей для сверки длины и головы") ap.add_argument("--json", action="store_true", dest="как_json", help="машинный вывод") ap.add_argument("--explain", action="store_true", help="напечатать алгоритм и выйти") args = ap.parse_args() if args.explain: print(ОБЪЯСНЕНИЕ) return 0 if not args.file: ap.error("укажите файл цепи (или --explain)") records, причина = прочитать(Path(args.file)) if причина: итог = {"ok": None, "причина": причина, "records": len(records)} print(json.dumps(итог, ensure_ascii=False) if args.как_json else f"НЕ ПРОВЕРЕНО: {причина}") return 2 # Файл якорей проверяется САМ ПО СЕБЕ: он публичен целиком, и посторонний скачает его # первым. Отвечать ему «схема не опознана» было бы верно формально и бесполезно по делу. if это_якоря(records) and args.kind != "journal": итог = проверить_якорную_цепь(records) итог.update(эпох=len(records), схема="anchors", файл=str(args.file)) метка = итог.get("метка_времени") if метка: итог["сверка_тела"] = сверить_тело(Path(args.file).parent / метка[:-len(".ots")], records[-1]) if args.как_json: print(json.dumps(итог, ensure_ascii=False, indent=1)) else: print(f"файл: {итог['файл']}\nсхема: anchors — якоря цепи (тело якоря + сцепка эпох)" f"\nэпох: {итог['эпох']}") if итог["ok"]: print(f"ЯКОРЯ СОШЛИСЬ. последняя эпоха {итог['последняя_эпоха']}: " f"заякорено записей {итог['длина_заякоренной_цепи']}, голова " f"{str(итог['голова_заякоренной_цепи'])[:32]}…") вс = итог["внешний_свидетель"] print(f"внешний свидетель последней эпохи: {', '.join(вс) if вс else 'НЕТ — за якорем только наш файл'}") if метка: print(f"время якоря доказывается ТОЛЬКО внешней меткой: `ots verify {метка}`") print(итог["сверка_тела"]) else: print("время якоря доказывается ТОЛЬКО внешней меткой, но файл метки в записи " "якоря не назван; имя по номеру эпохи не подставляю — у разных цепей оно совпадает") else: print(f"ЯКОРЯ НЕ СОШЛИСЬ на эпохе №{итог['broken_index']}: {итог['почему']}") print("\nчего пересчёт не доказывает — `--explain`") return 0 if итог["ok"] else 1 имя = args.kind or "" if not имя: имя, ошибка = определить_схему(records) if not имя: print(json.dumps({"ok": None, "причина": ошибка}, ensure_ascii=False) if args.как_json else f"НЕ ПРОВЕРЕНО: {ошибка}") return 2 итог = проверить_цепь(records, имя) итог.update(records=len(records), схема=имя, файл=str(args.file)) if args.anchors: anchors, причина_я = прочитать(Path(args.anchors)) if причина_я: итог["якоря"] = {"ok": None, "причина": причина_я} else: замечания = проверить_якоря(anchors, итог.get("head") or "", len(records)) итог["якоря"] = {"ok": not замечания, "эпох": len(anchors), "замечания": замечания} if args.как_json: print(json.dumps(итог, ensure_ascii=False, indent=1)) else: print(f"файл: {итог['файл']}\nсхема: {имя} — {СХЕМЫ[имя]['описание']}\nзаписей: {итог['records']}") if итог["ok"]: print(f"ЦЕПЬ СОШЛАСЬ. голова: {итог['head']}") else: print(f"ЦЕПЬ НЕ СОШЛАСЬ на записи №{итог['broken_index']}: {итог['почему']}") я = итог.get("якоря") if я: if я.get("ok") is None: print(f"якоря: НЕ ПРОВЕРЕНЫ — {я['причина']}") elif я["ok"]: print(f"якоря: сошлись, эпох {я['эпох']}") else: print(f"якоря: эпох {я['эпох']}, замечания:") for з in я["замечания"]: print(f" • {з}") print("\nчего пересчёт не доказывает — `--explain`") if not итог["ok"]: return 1 я = итог.get("якоря") or {} if я.get("ok") is False: return 1 return 0 if __name__ == "__main__": sys.exit(main())