#!/usr/bin/env python3
"""verify_consistency.py — prove the BlueFox log only ever grew, between two
checkpoints you hold.

Download it, run it, edit nothing:

    curl -sS -O https://api.bluefoxedge.ai/verify_consistency.py
    curl -sS https://api.bluefoxedge.ai/verify_consistency.py.sha256 | shasum -a 256 -c -
    curl -sS "https://api.bluefoxedge.ai/v1/transparency/consistency\
?log_id=reliance/v1&from=<the size you saved>&to=<a newer size>" \\
      | python3 verify_consistency.py

Exit 0 means the two checkpoints are consistent — the newer tree contains the
older one, unchanged, as a prefix. Any non-zero exit means it does not, and the
reason is one sentence on stderr — never a stack trace.

DEPENDENCIES: none. Python 3.8+ standard library only. If the third-party
``cryptography`` package happens to be installed the script uses its Ed25519
verifier; otherwise it falls back to a self-contained RFC 8032 verifier bundled
below. Both paths check the same bytes; ``--pure-ed25519`` forces the bundled one.

═══════════════════════════════════════════════════════════════════════════════
WHY THIS SCRIPT EXISTS
═══════════════════════════════════════════════════════════════════════════════

An inclusion proof binds your receipt to ONE checkpoint — the tree as it stood
at some size N. It is a statement about a moment, and it never expires. But it
also never, on its own, tells you that the log kept its promise afterwards. A
log that quietly rewrote its history would hand you a perfectly valid inclusion
proof against a tree that no longer contains your leaf where it used to.

The proof that history was not rewritten is a CONSISTENCY proof, and checking
one correctly is genuinely fiddly: the RFC 6962 algorithm is short, subtle, and
easy to get wrong in a way that still returns True on honest inputs. An auditor
who reimplements it from the RFC and tests it only against our real responses
will very likely ship something that passes everything we send and would also
pass a forgery. That is why this file is published rather than described.

═══════════════════════════════════════════════════════════════════════════════
THE RECIPE — what this script checks, and why each step is here
═══════════════════════════════════════════════════════════════════════════════

 1. TWO KEY FAMILIES, AND THEY NEVER CROSS. Receipts are signed by the receipt
    key. Checkpoints are signed by the inclusion-log key — a DIFFERENT key. Both
    public keys are published in the same key set at
    https://api.bluefoxedge.ai/.well-known/jwks.json. Where the published set
    labels its families, each entry declares its own in a ``bfx:purpose``
    member, and A CHECKPOINT IS ONLY EVER CHECKED HERE AGAINST A "log"-PURPOSE
    KEY. DO NOT SELECT THE KEY BY POSITION: ``keys[0]`` is the RECEIPT key, and
    a checker that reasonably takes the first entry gets the wrong key for this
    job. Read ``bfx:purpose``; never the index, and never the order. An older
    key set carrying no labels still works — but then this script cannot enforce
    the separation, and it SAYS SO on the success line rather than letting a
    green result imply a check it did not make.

 2. PIN THE KEY OUT OF BAND. A verifier MUST NOT follow a key-set address
    carried inside the artifact it is checking. This script fetches from the
    address in its own source (the constant JWKS_URL below), an address you can
    read before you run it, and it never reads a key address out of the
    response. ``--jwks-file`` and ``--jwks-url`` let you supply the key set
    yourself, from your own record — still out of band, still never from the
    artifact.

 3. BOTH ENDS MUST BE SIGNED, OR THERE IS NOTHING TO PROVE. A consistency
    response on its own is a CLAIM: it says "the root at size N was X and the
    root at size M was Y, and here is the path between them". Unsigned, that is
    the log's word about its own history — you could compute the same numbers
    from a tree it made up this morning. So this script requires the two SIGNED
    CHECKPOINT NOTES for the two ends. The response bundles them as ``from_note``
    and ``to_note``; if you kept your own copies, pass ``--from-note`` and
    ``--to-note`` and yours win. THE ARITHMETIC BELOW RUNS ON THE ROOTS INSIDE
    THE SIGNED NOTES — never on the unsigned ``from_size`` / ``to_size`` /
    ``from_root`` / ``to_root`` fields beside them. Those four are REQUIRED and
    are never trusted: this script refuses a response that omits any of them,
    and refuses one carrying JSON ``null`` in place of any — a null is not an
    absent field, and a cross-check that silently did not run reads exactly
    like one that passed — and then requires each to equal what the signed
    notes say.

 4. THE NOTE, BYTE-EXACT. Each note is a C2SP signed note, verbatim, trailing
    newline included. Its body is at least three newline-terminated lines: the
    origin, the tree size in decimal, and base64 of the 32 root bytes. Any
    further lines are C2SP extension lines — opaque, optional, and part of the
    signed bytes. Then one blank line. Then the signature line: "— " + key name
    + " " + base64(4-byte key hash || 64-byte Ed25519 signature). The key name
    is the origin string. The key hash is the first 4 bytes of
    sha256(key_name || 0x0A || 0x01 || raw 32-byte public key) — algorithm byte
    0x01. THE SIGNED MESSAGE IS THE WHOLE BODY, every line of it, INCLUDING its
    trailing newline and INCLUDING any extension lines — but NOT the blank line
    that separates body from signatures. This is where an independent
    implementation loses an afternoon; it is stated here so yours does not.

 5. THE TWO NOTES MUST BE FROM THE SAME LOG. Both origin lines must be the same
    string. Two genuine, correctly-signed checkpoints from two different logs
    are not a consistency proof about either of them, and a checker that skips
    this test will happily verify the arithmetic between unrelated trees.

 6. THE OLDER END MUST ACTUALLY BE OLDER. from_size <= to_size, checked against
    the sizes INSIDE THE NOTES rather than the sizes in the response. Handed the
    two notes the other way round, this script says so in those words instead of
    reporting an arithmetic failure that would send you looking for a forgery.

 7. ONE ROOT, TWO SPELLINGS. JSON surfaces spell a root "sha256:<64hex>"; the
    note spells base64 of the same 32 bytes. Convert before comparing. Hex is
    lower-case only — this script REJECTS anything else, never
    normalize-and-accept.

 8. THE CONSISTENCY WALK (RFC 6962 section 2.1.4). Given the two sizes and the
    proof's digest list, recompute BOTH roots: the old one, from the proof, and
    the new one, from the same steps. Both recomputations must land exactly on
    the two roots in the signed notes, and the walk must consume the path
    exactly — a proof with a step left over, or one step short, is refused. The
    defined trivial case is from_size == to_size: an empty path, and the two
    notes must carry the same root.

 9. WHAT SUCCESS MEANS — AND WHAT IT DOES NOT. A passing run proves exactly
    this: both checkpoints were signed by the pinned log key, and the tree at
    the larger size CONTAINS THE TREE AT THE SMALLER SIZE AS AN UNCHANGED
    PREFIX. Nothing was removed, reordered, or edited between them. It does NOT
    prove that any particular receipt is in either tree (that is the inclusion
    check — a separate claim, graded separately, and
    https://api.bluefoxedge.ai/verify_inclusion.py is the script for it), it
    does NOT prove the log showed the same history to anybody else, and it
    involves no outside witness: ``cosignatures`` is empty today, so trust in
    the log key is trust in us. This script says all of that out loud, every
    run — separate claims, separate lines, never merged into one green line.

10. WHAT A CONSISTENCY PROOF CANNOT DO FOR YOU. It ties two checkpoints YOU
    HOLD. It cannot retroactively bind a checkpoint you never saved: if you have
    only today's checkpoint, a consistency proof back to last month's size
    proves the arithmetic, but last month's root is then still only our word.
    THE HABIT THAT FIXES THAT IS YOURS TO KEEP: save the signed note every time
    you verify anything, and check each new one against your oldest. This is a
    real limit of every transparency log with a single origin and no witness,
    and it is stated here rather than left for you to discover.

11. THE WHOLE WALK, WORKED — first day, no saved notes, nothing installed.
    Every number below was verified by an outside auditor against a key set
    she pinned herself; the walk used to start with an integer sweep (the API
    served checkpoints "for published sizes only" and published no index of
    which sizes those were — discovering a second fold cost her the longest
    stretch of her audit). It is now one GET:

      # 1. WHICH SIZES EXIST — the GET that used to be an integer sweep.
      curl -sS "https://api.bluefoxedge.ai/v1/transparency/checkpoints/history?log_id=reliance/v1&limit=100"

      # 2. PICK TWO. Any two published sizes, older and newer.
      #    (2900 and 3128 are the pair the auditor walked on 2026-08-21.)

      # 3. THE PROOF between them, carrying BOTH signed notes.
      curl -sS "https://api.bluefoxedge.ai/v1/transparency/consistency?log_id=reliance/v1&from=2900&to=3128" \
        > consistency_2900_3128.json

      # 4. CHECK IT, offline, against a key set you pinned yourself.
      curl -sSo pinned-jwks.json https://api.bluefoxedge.ai/.well-known/jwks.json
      python3 verify_consistency.py --jwks-file pinned-jwks.json --file consistency_2900_3128.json

    What a pass looks like (hers, verbatim):

      older checkpoint : size 2900, root sha256:6db1b82a…a3e2   (issued 2026-08-20T19:05:23Z)
      newer checkpoint : size 3128, root sha256:e2a29116…f5a9   (issued 2026-08-21T08:07:04Z)
      VERIFIED -- log consistency: … the tree of 3128 leaves contains the tree
      of 2900 leaves as an unchanged prefix. → EXIT 0

    And the three plants that made that green MEAN something — a reader who
    has not seen the tool fail has not seen the tool:

      one-bit flip in a proof step   -> "the proof does not rebuild the OLDER
                                         root … the signed notes win" (exit 1)
      alter the UNSIGNED from_root   -> "from_root is not the root inside the
                                         SIGNED 'from' note" (exit 1)
      alter the SIGNED from_note     -> signature refusal (exit 1)

    The middle plant is the whole argument for the history list's own notice:
    this tool already refuses to let an unsigned convenience win, and the
    history index is ALL unsigned conveniences — take the numbers you prove
    with from a signed note, never from a list. The index gives a first-day
    auditor a starting point; the item-10 habit (save every note, check each
    new one against your oldest) is what carries you after. An index does not
    retire that habit, and nothing here says otherwise.
"""
from __future__ import annotations

