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 Node as local Bitcoin node
participant Exp as public block explorer
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->>Node: ots verify -d <digest> <receipt>
alt local node reachable
Node-->>V: independently confirmed / pending / FAIL
V->>Pub: Anchored in Bitcoin block N on date D
else no local node
V->>Exp: GET block header at the attested height
Exp-->>V: raw 80-byte header → merkleroot matches
V->>Pub: Consistent with block N per <explorer><br/>(a note; not independently confirmed)
end
Critically, there is no arrow to BlindProof. The verifier talks to a local Bitcoin node if one is present and, failing that, to a public block explorer — both independent of BlindProof, and either of which talks to the public OpenTimestamps record 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) is the only thing that independently confirms it, because the node validated the chain itself. Without a node, an internet connection still letsverify.pycorroborate the anchor against a public block explorer — useful, but trust in that explorer rather than independent proof, so it is surfaced as a note rather than a confirmed-on-chain result. The signature, identifier, fingerprint, and Merkle checks all run fully offline and need neither a node nor a network. See Interpreting the results.
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.
Checking the Bitcoin anchor (node or explorer)¶
To check a receipt's Bitcoin anchor, verify.py needs to read the header of the block the receipt is anchored in. It gets that header one of two ways, which give results of very different strength:
- A local Bitcoin node — independent confirmation. If
ots verifycan reach abitcoind, the node validated the chain itself (proof-of-work, difficulty, linkage), so a match is genuine, independent confirmation. This is the maximal-trust path and depends on no third party. - A public block explorer — corroboration only. With no node reachable,
verify.pyfetches the attested block's raw 80-byte header from an Esplora-compatible explorer (blockstream.info, then mempool.space by default) over a plain HTTPS request, and re-checks the merkleroot using the OpenTimestamps library's own routine. This is genuinely useful — it tells a node-less publisher the anchor almost certainly exists, and surfaces the block height and date — but it is trust in the explorer, not independent confirmation: the explorer hands over a header it also chose, andverify.pydoes not validate proof-of-work or chain linkage. A cooperating or compromised explorer (or one you were pointed at by an attacker) could serve a forged header, including a forged block time. So an explorer match is reported as a not independently confirmed here note, never as a confirmed-on-chainPASS.
The explorer is consulted only as a fallback, and it can only ever add a note — it can neither produce an independent confirmation nor turn a passing bundle into a failure. A genuine FAIL comes only from a local node (which validated the chain) or from the receipt failing to commit to the root; an explorer that is unreachable or returns a mismatching header yields not confirmed here, not a failure. The explorers are independent of BlindProof, so a bundle still verifies long after the company is gone — against any explorer, or any node. (Teaching verify.py to validate a header chain to a pinned checkpoint — real SPV — would make the node-less path an independent confirmation; that is tracked as a follow-up to #38.)
To point the check at a specific explorer (or several, tried in order), pass --ots-explorer:
The same can be set once via the BLINDPROOF_OTS_EXPLORERS environment variable (comma-separated). To insist on a local node and skip the explorer fallback entirely, pass --no-ots-explorer.
Running a local node for an independent check¶
The local-node path is the only one that independently confirms the anchor today, but "run bitcoind" is heavier than it sounds — so it's worth being clear about what it involves.
Discovery is automatic. If a Bitcoin Core node is running on the same machine, under the same user, with its default data directory, verify.py finds it with no configuration: opentimestamps-client connects over local RPC using the .cookie file Core writes into its data directory (~/.bitcoin on Linux, ~/Library/Application Support/Bitcoin on macOS) at localhost:8332. There is nothing to wire up — start the node and re-run verify.py.
The cost is the sync, not the install. Installing Bitcoin Core takes minutes (brew install bitcoin, a distro package, the official binaries from bitcoincore.org, or Docker). The expense is the initial block download (IBD): the node connects to peers and downloads and validates the chain — well over half a terabyte (roughly 700 GB in 2026, growing ~50 GB/year). That is several hours on a fast machine and connection, and longer on modest hardware. OTS verification only reads block headers and the height→hash index, so a pruned node (prune=550, ~5–10 GB on disk) is sufficient and txindex is not needed — but pruning saves disk, not download time: the node still validates the whole chain during IBD. And headers-first sync is not a shortcut here — ots verify resolves the attestation's block height via Bitcoin Core's getblockhash, which answers only for the fully-validated active chain (not the synced header chain), so a header-only node returns Block height out of range until IBD has validated past that block. (Validating just the header chain to a pinned checkpoint — the genuinely fast, minutes-not-hours path — needs the purpose-built SPV verifier tracked in #52, not a stock node.)
So a cold, from-scratch node is not a thirty-minute task. The node path makes sense if you already run a node, or are willing to sync a pruned node once (e.g. overnight) and keep it — after which every future bundle verifies in seconds.
A lightweight independent check is on the roadmap. Because OTS only needs headers, a future SPV mode — validating just the header chain (proof-of-work, retargeting, linkage) back to a checkpoint pinned in verify.py — would give genuine, node-less independent confirmation by downloading only tens of megabytes of headers, in minutes rather than hours. That is what a "spin up a quick environment to check this properly" workflow wants, and it is tracked in #52. Until it lands, a fast check means the explorer corroboration (above) or opentimestamps.org — both convenient, both trusting a public explorer rather than independently confirming.
Skipping OpenTimestamps¶
If the ots CLI isn't available and there's no network at all, --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.
With no local Bitcoin node, the anchor is not independently confirmed. Instead verify.py corroborates it against a public block explorer and prints a note giving the explorer, block height, and date — trust in that explorer, not independent proof (see below). The overall verdict is still PASS on the offline checks (signature, identifier, fingerprint, Merkle), with a one-line caveat printed under it so an explorer corroboration — or, when fully offline, no anchor check at all — isn't mistaken for an independent confirmed-on-chain claim.
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 independently confirmed here¶
verify.py prints a not independently confirmed here note whenever it could not independently confirm a receipt's Bitcoin anchor on the machine it ran on. Two situations produce it:
- Corroborated by an explorer, not a node. With no local node but a working connection,
verify.pychecked the anchor against a public block explorer and the block's merkleroot matched. The note reads consistent with block N (date) per<explorer>. This is real, useful signal — but it is trust in that explorer, not independent confirmation: no proof-of-work or chain linkage is checked, so it is deliberately not reported as a confirmed-on-chainPASS. - Not checked at all. There is no node and no reachable explorer (fully offline), or the anchor is still
PENDING(not yet folded into a Bitcoin block).
Either way this is not a failure. The signature, identifier, fingerprint, and Merkle re-derivation have all passed, and the commitment may well be anchored. The overall verdict stays PASS, with a one-line caveat under it so the result isn't read as an independent 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. An explorer that is unreachable or that returns a mismatching header is not a failure: a third party is never allowed to fail an otherwise-valid bundle.)
To independently confirm the anchor, the publisher can:
- Re-run on a host that runs
bitcoind— the maximal-trust path, and the only one that independently confirms, since the node validated the chain itself. (Pass--no-ots-explorerto skip the explorer and rely on the node alone.) See Running a local node for an independent check for what syncing a node actually involves. - Check the receipt at https://opentimestamps.org, whose browser verifier reads the block from a public explorer (the same trust model as the explorer corroboration above, but a useful external cross-check).
- Wait and re-run if the anchor was
PENDING; OpenTimestamps anchoring typically completes within a few hours of a save.
A working internet connection alone gets the explorer corroboration note above, which for many publishers is enough — but it is not the same as independent confirmation.
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. Its canonical source is client/verify.py; the copy inside a bundle is those bytes, unchanged (the PDF's appendix records the shipped copy's SHA-256 so the two can be compared). 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>.) And the explorer fallback (_ots_explorer_runner): does it bind the receipt to the root being verified (file_digest == digest) before looking at any header, and does it reuse the OpenTimestamps library's ownverify_against_blockheaderrather than re-implementing the merkleroot check? Note what it does not do: it does not validate proof-of-work, difficulty, or chain linkage, and it fetches the block hash and the header from the same explorer — so theGetHash()self-check proves only internal consistency, not membership in Bitcoin's chain. That is why an explorer match is returned as a note (None), never as an independentTrue: a cooperating or compromised explorer could forge a matching header. The deliberate asymmetry is that the explorer can only ever add a note, never independently confirm and never fail a bundle — a genuineOTS FAILcomes only from the digest-binding check or a local node, so a third party can neither manufacture nor invalidate a proof. (An independent node-less path would require validating a header chain to a pinned checkpoint — real SPV — tracked as a follow-up.) - 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 — needs to read the attested block header. Independent confirmation requires a local Bitcoin node (it validates the chain). With only an internet connection,
verify.pycan corroborate the anchor against a public block explorer (a note, trusted to that explorer); fully offline, this step can't run at all.
If a publisher runs verify.py on a fully air-gapped machine — no node and no network — they get PASS for the first three and a not independently confirmed here note for the anchor; the overall verdict still passes on the offline checks. They can reach a final, independent verdict later on a host with bitcoind (or cross-check at opentimestamps.org); re-running with just a network connection gets the explorer corroboration note, which is weaker than a node's confirmation.
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.