NotaryHash — Privacy-Preserving Signed-Hash Notarization with SPV-Verifiable Certificates
Proving something existed and was signed at a certain time normally means publishing the thing itself. This puts only a hash on the chain and issues a certificate anyone can check, so the content stays private.
Reference for an AI
Everything an assistant needs to answer questions about BRC-220 accurately, including what it depends on.
Summary
- Why
- Blockchain notarization services typically require trusting the issuer's database or a block explorer, and often see the private document itself, when neither should be necessary to prove a signed hash existed at a point in time.
- What
- BRC-220 (NotaryHash) is a transaction format and certificate for anchoring a signed hash on the BSV blockchain so anyone can verify it offline against block headers alone, without the document or the issuing service.
- How
- A signer hashes and signs content locally, sends only the hash and signature to a service, which builds a canonical binary proof, anchors it in an OP_RETURN output, and issues a JSON certificate that a verifier checks by re-verifying the signature, recomputing the proof hash, and confirming a Simplified Payment…
What this lets you do
- Anchor a signed hash on-chain without exposing the underlying document
- Verify a certificate fully offline except for obtaining one block header
- Batch many proofs under a single Merkle root to save on-chain space
- Use classical or post-quantum signature algorithms in the same format
- Attach an SPV envelope to a certificate after issuance without invalidating it
Written by claude-sonnet-5 from the specification text. Where the two differ, the original is correct.
The specification
Abstract
This proposal defines a transaction format and a self-contained certificate for anchoring a signed hash to the Bitcoin blockchain. A signer proves they signed a specific hash; the on-chain anchor fixes that proof in time at the block in which it is mined. The notarizing service never receives the original document and never handles any client private key. Certificates are independently verifiable — offline for the signature and proof integrity, and against block headers alone (SPV) for the anchor — with no dependency on the issuing service or any chain-indexing API.
Motivation
Many "blockchain notarization" services require trusting the issuer's database, or a block explorer, to attest that a record exists on-chain. This proposal removes that trust and standardises an interoperable, privacy-preserving format so that any party can produce and verify these proofs:
- Privacy. Only
SHA-256(content)and a signature over it leave the client; the document itself is never disclosed. - Determinism. The integrity root is a fixed length-prefixed binary encoding (never
JSON.stringify, which is not stable across implementations), so any implementation in any language reproduces identical bytes. - Post-quantum readiness. Classical (ECDSA) and post-quantum (ML-DSA, SLH-DSA) signatures are first-class, so proofs intended to last remain verifiable as classical schemes weaken.
- Trustless verification. A certificate carries an SPV envelope, so a verifier confirms the anchor using only Bitcoin block headers (BRC-9, BRC-10/BRC-11).
Specification
Roles
- Signer hashes content locally, signs the hash locally, and submits
{algorithm, payloadHash, publicKey, signature}. - Service verifies the signature, builds canonical proof bytes, anchors them in an
OP_RETURN, and returns a certificate. - Verifier re-checks the signature and proof bytes offline and confirms the anchor.
Algorithms
| family | ids | hash |
|---|---|---|
| ECDSA | ECDSA-secp256k1 | SHA-256 |
| ML-DSA (FIPS 204) | ML-DSA-44, ML-DSA-65, ML-DSA-87 | SHA-256 |
| SLH-DSA (FIPS 205) | SLH-DSA-{SHA2,SHAKE}-{128,192,256}{s,f} (12 parameter sets) | SHA-256 |
The signer signs the 32-byte payloadHash directly; post-quantum schemes apply their own internal hashing. For ECDSA-secp256k1, payloadHash is the message digest H of SEC 1 §4.1.3: it is read as a big-endian integer and is not hashed again. Some ECDSA libraries hash their input by default, and some Bitcoin libraries can read the digest as little-endian; either produces a signature over a different value, which conformant verifiers reject.
Canonical proof bytes (integrity root)
proofHash = SHA-256(canonicalBytes), where:
lp("NotaryHash/1.0") || u8(version=1) ||
lp(algorithm) || lp(hashAlgorithm) ||
lp(payloadHash) || lp(publicKey) || lp(signature) ||
u64be(createdAtUnix)
lp(x) = u32be(len(x)) || x; u8/u64be are unsigned big-endian integers. All multi-byte fields are length-prefixed, so field boundaries are unambiguous regardless of content. This binary encoding — never JSON — is what makes the proof reproducible across implementations.
On-chain record (OP_FALSE OP_RETURN)
A safe data output whose pushes are discriminated by the mode/kind byte at push index 2:
-
full (
mode = 0):"NOTARYHASH" | u8(1) | u8(0) | algorithm | hashAlgorithm | payloadHash | proofHash | publicKey | signature. -
hybrid (
mode = 1): as full, but the final two pushes areSHA-256(publicKey)andSHA-256(signature); the full blobs live in the certificate (keeps large post-quantum records small on-chain). -
batch (
kind = 2):"NOTARYHASH" | u8(1) | u8(2) | merkleRoot(32) | u32be(leafCount). One transaction anchors many proofs under an RFC 6962 Merkle root, domain-separated (leaf = SHA256(0x00 ‖ d),node = SHA256(0x01 ‖ l ‖ r), split at the largest power of two< n, last leaf never duplicated).The leaf datum
dfor each proof is its 32-byteproofHash, so a leaf isSHA256(0x00 ‖ proofHash)— that is,SHA256(0x00 ‖ SHA256(canonicalBytes)), notSHA256(0x00 ‖ canonicalBytes). Both are sound, but they produce different roots, so the choice is stated rather than left to RFC 6962's genericd. Leaves are in the order the batch was assembled, andleafIndexin the certificate'smerkleobject is that position, counted from 0.
Certificate
A self-contained JSON object, canonicalised via RFC 8785 (JCS) for hashing and transport, carrying the twelve required fields below. Hex is written lowercase, without a 0x prefix; a reader MAY accept upper case and the prefix. Numbers are JSON integers. Verifiers ignore members they do not recognise, which is how the SPV envelope is added to a certificate already issued.
| field | value |
|---|---|
protocol | the string "NotaryHash" |
version | the string "1.0": the certificate format version. It corresponds to u8(version=1) in the canonical proof bytes and the on-chain record, and to the domain separator "NotaryHash/1.0", but is written as a string. A verifier MUST reject a certificate whose version it does not implement. |
mode | "full" or "hybrid": how the proof is recorded on chain, corresponding to the on-chain mode byte 0 or 1. Batching is marked by anchor.type, not by mode. |
algorithm | an identifier from §Algorithms, e.g. "ECDSA-secp256k1" |
hashAlgorithm | "SHA-256" |
payloadHash | the 32-byte payload hash, hex |
publicKey | the full public key in every mode (hybrid puts only its SHA-256 on chain), written per encoding |
signature | the full signature, as the signer produced it, in every mode, written per encoding |
encoding | how publicKey and signature are written: "hex", or "base64" (RFC 4648 §4: standard alphabet, padded). It applies to those two fields only, and says nothing about the format of the bytes themselves. A reader MUST reject a value that is not valid in its encoding rather than decode what remains of it — for base64, a character outside the RFC 4648 §4 and §5 alphabets, padding anywhere but the end, or a length no byte string encodes. A reader MAY accept the §5 (URL-safe) alphabet and missing padding. |
proofHash | the 32-byte SHA-256(canonicalBytes), hex |
createdAt | createdAtUnix as an ISO 8601 UTC timestamp with milliseconds, e.g. "2026-01-01T00:00:00.000Z". The milliseconds are always 000, because only whole seconds enter the canonical bytes; a verifier recovers createdAtUnix as the whole seconds the timestamp denotes. Advisory only — see §Verification. |
anchor | the object below |
For ECDSA-secp256k1, publicKey is 33 bytes (compressed) or 65 (uncompressed), and signature is 64 bytes (r ‖ s, each a 32-byte big-endian integer) or DER. A verifier reads a 64-byte signature as r ‖ s, and any other signature as DER, which must parse as DER. DER can itself be 64 bytes long — only when r and s are unusually short — and is then read as r ‖ s and does not verify; a signer holding such a signature sends it as r ‖ s instead. S is not normalised: the certificate commits, through proofHash, to the exact bytes the signer produced, so the malleated form of a signature is a different certificate rather than a forgery of this one.
anchor locates the on-chain record:
| member | value |
|---|---|
type | "direct" if the record carries this proof (mode 0 or 1); "batch" if it carries a Merkle root (kind = 2) |
network | the chain the anchoring transaction is on: "bsv-mainnet" for BSV mainnet, "bsv-testnet" for BSV testnet. Other values are not interoperable. The field is descriptive: which chain the anchor is on is established by the block header the verifier obtains, not by this value. |
txid | the anchoring transaction's id, hex, in display order: reverse(SHA256(SHA256(rawTx))) |
vout | the index of the OP_RETURN output within that transaction |
blockHeight | the height of the block that mined the transaction, or null until it is mined |
blockTime | that block's timestamp in Unix seconds, or null until it is mined |
A batched certificate (anchor.type "batch") additionally carries merkle, the proof that this proof is one of the batch's leaves:
| member | value |
|---|---|
root | the 32-byte batch root, hex; equal to merkleRoot in the on-chain batch record |
leafIndex | this proof's position in the batch, from 0 |
leafCount | the number of proofs in the batch; equal to leafCount in the on-chain batch record |
path | the audit path, leaf → root, as an array of { "hash": <32-byte hex>, "side": "left" or "right" }. side is the sibling's position relative to the running hash. Starting from SHA256(0x00 ‖ proofHash), a "left" sibling folds as SHA256(0x01 ‖ hash ‖ running) and a "right" one as SHA256(0x01 ‖ running ‖ hash). The result must equal root. |
In a batched certificate mode is not checked against the chain: the batch record carries neither the proof nor a mode byte.
A direct-anchored ECDSA certificate, before its SPV envelope is attached:
{
"protocol": "NotaryHash",
"version": "1.0",
"mode": "full",
"algorithm": "ECDSA-secp256k1",
"hashAlgorithm": "SHA-256",
"payloadHash": "97ef50e782e55cfbfbfb0c6199b96837836b7f7b0fcae76f79a65e2466dc596b",
"publicKey": "02375ac16df62a74475844721d6a180927f29314c3455eaa699f7dcf5237c36e52",
"signature": "e65171edb82a702a8390fb90f005ba3d4e174ab945ab0bcaf5f1f8d18c3547426ca419ea252b142e863d2eeebdfc787a624bed5a06d23a671959b07b5cb12ba3",
"encoding": "hex",
"proofHash": "1b34fbeb640e63b366751a279aa749e4f279cfb7e1add1a96150d7fd7c2ff05d",
"createdAt": "2026-01-01T00:00:00.000Z",
"anchor": {
"type": "direct",
"network": "bsv-mainnet",
"txid": "460d19b875036e41d6dea392bfcb84508120e7b573080c45869eedbae39dec6a",
"vout": 0,
"blockHeight": 900000,
"blockTime": 1767225600
}
}
The merkle member of the last certificate in a five-proof batch:
{
"root": "abb53eb3b2e3530d51685c6813bb85c85892d2f214c2dc504029d071434ac074",
"leafIndex": 4,
"leafCount": 5,
"path": [
{ "hash": "060ca4e4bbcb647ab0162bb027cd8f08c1577aaa7069166dd629a3df7e57befd", "side": "left" }
]
}
The anchoring transactions and blocks in these examples are test fixtures, not mainnet data. Everything else in them verifies: the signature, proofHash, the on-chain record in the transaction, and the batch inclusion.
SPV envelope (additive)
Attached once the anchoring transaction is mined:
"spv": {
"rawTx": "<hex>",
"blockHash": "<hex>",
"blockHeight": 0,
"merkleProof": { "index": 0, "nodes": ["<hex>", "*", "..."] },
"format": "TSC"
}
merkleProof is a Merkle inclusion proof of txid under the block's Merkle root, expressed in the BRC-10/BRC-11 (TSC) model; a BRC-74 (BUMP) or BRC-62 (BEEF) encoding MAY be substituted by setting format accordingly. The SPV envelope is not part of the canonical proof bytes, so adding it never changes proofHash and never invalidates a previously issued certificate.
rawTx is hex, and blockHash is hex in display order, like txid. In the "TSC" format the entries of merkleProof.nodes are hex in display order too, and an entry of "*" means "duplicate the working hash", the TSC convention for a missing right sibling. The verifier checks that the header it obtained has hash spv.blockHash and height spv.blockHeight.
Verification
A certificate is valid if and only if all of the following hold:
- Signature —
verify(algorithm, payloadHash, signature, publicKey)is true. (offline) - Proof integrity — the recomputed
proofHashequalscertificate.proofHash. (offline) - Anchor — one of:
- SPV (preferred):
txid = reverse(SHA256(SHA256(rawTx)))equalsanchor.txid; theOP_RETURNread fromrawTxmatches the certificate fields; foldingmerkleProoffromtxidyields a root equal to the Merkle root of the block header forspv.blockHashatspv.blockHeight. The verifier trusts only a block header, obtained from any source it chooses (a synced header chain, or several independent sources cross-checked) — not a provider's word about the transaction (BRC-9). - Direct: read the
OP_RETURNatanchor.txidfrom a chain provider (legacy certificates with no SPV envelope).
- SPV (preferred):
Steps 1–2 require no network. The proof-of-existence time is the block time of anchor.txid; createdAt is an advisory client field only.
What a certificate proves
A specific public key signed a specific hash, and that proof was anchored on-chain at/by the block timestamp. It does not establish who submitted it (any party holding a valid (hash, signature, publicKey) triple may re-anchor it; the attestation remains valid), nor anything about the document's contents (a verifier needs the document to recompute the hash).
Security considerations
- The SPV trust model reduces to obtaining a correct block header. A single header source is a single point of trust; a multi-source quorum, or a proof-of-work-validated header chain, removes it. Implementations SHOULD make the header source explicit.
- Provider-supplied data is self-checking: a raw transaction is accepted only if its double-SHA-256 equals the txid already held, so a provider cannot substitute different bytes.
Implementations
A reference implementation (service, client SDK, and a dependency-light standalone verifier) is available, including:
- a standalone certificate verifier that depends only on the protocol's own modules (signature +
proofHash+ SPV anchor), never on the issuing service; - a confirmation poller that attaches the SPV envelope once the anchoring transaction is mined; and
- published test vectors: a complete certificate-with-SPV-envelope golden vector,
txidFromRawTxchecked against the Bitcoin genesis coinbase transaction, and the Merkle fold checked against the real block-170 (two-transaction) proof.
Batch-mode test vector
Five proofs, so the tree splits 4/1 and exercises both the largest-power-of-two split and the never-duplicate rule. For i from 0 to 4, written as one ASCII decimal digit: private key SHA-256("BRC-220/batch-vector/key/" + i) read as a big-endian integer, payloadHash SHA-256("BRC-220/batch-vector/payload/" + i), createdAtUnix 1767225600 + 86400 * i (seconds), algorithm ECDSA-secp256k1, hashAlgorithm SHA-256, compressed public key, 64-byte r ‖ s signature with an RFC 6979 nonce, normalised to low-S. (Two of the five signatures differ without that normalisation, so it is needed to reproduce the values below.)
| leafIndex | proofHash |
|---|---|
| 0 | 83f3f73ebc5146b96cce1ee0b6ed4fd251a933a5332fbe5e5ab80da7920fe2c9 |
| 1 | 29fca10808a3653efbe38abd5f0b926aa09f13bff7847475139a422869729e77 |
| 2 | 3c44828bea3ca01dfcd5305a839ce02cc1e855f5a7c7028bb4931d42dea3aa2b |
| 3 | 0692e22da9b68634e5384bb012324c140c676d2301e078a67f17e335661a9565 |
| 4 | 1a9c6bc1a6cf8319344ca8c30ca9e9eef8ab3df732748f6be2c824bb84ec0ca9 |
merkleRoot(d = proofHash):abb53eb3b2e3530d51685c6813bb85c85892d2f214c2dc504029d071434ac074- audit path for leaf 4 (one node, left sibling):
060ca4e4bbcb647ab0162bb027cd8f08c1577aaa7069166dd629a3df7e57befd - root an implementation computes if it uses
d = canonicalBytesinstead:4a59ade74941e4d6ca4b7c9f610e0a01a0c3f24cfa94efe816e53ef85f99e324
The format has been demonstrated end-to-end on BSV mainnet: a certificate verifies fully offline against independently obtained block headers, with no trust in the issuing service.
References
- BRC-9: Simplified Payment Verification
- BRC-10: Merkle proof standardised format
- BRC-11: TSC Proof Format with Heights
- BRC-12: Raw Transaction Format
- BRC-62: Background Evaluation Extended Format (BEEF)
- BRC-74: BSV Unified Merkle Path (BUMP)
- RFC 6962: Certificate Transparency (Merkle trees)
- RFC 8785: JSON Canonicalization Scheme (JCS)
- FIPS 204: Module-Lattice-Based Digital Signature Standard (ML-DSA)
- FIPS 205: Stateless Hash-Based Digital Signature Standard (SLH-DSA)