import argparse
import base64
import binascii
import hashlib
import json
import re
import sys
import urllib.error
import urllib.request

# ── The pin (recipe item 2). An address in this file's own bytes, readable
#    before you run it — never a pointer taken from the artifact being checked.
JWKS_URL = "https://api.bluefoxedge.ai/.well-known/jwks.json"

DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
SIG_LINE_PREFIX = "— "  # em dash + space, per the C2SP signed-note format
NOTE_ED25519_ALG = b"\x01"
MAX_PATH_LENGTH = 64  # the frozen proof bound; an absurd tree fails fast

# ── Item 1's allowlist: the published key set labels which family each key
#    serves, and a checkpoint is only ever checked against a "log"-purpose key.
JWK_PURPOSE_CLAIM = "bfx:purpose"
PURPOSE_LOG = "log"

EXIT_OK = 0
EXIT_REFUSED = 1


# ══════════════════════════════════════════════════════════════════════════════
# The wall: every negative outcome leaves through here — one sentence, a
# non-zero exit, and no traceback. There is no bare ``assert`` anywhere on a
# verification branch in this file, by design: a stranger who catches a forgery
# must be handed a verdict, never a stack trace.
# ══════════════════════════════════════════════════════════════════════════════
def refuse(sentence: str) -> "None":
    sys.stderr.write("NOT PROVED: " + sentence + "\n")
    sys.exit(EXIT_REFUSED)


