Ctrl K
ComunidadGuías…

scripts/verify-receipt.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Check a receipt written by `examples/real_decision.py` without having been in the room.

A receipt only means something if a third party can re-derive its claims from what it contains.
This script does exactly that, from public data alone — no preference sheet is needed or read:

- rebuilds the transcript (`Transcript.from_dict`) and re-hashes it against the hash the
  receipt claims, so any edit to the record after the receipt was written shows up;
- recomputes the max-min outcome from the recorded verdicts (`consensus.verify_outcome`),
  requiring every entry to carry exactly one verdict from each listed participant;
- checks every acceptance through `ratify.agreement`'s rules: bound to exactly this hash and
  this decision, by an owner who appears in the record; and, where an acceptance carries a
  signature, validates it (`net.identity.verify_acceptance`, needs the `crypto` extra).

It also prints what it does NOT prove. Per SECURITY.md the public key in a signed acceptance
or verdict is self-attested by the record that carries it, so a holder of any key can sign
under another owner's name; unsigned acceptances authenticate nobody; the participants list is
written by the same coordinator as the record; and verdict payloads carry no replay binding.
None of that is hidden behind a green tick.

Exit 0 only when every check passes; 1 when any check fails or a present signature could not
be checked; 2 when the file cannot be read as a receipt.

