> For the complete documentation index, see [llms.txt](https://bsv.brc.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://bsv.brc.dev/apps/0220.md).

# NotaryHash — Privacy-Preserving Signed-Hash Notarization with SPV-Verifiable Certificates

Gregory Ward, CTO, SmartLedger (<codenlighten1@gmail.com>) · <https://github.com/codenlighten>

## 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:

1. **Privacy.** Only `SHA-256(content)` and a signature over it leave the client; the document itself is never disclosed.
2. **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.
3. **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.
4. **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 are `SHA-256(publicKey)` and `SHA-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](https://www.rfc-editor.org/rfc/rfc6962) 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 `d` for each proof is its 32-byte **`proofHash`**, so a leaf is `SHA256(0x00 ‖ proofHash)` — that is, `SHA256(0x00 ‖ SHA256(canonicalBytes))`, not `SHA256(0x00 ‖ canonicalBytes)`. Both are sound, but they produce different roots, so the choice is stated rather than left to RFC 6962's generic `d`. Leaves are in the order the batch was assembled, and `leafIndex` in the certificate's `merkle` object is that position, counted from 0.

### Certificate

A self-contained JSON object, canonicalised via [RFC 8785 (JCS)](https://www.rfc-editor.org/rfc/rfc8785) 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:

```json
{
  "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:

```json
{
  "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:

```json
"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:

1. **Signature** — `verify(algorithm, payloadHash, signature, publicKey)` is true. *(offline)*
2. **Proof integrity** — the recomputed `proofHash` equals `certificate.proofHash`. *(offline)*
3. **Anchor** — one of:
   * **SPV (preferred):** `txid = reverse(SHA256(SHA256(rawTx)))` equals `anchor.txid`; the `OP_RETURN` read from `rawTx` matches the certificate fields; folding `merkleProof` from `txid` yields a root equal to the Merkle root of the block header for `spv.blockHash` at `spv.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_RETURN` at `anchor.txid` from a chain provider (legacy certificates with no SPV envelope).

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, `txidFromRawTx` checked 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 = canonicalBytes` instead: `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](/transactions/0009.md)
* BRC-10: [Merkle proof standardised format](/transactions/0010.md)
* BRC-11: [TSC Proof Format with Heights](/transactions/0011.md)
* BRC-12: [Raw Transaction Format](/transactions/0012.md)
* BRC-62: [Background Evaluation Extended Format (BEEF)](/transactions/0062.md)
* BRC-74: [BSV Unified Merkle Path (BUMP)](/transactions/0074.md)
* [RFC 6962: Certificate Transparency (Merkle trees)](https://www.rfc-editor.org/rfc/rfc6962)
* [RFC 8785: JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785)
* FIPS 204: Module-Lattice-Based Digital Signature Standard (ML-DSA)
* FIPS 205: Stateless Hash-Based Digital Signature Standard (SLH-DSA)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://bsv.brc.dev/apps/0220.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