def present_field(container: dict, key: str, what: str, required: bool) -> object:
    """ABSENT and PRESENT, told apart — because ``.get()`` cannot tell them apart.

    ``dict.get()`` collapses two different facts into one ``None``: the key is
    MISSING, and the key is THERE carrying JSON ``null``. A gate written
    ``if x is not None:`` therefore skips a PRESENT null exactly as it skips an
    absent key — and on a field this script promises to cross-check against the
    signed artifact, a skipped check reads exactly like a passed one.

    PRESENT-and-null REFUSES, always: nothing here reads a null as "absent".
    ABSENT refuses when the field is required, and is lawfully skippable —
    returning ``None`` — only where this script's own recipe above says the
    field is optional.
    """
    if key not in container:
        if required:
            return refuse(
                "there is no %r field here, and this script cross-checks it "
                "against the signed artifact rather than trusting it -- "
                "refused rather than skipped" % key)
        return None
    value = container[key]
    if value is None:
        return refuse(
            "%s is PRESENT and null. A null is not an absent field: this "
            "script cross-checks that value against the signed artifact, so "
            "it is refused rather than skipped -- a skipped check reads "
            "exactly like a passed one" % what)
    return value


# ══════════════════════════════════════════════════════════════════════════════
# Ed25519 verification (RFC 8032), self-contained. Used when the third-party
# ``cryptography`` package is absent, so this script runs on a bare Python.
# Byte-identical to the block in verify_inclusion.py — one construction, copied
# rather than paraphrased, so the two siblings cannot drift into two answers.
# ══════════════════════════════════════════════════════════════════════════════
_P = 2 ** 255 - 19
_L = 2 ** 252 + 27742317777372353535851937790883648493


def _inv(x: int) -> int:
    return pow(x, _P - 2, _P)


