Verifying a bundle¶
The checking tool that ships inside every proof bundle is verify.py. This page walks through what it does, how to run it, and what PASS, FAIL, PENDING, and the not confirmed here note each mean.
The core property verified end-to-end: this exact manuscript, byte-for-byte, was registered at the public OpenTimestamps calendars on these specific dates, and the public record has since been anchored in the Bitcoin blockchain at a known block height.
What verify.py actually does¶
sequenceDiagram
participant Pub as Publisher
participant V as verify.py
participant OTSCLI as ots CLI
Pub->>V: uv run verify.py bundle.zip
V->>V: load bundle.json + bundle.sig.json
V->>V: verify Ed25519 signature against canonical bytes
V->>V: check bundle_identifier_hex == SHA-256(payload)
V->>V: compare public-key fingerprint to pinned anchor
V->>V: re-derive Merkle roots from leaves
V->>OTSCLI: ots verify -d <digest> <receipt>
OTSCLI->>OTSCLI: check against Bitcoin headers
Note over OTSCLI: reads block headers from a<br/>local Bitcoin node (see "Running it")
OTSCLI-->>V: confirmed / pending / not confirmed here
V->>Pub: Anchored in Bitcoin block N<br/>on date D
Critically, there is no arrow to BlindProof. The verifier talks to the ots CLI, which in turn talks to the public OpenTimestamps calendars and to Bitcoin. It does not authenticate to BlindProof, does not fetch anything from our servers, and does not need us to be online for the check to succeed.
Running it¶
Minimum setup¶
The publisher needs:
- Python 3.10 or later.
opentimestamps-clientandcryptographyinstalled (uvhandles both via the PEP 723 header inverify.py).- The bundle zip, and (if the publisher wants the manuscript-match check) the manuscript file.
- For the Bitcoin anchor check, a local Bitcoin node (
bitcoind).opentimestamps-clientconfirms a Bitcoin attestation only against a local node, so internet access alone is not enough today. The signature, identifier, fingerprint, and Merkle checks all run offline and need neither a node nor a network. Without a node, the anchor is reported not confirmed here — a note, not a failure (see Interpreting the results). Lettingverify.pyconfirm the anchor via a public block explorer, so no node is needed, is tracked in #38.
With uv — the simplest case, against the unmodified zip:
A directory containing the unpacked zip contents is also accepted:
With plain pip:
The pinned signing-key fingerprint¶
verify.py ships with BlindProof's production fingerprint pinned in EXPECTED_PUBLIC_KEY_FINGERPRINT (currently 515e 2080 8334 3640). Any bundle signed under a different key fails closed by default — no flags required. The fingerprint is 16 hex characters (the first 64 bits of SHA-256(public_key)) grouped in fours for visual comparison.
To override the pin for a single run — e.g. when verifying a bundle from a non-production environment — pass --expect-fingerprint "a1b2 c3d4 e5f6 0708". To disable the check entirely (the bundle's fingerprint is still printed for out-of-band comparison), edit the script and set the constant to None. A publisher who wants independent corroboration can compare the pinned value against the fingerprint BlindProof publishes on its website or distributes via a separate channel.
Skipping OpenTimestamps¶
If the ots CLI isn't available or the network is down, --skip-ots runs only the offline checks (signature, identifier, fingerprint, Merkle). Useful for an air-gapped audit; the OTS portion can be re-run later.
Matching against a manuscript¶
To confirm that a specific manuscript file is one of the saves covered by the bundle, the publisher passes --manuscript:
How it works under the hood: at capture time, the client computes each snapshot's HMAC commitment under a per-leaf key — HKDF(mac_key, info=b"blindproof/leaf/v2/" || ciphertext_ref). The master mac_key itself never leaves the client. At bundle-generation time, the client derives the per-leaf key for every snapshot it wants the publisher to be able to check and posts those keys to the server, which embeds them in bundle.json as a reveals array (one entry per leaf the publisher can verify). Because each leaf has its own key, revealing one reveal-key only re-enables HMAC computation for that one leaf — the publisher cannot do offline hypothesis testing against any other commitment, drafts the author later removed, or sentences the author tried and abandoned.
verify.py reads the manuscript with the same normalisation the client did (UTF-8, optional BOM stripped, CRLF → LF), then for each reveal entry computes HMAC-SHA256(reveal_key, manuscript_bytes) and compares against the leaf at the given leaf_index. If any match: MATCHED. If none: NOT MATCHED — the file the publisher has is not the version of the manuscript the author registered.
A complete publisher's run looks like:
$ uv run verify.py BlindProof-2026-04-22.zip --manuscript manuscript.md
BlindProof bundle verification
==============================
Bundle identifier: 7e2c…
Author: [email protected]
Enrolled at: 2026-01-14T09:12:00Z
[PASS] signature: Ed25519 signature verifies against bundle.json.
[PASS] identifier: bundle_identifier_hex matches SHA-256 of the canonical payload.
[PASS] fingerprint: public key fingerprint matches pinned anchor: 515e 2080 8334 3640
[PASS] manuscript: MATCHED: the manuscript matches 1 snapshot in this bundle —
snapshot 7c4f… (root #6, leaf #12).
Merkle roots checked: 7
[PASS] root #1 (3 leaves) — Merkle root re-derived and matches claim.
OTS: anchored in Bitcoin. Got Bitcoin block 891204 attests existence as of 2026-03-15 UTC
...
Overall: PASS
Bundles produced before the manuscript-match flow¶
Snapshots captured by the legacy v1-mac-key client cannot participate in --manuscript: their commitment was computed under the raw mac_key, and revealing that key would expose every other leaf at once. Bundles whose only snapshots are v1 will report:
That's not a fault of the bundle; it's a limitation of the older client. New captures use v2-per-leaf and produce bundles the publisher can match against.
Interpreting the results¶
PASS¶
Every component of the chain verified. The manuscript matches a snapshot in the timeline, its enclosing Merkle root is internally consistent, and the OpenTimestamps receipt anchors that root to a specific Bitcoin block.
This is the desired outcome. The publisher now has an artefact that no one (including BlindProof) can retroactively tamper with — backdating would require breaking Bitcoin.
If verify.py ran on a machine with no Bitcoin node, the anchor line reads not confirmed here instead of confirming a block; the overall verdict is still PASS on the offline checks (signature, identifier, fingerprint, Merkle), with a one-line caveat printed under it so the result isn't mistaken for a confirmed-on-chain claim. See below.
PENDING for an OTS receipt¶
The calendar has recorded the submission but Bitcoin has not yet anchored it. OpenTimestamps calendars batch submissions, and anchoring typically takes 2–6 hours. A very recent save (last day or two) may legitimately be PENDING at the time of verification.
The publisher's options:
- Wait a day and re-run
verify.py— the pending receipt will upgrade toPASSonce the calendar has a Bitcoin attestation. - Ask the author to regenerate the bundle after a day has passed; the new bundle will include the now-anchored receipt.
- Accept the overall verdict as provisional if the delivery deadline is tight.
Note that a PENDING receipt is not worse than a missing receipt — the calendar's timestamp on its own is already public and unforgeable; the Bitcoin anchor is the final-form proof.
The Bitcoin anchor was not confirmed here¶
verify.py prints this when it could not confirm a receipt's Bitcoin anchor on the machine it ran on — almost always because there is no local Bitcoin node. opentimestamps-client confirms a Bitcoin attestation by reading the block header from a local bitcoind; with no node reachable it cannot complete the check, even over a working internet connection.
This is not a failure. The signature, identifier, fingerprint, and Merkle re-derivation have all passed, and the commitment may well be anchored — the check simply couldn't run here. The overall verdict stays PASS, with a one-line caveat under it so the result isn't read as a confirmed-on-chain claim it isn't. (Genuine failures — a receipt that doesn't commit to the root, or a reachable node that says the attestation is wrong — still report OTS FAIL and fail the run.)
To confirm the anchor itself, the publisher can:
- Re-run
verify.pyon a host that runsbitcoind— the maximal-trust path, since the node validated the chain itself. - Check the receipt at https://opentimestamps.org, whose browser verifier reads the block from a public explorer.
Teaching verify.py to confirm the anchor directly against a public block explorer — so no node is needed and a node-less publisher gets a genuine anchor PASS — is tracked in #38.
FAIL for Manuscript match¶
The file the publisher has does not match any snapshot in the bundle. Something is wrong: perhaps the wrong file was sent, or the manuscript was modified after the final save BlindProof captured.
This is a strong signal — false positives are essentially impossible. The publisher should ask the author to clarify which file they intended to deliver, and (if needed) to regenerate the bundle with that file's capture included.
FAIL for signature or Merkle root¶
Either the bundle was tampered with after generation, or the bundle was produced by a rogue signing key. Treat as invalid. Contact BlindProof.
Auditing verify.py¶
The script is designed to be read and audited — that's the point of keeping it stdlib-only. A reasonable audit looks at:
- The signature check. Is the Ed25519 signature in
bundle.sig.jsonactually verified against the canonical bytes ofbundle.json, or just printed? (It's verified — see_verify_signature. The public key it verifies against comes from the samebundle.sig.json; trust comes from comparing the public-key fingerprint to a known-good value, whichEXPECTED_PUBLIC_KEY_FINGERPRINTor--expect-fingerprintenforces.) - The identifier check. Does
bundle_identifier_hexactually match SHA-256 of the rest ofbundle.json? (Yes — see_check_identifier. This catches a class of edits that re-sign cleanly but produce an inconsistent self-reference.) - The Merkle re-derivation. Does the tree construction match the spec? (Bitcoin-style, odd-level duplication.)
- The OTS invocation. Does the script pass the receipt bytes and digest correctly? (See the call-site:
ots verify -d <digest_hex> <receipt_file>.) - The plaintext match path. How is the manuscript mapped to a snapshot, and what prevents offline hypothesis testing against the bundle? Each leaf carries an independently-derived per-leaf key — revealing one re-enables HMAC computation only for that leaf, so the bundle does not let a publisher test arbitrary hypotheses ("does this contain sentence X?") against the rest of the timeline.
None of these are expected to be subtle. If something in the audit reads as clever, it's probably wrong — file an issue at github.com/tomdyson/blindproof.
Offline verification¶
Everything except the Bitcoin anchor check can run fully offline:
- Signature verification — offline (pure Ed25519).
- Merkle re-derivation — offline.
- Manuscript match — offline (given the reveal tokens in the bundle).
- OTS receipt → Bitcoin anchor — requires a local Bitcoin node to read block headers. Internet access alone is not sufficient with today's
opentimestamps-client(see #38 for explorer-based confirmation that would lift this).
If a publisher runs verify.py on a machine without a Bitcoin node (air-gapped or otherwise), they get PASS for the first three and a not confirmed here note for the anchor; the overall verdict still passes on the offline checks. They can confirm the anchor later on a host with bitcoind, or via opentimestamps.org, and reach a final verdict.
Known limitations¶
- Fake OTS receipts from earlier demo data are explicitly detected by
verify.py(they beginFAKE-OTS-RECEIPT-FOR-…) and flagged asNOT ANCHORED. This is intentional — we do not want a development fake to ever be reported as a real anchor. - Only
bundle.jsonis signed. The PDF,verify.py, and the zip-as-a-whole are not covered by a signed manifest today — a tampered PDF or substituted verifier script would not be detected by the current verifier. Coverage is tracked in #5. - No partial bundles. If parts of the timeline are missing (e.g. incomplete sync before generation), the publisher sees fewer anchored days. This is correct behaviour — the bundle only claims what it was given.
See also¶
- Proof bundle format — the on-wire layout of the four files inside the zip.
- For publishers — the author-facing, lighter-touch version of this page.