Usage:  scripts/verify-receipt.py receipt.json
"""
import argparse
import importlib
import json
import os
import sys
from dataclasses import fields
from typing import Any, Optional

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))

from parley.consensus import verify_outcome  # noqa: E402
from parley.ratify import Acceptance, agreement  # noqa: E402
from parley.transcript import Transcript  # noqa: E402

REQUIRED = frozenset({"title", "status", "decision", "transcript_hash", "max_min_verified",
                      "unanimous_acceptance", "acceptances", "participants", "transcript"})
ACCEPTANCE_KEYS = frozenset(f.name for f in fields(Acceptance))
INSTALL_HINT = "install the crypto extra to check it: pip install 'parley[crypto]'"


def _crypto():
    """The optional Ed25519 layer, or None when pynacl is not installed."""
    try:
        return importlib.import_module("parley.net.identity")
    except ImportError:
        return None


def _acceptance(raw: Any, index: int) -> Acceptance:
    if not isinstance(raw, dict):
        raise ValueError(f"acceptance {index} is not an object")
    missing = sorted({"owner", "decision", "transcript_hash", "accepted"} - set(raw))
    unknown = sorted(set(raw) - ACCEPTANCE_KEYS)
    if missing or unknown:
        raise ValueError(f"acceptance {index}: missing {missing}, unknown {unknown}")
    return Acceptance(**{k: raw.get(k) for k in ACCEPTANCE_KEYS})


def verify(receipt: Any, roster=None) -> dict:
    """Re-derive every checkable claim in a receipt. Returns a report; `report["ok"]` is the
    single answer, `report["errors"]` says why when it is False."""
    errors: list = []
    report: dict = {"ok": False, "errors": errors, "title": None, "status": None,
                    "decision": None, "hash": None, "result": None, "max_min": None,
                    "roster": None, "verdict_signatures": None, "acceptances": [],
                    "unanimous": None, "crypto": _crypto() is not None}
    if not isinstance(receipt, dict):
        errors.append("receipt is not a JSON object")
        return report
    missing = sorted(REQUIRED - set(receipt))
    if missing:
        errors.append(f"receipt is missing required field(s): {', '.join(missing)}")
        return report
    report["title"], report["status"] = receipt["title"], receipt["status"]
    report["decision"] = receipt["decision"]

    try:
        transcript = Transcript.from_dict(receipt["transcript"])
    except ValueError as exc:
        errors.append(f"transcript cannot be rebuilt: {exc}")
        return report

    try:
        recomputed = transcript.hash()
    except TypeError as exc:
        errors.append(f"transcript cannot be hashed: {exc}")
        return report

    claimed = receipt["transcript_hash"]
    hash_ok = recomputed == claimed
    report["hash"] = {"claimed": claimed, "recomputed": recomputed, "ok": hash_ok}
    if not hash_ok:
        errors.append("transcript hash mismatch: the record was edited after the receipt was written")

    result = transcript.result
    status = result.get("status") if result else None
    decision = result.get("decision") if result else None
    result_ok = (result is not None and status in ("agreed", "deadlock")
                 and (status == "deadlock") == (decision is None)
                 and receipt["status"] == status and receipt["decision"] == decision)
    report["result"] = {"ok": result_ok, "status": status, "decision": decision}
    if not result_ok:
        errors.append("receipt status/decision do not match the finalized record")

    owners = {v["owner"] for e in transcript.entries for v in e["verdicts"]}
    participants = receipt["participants"]
    if roster is not None:
        # The owner set comes from the owners' own pins, never from the coordinator-written receipt.
        if not (isinstance(roster, dict) and roster and all(isinstance(k, str) for k in roster)):
            errors.append("roster must be a non-empty {owner: pubkey} object")
            participants = None
        elif participants != sorted(roster) and set(participants or []) != set(roster):
            errors.append("the receipt's participants are not the roster's owners")
            participants = sorted(roster)
        else:
            participants = sorted(roster)
    roster_shape_ok = (isinstance(participants, list) and bool(participants)
                       and all(isinstance(p, str) for p in participants)
                       and len(set(participants)) == len(participants))
    roster_ok = roster_shape_ok and set(participants) == owners

    max_min = verify_outcome(transcript,
                             expected_owners=participants if roster_shape_ok else None)
    owner_set_mismatch = (not max_min and roster_shape_ok and verify_outcome(transcript))
    max_min_ok = max_min and roster_shape_ok and receipt["max_min_verified"] is True
    report["max_min"] = {"recomputed": max_min, "claimed": receipt["max_min_verified"],
                         "owner_set_mismatch": owner_set_mismatch,
                         "owner_set_unchecked": not roster_shape_ok, "ok": max_min_ok}
    if owner_set_mismatch:
        errors.append("the record's owner set is not exactly the receipt's participants, so "
                      "the max-min recomputation over it proves nothing (a participant's "
                      "verdicts are missing, or a stranger's are present)")
    elif not max_min:
        errors.append("the announced decision is not the max-min option over the recorded verdicts")
    elif receipt["max_min_verified"] is not True:
        errors.append("receipt claims max_min_verified is not true, but the recomputation says it is")
    report["roster"] = {"participants": participants, "transcript_owners": sorted(owners),
                        "ok": roster_ok}
    if not roster_ok:
        errors.append("participants listed in the receipt do not match the owners in the record")

    crypto = _crypto()
    signed_verdicts = [v for e in transcript.entries for v in e["verdicts"]
                       if v.get("sig") or v.get("pubkey_hex")]
    all_verdicts_signed = bool(signed_verdicts) and len(signed_verdicts) == sum(
        len(e["verdicts"]) for e in transcript.entries)
    if not signed_verdicts:
        verdict_sigs = "none"
    elif crypto is None:
        verdict_sigs = "unchecked"
        errors.append(f"verdicts carry signatures that were not checked: {INSTALL_HINT}")
    elif crypto.verify_transcript(transcript, require_signed=all_verdicts_signed, roster=roster):
        verdict_sigs = "verified"
    else:
        verdict_sigs = "FAILED"
        errors.append("a signed verdict does not validate against the key it carries")
    report["verdict_signatures"] = verdict_sigs

    accs: list = []
    seen: set = set()
    acc_ok = True
    for i, raw in enumerate(receipt["acceptances"] if isinstance(receipt["acceptances"], list)
                            else []):
        try:
            acc = _acceptance(raw, i)
        except ValueError as exc:
            errors.append(str(exc))
            acc_ok = False
            continue
        accs.append(acc)
        bound = (acc.owner in owners and acc.transcript_hash == recomputed
                 and acc.decision == decision)
        if acc.sig or acc.pubkey_hex:
            if crypto is None:
                signature = "unchecked"
            else:
                signature = "verified" if crypto.verify_acceptance(acc, roster=roster) else "FAILED"
        else:
            signature = "unsigned"
        duplicate = acc.owner in seen
        seen.add(acc.owner)
        ok = bound and signature in ("verified", "unsigned") and not duplicate
        report["acceptances"].append({"owner": acc.owner, "accepted": acc.accepted,
                                      "bound": bound, "signature": signature, "ok": ok})
        if not bound:
            errors.append(f"{acc.owner}: acceptance is not bound to this record and decision")
        if signature == "FAILED":
            errors.append(f"{acc.owner}: signature does not match the acceptance it is attached to")
        if signature == "unchecked":
            errors.append(f"{acc.owner}: acceptance is signed but unchecked; {INSTALL_HINT}")
        if duplicate:
            errors.append(f"{acc.owner}: more than one acceptance under this name")
        acc_ok = acc_ok and ok
    if not isinstance(receipt["acceptances"], list):
        errors.append("acceptances must be a list")
        acc_ok = False

    all_signed = bool(accs) and all(a.sig and a.pubkey_hex for a in accs)
    verifier = ((lambda a: crypto.verify_acceptance(a, roster=roster))
                if (crypto is not None and all_signed) else None)
    unanimous = agreement(transcript, accs, verifier=verifier,
                          participants=set(participants) if roster_ok else None)
    unanimous_ok = unanimous == receipt["unanimous_acceptance"]
    report["unanimous"] = {"recomputed": unanimous, "claimed": receipt["unanimous_acceptance"],
                           "with_signatures": verifier is not None, "ok": unanimous_ok}
    if not unanimous_ok:
        errors.append("receipt's unanimous_acceptance claim does not match the recomputation")

    pinned_ok = True
    if roster is not None:
        # Pinned keys only mean something if every verdict and every acceptance is signed.
        pinned_ok = (verdict_sigs == "verified"
                     and all(a["signature"] == "verified" for a in report["acceptances"])
                     and set(roster) == owners)
        if not pinned_ok:
            errors.append("with a roster, every verdict and acceptance must carry a signature under "
                          "the owner's pinned key, and the record's owners must be exactly the roster")
    report["ok"] = (hash_ok and result_ok and max_min_ok and roster_ok and acc_ok
                    and unanimous_ok and verdict_sigs in ("none", "verified") and pinned_ok
                    and not errors_from_roster(errors))
    return report


def errors_from_roster(errors: list) -> bool:
    return any(e.startswith(("roster must", "the receipt's participants are not")) for e in errors)


def _mark(ok: bool) -> str:
    return "OK" if ok else "FAIL"


def render(report: dict) -> str:
    """Plain text: what was checked, the answer, and what the answer does not cover."""
    lines = [f"Parley receipt check: {report['title']}"]
    if report["hash"] is None:
        lines += ["", "Result: FAILED", *(f"  - {e}" for e in report["errors"])]
        return "\n".join(lines) + "\n"
    decision = json.dumps(report["decision"], sort_keys=True, ensure_ascii=False)
    lines += [f"  status {report['status']}   decision {decision}", "", "Record"]
    mm = report["max_min"]
    if mm["owner_set_unchecked"]:
        mm_note = "owner set unchecked: participants malformed"
    elif mm["owner_set_mismatch"]:
        mm_note = "owner set does not match participants"
    else:
        mm_note = "recomputed from the recorded verdicts"
    participants = report["roster"]["participants"]
    roster = (", ".join(map(str, participants)) if isinstance(participants, list)
              else json.dumps(participants))
    h = report["hash"]
    lines += [f"  transcript hash   claimed    {h['claimed']}",
              f"                    recomputed {h['recomputed']}   {_mark(h['ok'])}",
              f"  status/decision   match the finalized record   {_mark(report['result']['ok'])}",
              f"  max-min honest    {'yes' if report['max_min']['recomputed'] else 'NO'}"
              f" ({mm_note})"
              f"   {_mark(report['max_min']['ok'])}",
              f"  roster            {roster}"
              f" (record: {', '.join(report['roster']['transcript_owners'])})"
              f"   {_mark(report['roster']['ok'])}",
              f"  verdict sigs      {report['verdict_signatures']}", "", "Acceptances"]
    for a in report["acceptances"]:
        lines.append(f"  {a['owner']:<16} {'accepted' if a['accepted'] else 'declined':<9}"
                     f" {'bound to this record' if a['bound'] else 'NOT bound to this record':<25}"
                     f" signature {a['signature']:<10} {_mark(a['ok'])}")
    if not report["acceptances"]:
        lines.append("  (none recorded)")
    u = report["unanimous"]
    if u["with_signatures"]:
        tier = "checked with signatures"
    elif any(a["signature"] != "unsigned" for a in report["acceptances"]):
        tier = "tamper-evidence only: signatures present but not all checked"
    else:
        tier = "tamper-evidence only: unsigned"
    lines.append(f"  unanimous         {'yes' if u['recomputed'] else 'no'}"
                 f" (receipt says {'yes' if u['claimed'] else 'no'}; {tier})   {_mark(u['ok'])}")
    lines.append("")
    if report["ok"]:
        lines.append("Result: VERIFIED. Every claim in this receipt re-derives from its own record.")
    else:
        lines += ["Result: FAILED", *(f"  - {e}" for e in report["errors"])]
    lines += [
        "",
        "What this checked",
        "  - the transcript in the receipt hashes to the hash the receipt claims, so the record",
        "    was not edited after the receipt was written",
        "  - the announced decision is the max-min option over the recorded verdicts (or the",
        "    deadlock is honest: no option cleared everyone's red lines), and every entry",
        "    carries exactly one verdict from each listed participant",
        "  - every acceptance is bound to exactly this hash and this decision, by an owner who",
        "    appears in the record, and the unanimity claim matches those acceptances",
        "  - a signed acceptance or verdict validates against the public key it carries",
        "",
        "What this does NOT prove",
        "  - who signed: each public key is self-attested by the record that carries it, so",
        "    anyone holding a key (the coordinator included) can sign an acceptance or a",
        "    verdict under another owner's name. There is no owner -> key roster yet (SECURITY.md).",
        "  - unsigned acceptances authenticate nobody: that tier is tamper-evidence only",
        "  - that the roster is the right one: the participants list is itself coordinator-written.",
        "    Checking every entry against it binds the record to the roster you expect only if",
        "    you check that list against who you know took part.",
        "  - that this is the record the participants saw: the transcript is coordinator-",
        "    authored. Each participant must compare the hash they were shown at ratification",
        "    with the recomputed one above.",
        "  - that nobody's red line was crossed: no private sheet is in the receipt or read here.",
        "    Each owner replays their own sheet locally (Transcript.verify_non_betrayal).",
        "  - freshness: verdict payloads carry no session id or nonce, so a genuine signed",
        "    verdict can be replayed into another parley over the same option",
    ]
    if not report["crypto"]:
        lines += ["", f"Note: pynacl is not installed; signatures cannot be checked here ({INSTALL_HINT})."]
    return "\n".join(lines) + "\n"


def main(argv: Optional[list] = None) -> int:
    parser = argparse.ArgumentParser(description="Check a receipt from examples/real_decision.py.")
    parser.add_argument("receipt", help="path to the receipt JSON")
    parser.add_argument("--roster", help="JSON file {owner: pubkey_hex} that each owner pinned at join, "
                        "out of band from whoever wrote the receipt; without it a signature only "
                        "proves the record was not edited, not who signed")
    args = parser.parse_args(argv)
    roster = None
    if args.roster:
        try:
            with open(args.roster, encoding="utf-8") as fh:
                roster = json.load(fh)
            if not isinstance(roster, dict):
                raise ValueError("roster must be a JSON object")
        except (OSError, ValueError) as exc:
            print(f"cannot read roster: {exc}", file=sys.stderr)
            return 2
    try:
        with open(args.receipt, encoding="utf-8") as fh:
            receipt = json.load(fh)
    except (OSError, ValueError) as exc:
        print(f"cannot read receipt: {exc}", file=sys.stderr)
        return 2
    report = verify(receipt, roster=roster)
    sys.stdout.write(render(report))
    return 0 if report["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())