_D = (-121665 * _inv(121666)) % _P
_SQRT_M1 = pow(2, (_P - 1) // 4, _P)


def _x_recover(y: int) -> "int | None":
    xx = ((y * y - 1) * _inv(_D * y * y + 1)) % _P
    x = pow(xx, (_P + 3) // 8, _P)
    if (x * x - xx) % _P != 0:
        x = (x * _SQRT_M1) % _P
    if (x * x - xx) % _P != 0:
        return None
    if x % 2 != 0:
        x = _P - x
    return x


def _point_add(pt1, pt2):
    x1, y1 = pt1
    x2, y2 = pt2
    prod = (_D * x1 * x2 * y1 * y2) % _P
    x3 = ((x1 * y2 + x2 * y1) * _inv(1 + prod)) % _P
    y3 = ((y1 * y2 + x1 * x2) * _inv(1 - prod)) % _P
    return (x3, y3)


def _scalar_mult(pt, scalar: int):
    acc = (0, 1)
    while scalar > 0:
        if scalar & 1:
            acc = _point_add(acc, pt)
        pt = _point_add(pt, pt)
        scalar >>= 1
    return acc


_BASE_Y = (4 * _inv(5)) % _P
_BASE = (_x_recover(_BASE_Y), _BASE_Y)


def _on_curve(pt) -> bool:
    x, y = pt
    return (-x * x + y * y - 1 - _D * x * x * y * y) % _P == 0


def _decode_point(blob: bytes):
    value = int.from_bytes(blob, "little")
    y = value & ((1 << 255) - 1)
    sign = value >> 255
    if y >= _P:
        return None
    x = _x_recover(y)
    if x is None:
        return None
    if x & 1 != sign:
        x = _P - x
    pt = (x, y)
    if not _on_curve(pt):
        return None
    return pt


def _ed25519_verify_pure(public_raw: bytes, signature: bytes, message: bytes) -> bool:
    """RFC 8032 Ed25519 verify. Returns True/False; never raises, never asserts."""
    if len(public_raw) != 32 or len(signature) != 64:
        return False
    point_a = _decode_point(public_raw)
    point_r = _decode_point(signature[:32])
    if point_a is None or point_r is None:
        return False
    scalar_s = int.from_bytes(signature[32:], "little")
    if scalar_s >= _L:
        return False
    challenge = int.from_bytes(
        hashlib.sha512(signature[:32] + public_raw + message).digest(), "little"
    ) % _L
    left = _scalar_mult(_BASE, scalar_s)
    right = _point_add(point_r, _scalar_mult(point_a, challenge))
    return left == right


def ed25519_verify(public_raw: bytes, signature: bytes, message: bytes,
                   force_pure: bool = False) -> bool:
    """Verify with ``cryptography`` when it is installed, else with the bundled
    RFC 8032 code above. Same bytes, same answer; the fallback exists so a bare
    stdlib Python can still run this script unedited."""
    if not force_pure:
        try:
            from cryptography.exceptions import InvalidSignature
            from cryptography.hazmat.primitives.asymmetric import ed25519 as _ed
        except ImportError:
            pass
        else:
            try:
                _ed.Ed25519PublicKey.from_public_bytes(public_raw).verify(
                    signature, message
                )
                return True
            except InvalidSignature:
                return False
            except Exception:
                return False
    return _ed25519_verify_pure(public_raw, signature, message)


# ══════════════════════════════════════════════════════════════════════════════
# Merkle arithmetic (RFC 6962), recipe item 8.
# ══════════════════════════════════════════════════════════════════════════════
def node_hash(left: bytes, right: bytes) -> bytes:
    return hashlib.sha256(b"\x01" + left + right).digest()


# ══════════════════════════════════════════════════════════════════════════════
# Encoding helpers — reject, never normalize (item 7).
# ══════════════════════════════════════════════════════════════════════════════
def digest_bytes(value: object, what: str) -> bytes:
    if not isinstance(value, str) or not DIGEST_RE.match(value):
        return refuse(
            "%s is not a 'sha256:' + 64 lower-case hex digest, so there is "
            "nothing this script can safely compare -- upper-case hex and "
            "short digests are rejected, never normalised (got %r)"
            % (what, value)
        )
    return bytes.fromhex(value[len("sha256:"):])


def b64_bytes(text: str, what: str) -> bytes:
    try:
        return base64.b64decode(text, validate=True)
    except (binascii.Error, ValueError):
        return refuse("%s is not valid base64, so the note cannot be read" % what)


# ══════════════════════════════════════════════════════════════════════════════
# The C2SP signed note (item 4). Parsed byte-exactly, from the note itself.
#
# A body of MORE than three lines is lawful: C2SP tlog-checkpoint permits
# extension lines after the root line, opaque and optional, and they are part of
# the signed bytes. This parser accepts them, keeps them, and the report says
# which form it verified — so an old three-line note and a note carrying an
# extension line both check out, and you can always tell which one you have.
# ══════════════════════════════════════════════════════════════════════════════
def parse_note(note_text: object, which: str) -> dict:
    if not isinstance(note_text, str) or not note_text:
        return refuse(
            "there is no signed checkpoint note for the %s end of this range, "
            "and the notes are the only signed things here -- pass --%s-note "
            "with the note you saved, or ask the endpoint for a response that "
            "bundles it" % (which, which)
        )
    if "\n\n" not in note_text:
        return refuse(
            "the %s checkpoint note has no blank line between its body and its "
            "signature, so it is not a C2SP signed note" % which
        )
    body, _, sig_block = note_text.partition("\n\n")
    body += "\n"
    lines = body.splitlines()
    if len(lines) < 3:
        return refuse(
            "the %s checkpoint note body is %d line(s); a C2SP tlog checkpoint "
            "body is at least three (origin, decimal tree size, base64 root)"
            % (which, len(lines))
        )
    origin, size_line, root_b64 = lines[0], lines[1], lines[2]
    extensions = lines[3:]
    if any(not line for line in extensions):
        return refuse(
            "the %s checkpoint note body carries an empty extension line, which "
            "C2SP forbids -- extension lines must be non-empty" % which
        )
    if not size_line.isdigit():
        return refuse(
            "the %s checkpoint note's second line is not a bare decimal tree "
            "size (got %r)" % (which, size_line)
        )
    root = b64_bytes(root_b64, "the %s checkpoint note's root line" % which)
    if len(root) != 32:
        return refuse(
            "the %s checkpoint note's root decodes to %d bytes; a sha256 root "
            "is 32" % (which, len(root))
        )
    signatures = []
    for line in sig_block.splitlines():
        if not line:
            continue
        if not line.startswith(SIG_LINE_PREFIX):
            return refuse(
                "a signature line in the %s checkpoint note does not start with "
                "the C2SP marker (em dash + space)" % which
            )
        rest = line[len(SIG_LINE_PREFIX):]
        key_name, _, blob_b64 = rest.rpartition(" ")
        blob = b64_bytes(blob_b64, "a signature line in the %s checkpoint note"
                         % which)
        if len(blob) != 68:
            return refuse(
                "a signature line in the %s note carries %d bytes; a signed-note "
                "Ed25519 line is 4 bytes of key hash plus a 64-byte signature"
                % (which, len(blob))
            )
        signatures.append(
            {"key_name": key_name, "key_hash": blob[:4], "signature": blob[4:]}
        )
    if not signatures:
        return refuse(
            "the %s checkpoint note carries no signature line at all" % which
        )
    return {
        "origin": origin,
        "tree_size": int(size_line),
        "root": root,
        "body": body,
        "extensions": extensions,
        "signatures": signatures,
    }


def note_key_hash(key_name: str, public_raw: bytes) -> bytes:
    """sha256(key_name || 0x0A || 0x01 || raw public key)[:4] — item 4."""
    return hashlib.sha256(
        key_name.encode("utf-8") + b"\n" + NOTE_ED25519_ALG + public_raw
    ).digest()[:4]


# ══════════════════════════════════════════════════════════════════════════════
# The pinned key set (items 1 and 2).
# ══════════════════════════════════════════════════════════════════════════════
def load_key_set(key_set_url: str, key_set_file: "str | None") -> dict:
    if key_set_file:
        try:
            with open(key_set_file, "rb") as handle:
                raw = handle.read()
        except OSError as exc:
            return refuse(
                "the key set file %s could not be read (%s)" % (key_set_file, exc)
            )
    else:
        try:
            with urllib.request.urlopen(key_set_url, timeout=30) as response:
                raw = response.read()
        except (urllib.error.URLError, OSError, ValueError) as exc:
            return refuse(
                "the pinned key set at %s could not be fetched (%s) -- this "
                "script will not fall back to any address found inside the "
                "response, so it stops here" % (key_set_url, exc)
            )
    try:
        parsed = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, ValueError) as exc:
        return refuse("the key set is not valid JSON (%s)" % exc)
    if not isinstance(parsed, dict) or not isinstance(parsed.get("keys"), list):
        return refuse("the key set has no 'keys' array, so no key can be pinned")
    return parsed


def ed25519_keys(key_set: dict) -> "list[tuple[str, bytes, str | None]]":
    """(kid, raw 32-byte public key, purpose) for every OKP/Ed25519 entry whose
    kid is self-consistent: the published kid IS sha256(raw public key) in hex,
    so an entry that disagrees with its own key material is dropped, not
    trusted. ``purpose`` is the entry's ``bfx:purpose`` member — "log" or
    "receipt" — or None on a key set published before the labels existed."""
    out = []
    for entry in key_set["keys"]:
        if not isinstance(entry, dict):
            continue
        if entry.get("kty") != "OKP" or entry.get("crv") != "Ed25519":
            continue
        kid, x_value = entry.get("kid"), entry.get("x")
        if not isinstance(kid, str) or not isinstance(x_value, str):
            continue
        # Strict, and canonical: the same "reject, never normalize" rule item 7
        # applies to hex. A non-canonical base64url spelling of the right bytes
        # is still a spelling we did not publish, so it is dropped rather than
        # quietly accepted — re-encoding and requiring byte equality is the
        # cheapest complete test for that.
        padded = x_value + "=" * (-len(x_value) % 4)
        try:
            raw = base64.b64decode(padded, altchars=b"-_", validate=True)
        except (binascii.Error, ValueError):
            continue
        if len(raw) != 32:
            continue
        if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != x_value:
            continue
        if hashlib.sha256(raw).hexdigest() != kid:
            continue
        purpose = entry.get(JWK_PURPOSE_CLAIM)
        out.append((kid, raw, purpose if isinstance(purpose, str) else None))
    return out


def log_key_candidates(key_set: dict) -> "tuple[list, list, bool]":
    """Split the published keys into the ones eligible to verify a checkpoint
    and the ones that are not (item 1). Returns (eligible, ineligible,
    purpose_enforced). When no entry carries a label, every self-consistent key
    is eligible and ``purpose_enforced`` is False — a holder who pinned their
    own copy before the labels existed must not be handed a refusal for keeping
    a record, but the success line then discloses that this check was not made.
    An UNLABELLED entry inside a LABELLED set is ineligible: a key that does not
    claim the job does not get it."""
    published = ed25519_keys(key_set)
    enforced = any(purpose is not None for _, _, purpose in published)
    if not enforced:
        return [(kid, raw) for kid, raw, _ in published], [], False
    eligible = [(kid, raw) for kid, raw, purpose in published
                if purpose == PURPOSE_LOG]
    ineligible = [(kid, raw, purpose) for kid, raw, purpose in published
                  if purpose != PURPOSE_LOG]
    return eligible, ineligible, True


def verify_note_signature(note: dict, key_set: dict, force_pure: bool,
                          which: str) -> "tuple[str, bool]":
    """Verify the log's own signature over one note's body. Returns
    (verified kid, purpose_enforced). Refuses on any failure, and diagnoses the
    key-family crossing by name rather than as an anonymous "unknown key"."""
    key_name = note["origin"]
    # A C2SP note may carry MORE signature lines than the log's own: a witness
    # cosignature is an extra line under a different key name, and the format
    # accommodates it without change. Lines that do not name this log's origin
    # are skipped here, not refused — this script checks the LOG's signature and
    # says plainly, every run, that it checked no witness. The day a witness is
    # added, this script keeps working.
    own_lines = [s for s in note["signatures"] if s["key_name"] == key_name]
    if not own_lines:
        return refuse(
            "the %s checkpoint note carries no signature line under its own "
            "origin %r, so nothing in it is signed by the log it claims to be "
            "from" % (which, key_name)
        )
    eligible, ineligible, enforced = log_key_candidates(key_set)
    if not eligible and not ineligible:
        return refuse(
            "the pinned key set publishes no self-consistent Ed25519 key, so "
            "there is nothing to verify the %s checkpoint against" % which
        )
    for kid, public_raw in eligible:
        expected_hint = note_key_hash(key_name, public_raw)
        for signature in own_lines:
            if signature["key_hash"] != expected_hint:
                continue
            if ed25519_verify(public_raw, signature["signature"],
                              note["body"].encode("utf-8"), force_pure):
                return kid, enforced
            return refuse(
                "the %s checkpoint note's signature does not verify against the "
                "pinned log key %s -- these are not bytes that key signed"
                % (which, kid)
            )
    # No eligible key claimed the note. Only NOW is it worth asking whether an
    # INELIGIBLE published key did — the diagnosis must never pre-empt a genuine
    # verification, and naming the family beats a false "unknown key".
    for kid, public_raw, purpose in ineligible:
        expected_hint = note_key_hash(key_name, public_raw)
        if any(s["key_hash"] == expected_hint for s in own_lines):
            designation = ("is designated %r" % purpose if purpose
                           else "carries no purpose label")
            return refuse(
                "the %s checkpoint is signed by the published key %s, which %s "
                "and so is not the inclusion log's key -- the two key families "
                "never cross, and a checkpoint under a receipt key is refused "
                "here rather than verified and reported as a log proof"
                % (which, kid, designation)
            )
    return refuse(
        "no key in the pinned key set matches the %s checkpoint note's key hint "
        "for origin %r -- the note was signed by a key we do not publish, and "
        "this script will not go looking for one elsewhere" % (which, key_name)
    )


# ══════════════════════════════════════════════════════════════════════════════
# Item 8 — the RFC 6962 section 2.1.4 consistency walk.
#
# The arithmetic is the shipped construction's, step for step. What is added
# here is a SENTENCE at every distinguishable way it can fail: a bare False is
# no use to a stranger trying to work out whether they have found a forgery or
# a typo.
# ══════════════════════════════════════════════════════════════════════════════
def walk_consistency(from_size: int, to_size: int, from_root: bytes,
                     to_root: bytes, path: "list[bytes]") -> None:
    """Refuses with one sentence, or returns None having proved that the tree at
    ``to_size`` contains the tree at ``from_size`` as an unchanged prefix."""
    if from_size == to_size:
        # The defined trivial case: nothing to walk, and the two signed notes
        # must agree about the root of the one tree they both describe.
        if path:
            return refuse(
                "the two checkpoints are the same size (%d), which is the "
                "trivial case and takes an empty path, but the response carries "
                "%d proof step(s)" % (from_size, len(path))
            )
        if from_root != to_root:
            return refuse(
                "the two signed checkpoints both declare tree size %d but carry "
                "DIFFERENT roots -- that is not an inconsistency this proof can "
                "resolve, it is two irreconcilable claims about one tree, and it "
                "is the shape a split view would take" % from_size
            )
        return None
    if not path:
        return refuse(
            "the response carries no proof steps, but tying size %d to size %d "
            "requires at least one -- an empty path is only ever valid when the "
            "two sizes are equal" % (from_size, to_size)
        )

    fn, sn = from_size - 1, to_size - 1
    # Skip the common power-of-two prefix: while the old tree's last leaf is a
    # right child, both trees share that whole subtree and the proof says nothing
    # about it.
    while fn & 1:
        fn >>= 1
        sn >>= 1
    if fn:
        from_recomputed = to_recomputed = path[0]
        rest = path[1:]
    else:
        # from_size is a power of two: its root is a node of the newer tree, so
        # the proof does not repeat it and the walk starts from the SIGNED root.
        from_recomputed = to_recomputed = from_root
        rest = path
    for position, step in enumerate(rest):
        if sn == 0:
            return refuse(
                "the proof has %d step(s) left over after the walk from size %d "
                "to size %d was complete -- a consistency proof for this pair "
                "has exactly one length, and a longer one is not a stronger "
                "proof, it is a different tree" % (len(rest) - position,
                                                   from_size, to_size)
            )
        if fn & 1 or fn == sn:
            from_recomputed = node_hash(step, from_recomputed)
            to_recomputed = node_hash(step, to_recomputed)
            while fn and not fn & 1:
                fn >>= 1
                sn >>= 1
        else:
            to_recomputed = node_hash(to_recomputed, step)
        fn >>= 1
        sn >>= 1
    if sn != 0:
        return refuse(
            "the proof ran out of steps before the walk from size %d to size %d "
            "was complete -- a truncated consistency proof is refused, never "
            "credited for the part of the tree it did cover"
            % (from_size, to_size)
        )
    if from_recomputed != from_root:
        return refuse(
            "the proof does not rebuild the OLDER root: walking it produces "
            "sha256:%s, but the signed checkpoint at size %d says sha256:%s -- "
            "the signed notes win, and this proof is not about that tree"
            % (from_recomputed.hex(), from_size, from_root.hex())
        )
    if to_recomputed != to_root:
        return refuse(
            "the proof does not rebuild the NEWER root: walking it produces "
            "sha256:%s, but the signed checkpoint at size %d says sha256:%s -- "
            "the signed notes win, and these are not consistent trees"
            % (to_recomputed.hex(), to_size, to_root.hex())
        )
    return None


# ══════════════════════════════════════════════════════════════════════════════
# Input.
# ══════════════════════════════════════════════════════════════════════════════
def read_response(path: "str | None") -> dict:
    if path:
        try:
            with open(path, "rb") as handle:
                raw = handle.read()
        except OSError as exc:
            return refuse(
                "the consistency response file %s could not be read (%s)"
                % (path, exc)
            )
    else:
        raw = sys.stdin.buffer.read()
    if not raw.strip():
        return refuse(
            "no consistency response was supplied -- pipe the response in on "
            "stdin, or pass --file"
        )
    try:
        parsed = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, ValueError) as exc:
        return refuse("the consistency response is not valid JSON (%s)" % exc)
    if not isinstance(parsed, dict):
        return refuse("the consistency response is not a JSON object")
    # The service wraps its answers in {"data": ..., "meta": ...}; a bare data
    # object is accepted too, so a holder who saved either shape can still run
    # this unedited.
    inner = parsed.get("data")
    if isinstance(inner, dict) and "from_size" in inner:
        return inner
    if "from_size" in parsed:
        return parsed
    if isinstance(parsed.get("error"), dict) or "type" in parsed:
        detail = parsed.get("detail") or parsed.get("title") or "no detail given"
        return refuse(
            "this is a refusal from the service, not a consistency proof (%s) "
            "-- there is nothing to check" % detail
        )
    return refuse(
        "the JSON carries no 'from_size' field, so it is not a consistency "
        "response from /v1/transparency/consistency"
    )


def read_note_file(path: "str | None", which: str) -> "str | None":
    if not path:
        return None
    try:
        with open(path, "rb") as handle:
            raw = handle.read()
    except OSError as exc:
        return refuse(
            "the %s checkpoint note file %s could not be read (%s)"
            % (which, path, exc)
        )
    try:
        return raw.decode("utf-8")
    except UnicodeDecodeError:
        return refuse(
            "the %s checkpoint note file %s is not UTF-8 text, so it is not a "
            "C2SP note" % (which, path)
        )


# ══════════════════════════════════════════════════════════════════════════════
# The check.
# ══════════════════════════════════════════════════════════════════════════════
def check(response: dict, key_set: dict, force_pure: bool,
          from_note_text: "str | None", to_note_text: "str | None") -> dict:
    # ── Item 3: the two signed notes. Yours win over the bundled ones — a copy
    #    you saved yourself is better evidence than one handed to you today.
    from_supplied = from_note_text is not None
    to_supplied = to_note_text is not None
    if from_note_text is None:
        from_note_text = response.get("from_note")
    if to_note_text is None:
        to_note_text = response.get("to_note")
    from_note = parse_note(from_note_text, "from")
    to_note = parse_note(to_note_text, "to")

    from_kid, enforced = verify_note_signature(from_note, key_set, force_pure,
                                               "from")
    to_kid, _ = verify_note_signature(to_note, key_set, force_pure, "to")

    # ── Item 5: one log, or this proves nothing about either.
    if from_note["origin"] != to_note["origin"]:
        return refuse(
            "the two checkpoint notes name DIFFERENT logs (%r and %r) -- both "
            "may be genuine, but a consistency proof between two logs is not a "
            "statement about either of them"
            % (from_note["origin"], to_note["origin"])
        )
    if from_kid != to_kid:
        return refuse(
            "the two checkpoint notes verify under DIFFERENT keys (%s and %s) "
            "-- consistency is a claim by one signer about its own history, and "
            "two signers cannot make it jointly here" % (from_kid, to_kid)
        )

    # ── Item 6: the older end must be older, judged by the SIGNED sizes.
    from_size = from_note["tree_size"]
    to_size = to_note["tree_size"]
    if from_size < 1:
        return refuse(
            "the 'from' checkpoint note declares tree size %d" % from_size)
    if to_size > (1 << MAX_PATH_LENGTH):
        return refuse(
            "the 'to' checkpoint note declares a tree of %d leaves, past the "
            "frozen %d-step bound -- refusing rather than computing it"
            % (to_size, MAX_PATH_LENGTH)
        )
    if from_size > to_size:
        return refuse(
            "the 'from' checkpoint is size %d and the 'to' checkpoint is size "
            "%d, so the notes are the wrong way round -- consistency runs from "
            "the SMALLER tree to the larger one; swap them and run this again"
            % (from_size, to_size)
        )

    # ── Item 3 + item 7: the unsigned fields must equal the signed notes.
    claimed_from_size = present_field(response, "from_size",
                                  "the response's from_size", True)
    # #346's ruling, applied at this site too: a JSON ``true`` reads as 1 and a
    # JSON ``1.0`` compares equal to 1, so neither may stand in for a tree size.
    if type(claimed_from_size) is not int:
        return refuse(
            "the response's from_size is %r, which is not a plain integer -- a JSON "
            "true reads as 1 and a JSON 1.0 compares equal to 1, so neither is "
            "accepted where a tree size is meant" % (claimed_from_size,))
    if claimed_from_size != from_size:
        return refuse(
            "the response says from_size %r but the SIGNED 'from' note says %d "
            "-- the note is the signed artifact and the unsigned field beside "
            "it does not match it" % (claimed_from_size, from_size)
        )
    claimed_to_size = present_field(response, "to_size",
                                  "the response's to_size", True)
    # #346's ruling, applied at this site too: a JSON ``true`` reads as 1 and a
    # JSON ``1.0`` compares equal to 1, so neither may stand in for a tree size.
    if type(claimed_to_size) is not int:
        return refuse(
            "the response's to_size is %r, which is not a plain integer -- a JSON "
            "true reads as 1 and a JSON 1.0 compares equal to 1, so neither is "
            "accepted where a tree size is meant" % (claimed_to_size,))
    if claimed_to_size != to_size:
        return refuse(
            "the response says to_size %r but the SIGNED 'to' note says %d -- "
            "the note is the signed artifact and the unsigned field beside it "
            "does not match it" % (claimed_to_size, to_size)
        )
    claimed_from_root = present_field(response, "from_root",
                                      "the response's from_root", True)
    if digest_bytes(claimed_from_root,
                    "the response's from_root") != from_note["root"]:
        return refuse(
            "the response's from_root is not the root inside the SIGNED "
            "'from' note -- the note wins, and these disagree"
        )
    claimed_to_root = present_field(response, "to_root",
                                    "the response's to_root", True)
    if digest_bytes(claimed_to_root,
                    "the response's to_root") != to_note["root"]:
        return refuse(
            "the response's to_root is not the root inside the SIGNED 'to' "
            "note -- the note wins, and these disagree"
        )
    claimed_origin = response.get("origin")
    if isinstance(claimed_origin, str) and claimed_origin != from_note["origin"]:
        return refuse(
            "the response's origin is %r but the signed notes' origin line is "
            "%r" % (claimed_origin, from_note["origin"])
        )

    # ── Item 8: the walk, on the roots INSIDE the signed notes.
    raw_path = response.get("path")
    if not isinstance(raw_path, list):
        return refuse(
            "the response carries no 'path' array, so there is no consistency "
            "proof here to walk"
        )
    if len(raw_path) > MAX_PATH_LENGTH:
        return refuse(
            "the proof has %d steps, past the frozen %d-step bound"
            % (len(raw_path), MAX_PATH_LENGTH)
        )
    path = [digest_bytes(step, "proof step %d" % position)
            for position, step in enumerate(raw_path)]
    walk_consistency(from_size, to_size, from_note["root"], to_note["root"], path)

    own_from = len([s for s in from_note["signatures"]
                    if s["key_name"] == from_note["origin"]])
    own_to = len([s for s in to_note["signatures"]
                  if s["key_name"] == to_note["origin"]])
    return {
        "from_size": from_size,
        "to_size": to_size,
        "from_root": "sha256:" + from_note["root"].hex(),
        "to_root": "sha256:" + to_note["root"].hex(),
        "origin": from_note["origin"],
        "kid": from_kid,
        "purpose_enforced": enforced,
        "log_id": response.get("log_id"),
        "steps": len(path),
        "from_extensions": from_note["extensions"],
        "to_extensions": to_note["extensions"],
        "notes_supplied_by_you": (from_supplied, to_supplied),
        "cosignatures": response.get("cosignatures"),
        # Signature lines in either note under some OTHER key name: witness
        # cosignatures, which this script does not check and says so.
        "unchecked_signature_lines": (len(from_note["signatures"]) - own_from
                                      + len(to_note["signatures"]) - own_to),
    }


ISSUED_AT_PREFIX = "bfx:issued-at "


def _issued_at(extensions: "list[str]") -> "str | None":
    """The signed issuance instant, if the note carries one. Read out of the SIGNED
    body's extension lines — never from a field beside the note."""
    for line in extensions:
        if line.startswith(ISSUED_AT_PREFIX):
            return line[len(ISSUED_AT_PREFIX):]
    return None


def _extension_summary(extensions: "list[str]") -> str:
    if not extensions:
        return "three-line body (no extension lines)"
    return "%d extension line(s): %s" % (len(extensions), "; ".join(extensions))


def report(result: dict) -> None:
    """Exactly what was verified, and — separately, never merged into the same
    line — exactly what was not."""
    extra = result.get("cosignatures") or []
    unchecked = result.get("unchecked_signature_lines") or 0
    if not extra and not unchecked:
        witness = ("cosignatures is empty, so trust in the log key is trust in "
                   "BlueFox")
    else:
        witness = (
            "this run checked the log's own signatures ONLY and did not check "
            "%d further signature line(s) in the notes or %d cosignature(s) "
            "beside them, so no outside witness has been verified here"
            % (unchecked, len(extra))
        )
    from_supplied, to_supplied = result["notes_supplied_by_you"]
    provenance = {
        (True, True): "both notes came from your own files",
        (True, False): "the 'from' note came from your own file; the 'to' note "
                       "came from the response",
        (False, True): "the 'to' note came from your own file; the 'from' note "
                       "came from the response",
        (False, False): "both notes came from the response",
    }[(bool(from_supplied), bool(to_supplied))]
    print("  older checkpoint       : size %d, root %s"
          % (result["from_size"], result["from_root"]))
    print("  newer checkpoint       : size %d, root %s"
          % (result["to_size"], result["to_root"]))
    print("  proof steps walked     : %d" % result["steps"])
    print("  log origin             : %s" % result["origin"])
    print("  log key (pinned)       : %s  [purpose: %s]"
          % (result["kid"],
             PURPOSE_LOG if result.get("purpose_enforced") else "UNLABELLED"))
    print("  'from' note form       : %s"
          % _extension_summary(result["from_extensions"]))
    print("  'to' note form         : %s"
          % _extension_summary(result["to_extensions"]))
    print("  note provenance        : %s" % provenance)
    print("")
    print(
        "VERIFIED -- log consistency: both checkpoints were signed by the "
        "pinned log key, and the tree of %d leaves contains the tree of %d "
        "leaves as an unchanged prefix. Nothing was removed, reordered or "
        "edited between those two sizes."
        % (result["to_size"], result["from_size"])
    )
    # Item 9's timing half. Two timestamped checkpoints prove a WINDOW, which is
    # usually the shape a compliance reader actually wants — and when the notes
    # carry no signed instants, saying so is the only honest line available.
    from_when = _issued_at(result["from_extensions"])
    to_when = _issued_at(result["to_extensions"])
    if from_when and to_when:
        print(
            "ALSO PROVED -- WHEN: both notes carry a SIGNED issuance instant, so "
            "the growth from %d to %d leaves is placed between %s and %s. Read "
            "that scope narrowly: those are the instants the LOG ISSUED THOSE "
            "NOTES, not when any entry was admitted and not when anything the "
            "entries describe took place."
            % (result["from_size"], result["to_size"], from_when, to_when)
        )
    elif from_when or to_when:
        print(
            "PARTLY PROVED -- WHEN: only one of the two notes carries a signed "
            "issuance instant (%s), so this run places one end of the range in "
            "time and not the other."
            % (from_when or to_when)
        )
    else:
        print(
            "NOT PROVED by this run -- WHEN: neither note carries a signed "
            "issuance instant, so nothing here places this growth in time. The "
            "log proves ORDERING, not timing; any dates you have for these "
            "checkpoints came from outside the signatures."
        )
    if not result.get("purpose_enforced"):
        print(
            "NOT ENFORCED by this run -- the pinned key set carries no '%s' "
            "labels, so this script could not check that these checkpoints were "
            "signed by the LOG key rather than the receipt key. It verified the "
            "signatures against whichever published key matched. Re-run against "
            "a labelled key set (%s) to get that check."
            % (JWK_PURPOSE_CLAIM, JWKS_URL)
        )
    print(
        "NOT VERIFIED by this run -- separate claims, graded separately: that "
        "any particular receipt is a leaf in either tree (that is the inclusion "
        "check, a different question -- verify_inclusion.py), that this same "
        "history was shown to anybody else, and any outside witness -- %s."
        % witness
    )
    # The anchor claim, stated rather than left as an unexplained clause — the
    # blanket "the log's anchors" used to sit in the list above with no word on
    # what an anchor IS here, and a careful auditor concluded the lane was
    # decorative. A consistency response itself claims no anchor; the sentence
    # distinguishes the claim from the check and says where to follow it.
    print(
        "NOT VERIFIED by this run -- THE ANCHOR: a consistency response "
        "carries no anchor claim of its own. In this log, binding \"anchored\" "
        "(a claim some inclusion responses make) means a qualifying EXTERNAL "
        "time anchor -- an OpenTimestamps-confirmed Bitcoin commitment, or a "
        "held qualified timestamp -- covers a tree size. This run checked no "
        "anchor for either end of this range: /v1/transparency/anchors reports "
        "the lane's state for this log, /v1/transparency/anchor names the "
        "anchor for a checkpoint root, and /v1/transparency/anchor/proof "
        "serves the raw timestamp proof bytes."
    )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="verify_consistency.py",
        description=(
            "Check a BlueFox consistency proof: verify the two signed "
            "checkpoint notes against the log key pinned in this script, then "
            "recompute the RFC 6962 walk between them and require it to land on "
            "the roots INSIDE those notes. Exit 0 = the newer tree contains the "
            "older one unchanged; any non-zero = not proved, with the reason in "
            "one sentence on stderr."
        ),
        epilog=(
            "The response comes in on stdin, or from --file. The two signed "
            "notes come from the response's from_note / to_note fields, or from "
            "--from-note / --to-note if you saved your own copies, which is the "
            "better habit and wins when both are present. The log key comes "
            "from %s (pinned in this file), or from --jwks-file / --jwks-url -- "
            "never from a pointer inside the response. The log signs "
            "checkpoints with a DIFFERENT key than it signs receipts with; both "
            "live in that same key set, and where the set labels them this "
            "script requires the '%s' one. Read the recipe at the top of this "
            "file for the full construction, including what a consistency proof "
            "cannot do for you." % (JWKS_URL, JWK_PURPOSE_CLAIM)
        ),
    )
    parser.add_argument("--file", metavar="PATH", default=None,
                        help="read the consistency response from PATH instead "
                             "of stdin")
    parser.add_argument("--from-note", metavar="PATH", dest="from_note",
                        default=None,
                        help="read the OLDER checkpoint note from PATH (your "
                             "own saved copy) instead of the response's "
                             "from_note field")
    parser.add_argument("--to-note", metavar="PATH", dest="to_note",
                        default=None,
                        help="read the NEWER checkpoint note from PATH instead "
                             "of the response's to_note field")
    parser.add_argument("--jwks-file", "--jwks", metavar="PATH",
                        dest="key_set_file", default=None,
                        help="read the key set from PATH instead of fetching "
                             "it. --jwks is the same flag: one spelling works "
                             "across every published checker, so nobody is "
                             "wrong at a front door")
    parser.add_argument("--jwks-url", metavar="URL", dest="key_set_url",
                        default=JWKS_URL,
                        help="fetch the key set from URL (default: the pinned "
                             "address above)")
    parser.add_argument("--pure-ed25519", action="store_true",
                        help="always use the bundled RFC 8032 verifier, even "
                             "if the cryptography package is installed")
    return parser


def main(argv: "list[str] | None" = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        response = read_response(args.file)
        from_note_text = read_note_file(args.from_note, "from")
        to_note_text = read_note_file(args.to_note, "to")
        key_set = load_key_set(args.key_set_url, args.key_set_file)
        result = check(response, key_set, args.pure_ed25519,
                       from_note_text, to_note_text)
    except SystemExit:
        raise
    except KeyboardInterrupt:
        return refuse("interrupted before the check finished")
    except Exception as exc:  # the backstop: a sentence, never a traceback
        return refuse(
            "this consistency proof could not be checked -- %s: %s"
            % (type(exc).__name__, exc)
        )
    report(result)
    return EXIT_OK


if __name__ == "__main__":
    sys.exit(main())
