Merkle-row schemes (merkle-row-v1, merkle-row-sealed-v1)
Selective-disclosure schemes for tabular data: anchor a whole table on chain, later reveal one row to an auditor without revealing any of the other rows.
These are client-side schemes layered on Satsignal's existing API. The server does not need to be aware of them. merkle-row-v1 rides on top of manifest mode (POST /api/v1/anchors {items: [...]}); merkle-row-sealed-v1 rides on top of plain commitment anchors (POST /api/v1/anchors {category: "commitment"}). Both schemes are specified here so third-party verifiers can interop without reading helper source.
Reference helpers (stdlib-only, byte-identical between runtimes):
- Python:
/merkle_row.py - Browser + Node 18+:
/merkle-row.js
Public-domain primitives. Pin the sha256 if supply chain is a concern.
1. Common substrate
1.1 Canonical bytes
Each row is JCS-canonicalized before hashing or HMACing. Identical to notary.canonicalize and the canonicalize() in /commit-reveal.js:
- NFC normalize all string keys and string values (recursive).
- Sort all dict keys at every level.
- Emit minimal JSON (no whitespace, no trailing commas, no NaN/Infinity).
- Floats are forbidden. Pre-quantize integers (e.g. cents) or pass decimals as strings. Cross-runtime float encoding diverges on integer-valued floats (
1.0vs1); refusing them is the only way to guarantee byte-identical canonical bytes between Python and JavaScript.
1.2 Merkle tree
Standard binary Merkle, SHA-256 throughout:
merkle_root([leaf_0, ..., leaf_{N-1}]):
level = leaves
while len(level) > 1:
next = []
for i in 0..len(level) step 2:
left = level[i]
right = level[i+1] if i+1 < len(level) else level[i] // last-node-dup
next.append(sha256(left || right))
level = next
return level[0]
Inclusion-proof entries are {"sib": "<64-hex>", "side": "L"|"R"}, ordered from leaf level upward. To verify, start with the leaf bytes; for each entry, if side="L" then carry = sha256(sib || carry) else carry = sha256(carry || sib). The final carry must equal the root.
This matches the existing manifest-mode implementation in notary/manifest.py and the verifier-page implementation in docs/notary_spec/verifier.html (merkleRootFromHexLeaves).
2. merkle-row-v1 — standard scheme
2.1 When to use it
Use merkle-row-v1 when each row's contents are high-entropy (UUIDs, free-text bodies, large structured payloads) so that a plain SHA-256 leaf cannot be brute-forced from a small candidate space.
2.2 Leaf and root
row_canonical_i = canonicalize(rows[i])
row_sha_i = sha256(row_canonical_i) // hex
leaf_i = sha256(canonicalize({ // bytes
"label": <row label>,
"sha256_hex": row_sha_i
}))
root = merkle_root([leaf_0, ..., leaf_{N-1}])
The leaf shape is manifest-items-v1 exactly — merkle-row-v1 is a documented use of manifest mode where each "item" is a JCS-canonicalized row. The label is conventionally "row-{i}" but callers may supply human-readable labels (e.g. "q1", "customer-42").
2.3 Anchor
POST https://app.satsignal.cloud/api/v1/anchors
Authorization: Bearer sk_...
Content-Type: application/json
{
"matter_slug": "<your matter>",
"items": [
{"label": "row-0", "sha256_hex": "<row_sha_0>"},
...
],
"category": "evidence_bundle",
"label": "<optional anchor-level label>"
}
The server computes the Merkle root, anchors a transaction whose OP_RETURN binds to it, and ships a receipt bundle (.mbnt) whose proofs.json carries the leaves.
2.4 Reveal payload
{
"version": "satsignal-merkle-row-v1",
"leaf_index": 7,
"label": "row-7",
"row": { ... the original row JSON ... },
"row_canonical_b64": "<base64 of canonicalize(row)>",
"row_sha256_hex": "<64-hex>"
}
Plus the receipt bundle (<bundle_id>.mbnt) — which already carries the leaves and lets the verifier walk the inclusion path.
2.5 Verification
The existing https://proof.satsignal.cloud/verify page handles merkle-row-v1 reveals end-to-end as manifest leaf reveals — the user drops the bundle plus the row's canonical bytes (decode row_canonical_b64) into the verifier; it hashes the bytes, looks up the matching leaf in proofs.json, walks the inclusion path to the root, and checks the root against the on-chain transaction.
A verifier MUST ALSO confirm that canonicalize(reveal.row) equals the supplied row_canonical_b64 bytes. Without this binding check, an attacker could mutate the human-readable row field while leaving the canonical bytes intact, producing a verifier-clean reveal that lies about what was committed.
3. merkle-row-sealed-v1 — sealed-style scheme
3.1 When to use it
Use merkle-row-sealed-v1 when row contents are low-entropy ({"vote": "yes"}, {"score": 73}, single bids on a small set of bidders), where a plain SHA-256 leaf would be brute-forceable from the published commitment. The HMAC layer makes leaves opaque to anyone without the per-leaf salt.
3.2 Per-leaf salts (HKDF derivation)
For i in 0..N-1:
salt_i = HKDF-SHA256(
ikm = master_salt, // 32 random bytes
salt = b"satsignal-merkle-row-sealed-v1/per-leaf",
info = b"row/" || encode_u32_be(i), // 4 BE bytes
length = 32
)
This matches the structure of chunk_merkle in SPEC_v2_sealed.md §3.3 — different namespace (row/... vs chunk/...), different HKDF salt string. Revealing salt_i does not permit deriving any other salt_j or recovering master_salt; see SPEC_v2_sealed.md §5.3 for the formal argument.
3.3 Leaf, commitment, and root
row_canonical_i = canonicalize(rows[i])
commitment_i = HMAC-SHA256(salt_i, row_canonical_i) // 32 bytes
root = merkle_root([commitment_0, ..., commitment_{N-1}])
The Merkle tree's inner-node hashing is plain SHA-256; the per-leaf HMAC is the only salting point.
3.4 Commit doc
What gets anchored on chain is the SHA-256 of the commit doc's canonical bytes:
{
"scheme": "satsignal-merkle-row-sealed-v1",
"root": "<64-hex>",
"leaf_count": <N>
}
Anchor:
POST https://app.satsignal.cloud/api/v1/anchors
Authorization: Bearer sk_...
Content-Type: application/json
{
"matter_slug": "<your matter>",
"sha256_hex": "<sha256 of canonicalize(commit_doc)>",
"file_size": <bytes of canonicalize(commit_doc)>,
"category": "commitment",
"label": "merkle-row-sealed-v1 root"
}
The on-chain commitment binds the root + leaf_count together. Holder keeps master_salt, the per-leaf salts (or just regenerates them via HKDF from master_salt), and the row-canonical bytes private until disclosure.
3.5 Reveal payload
To disclose a single row:
{
"version": "satsignal-merkle-row-sealed-v1",
"leaf_index": 7,
"leaf_count": 50,
"label": "row-7",
"row": { ... original row JSON ... },
"row_canonical_b64": "<base64 of canonicalize(row)>",
"salt_b64": "<base64 of salt_i (32 bytes)>",
"commitment_hex": "<64-hex of HMAC(salt_i, row_canonical)>",
"proof": [
{"sib": "<64-hex>", "side": "L" | "R"},
...
],
"root_hex": "<64-hex>"
}
The reveal does not contain master_salt. Per §3.2, revealing salt_i does not permit deriving any salt_j for j ≠ i.
3.6 Verification
A standalone verifier (no Satsignal repo dependency) checks, in order:
reveal.version == "satsignal-merkle-row-sealed-v1".commit_doc.scheme == "satsignal-merkle-row-sealed-v1".commit_doc.root == reveal.root_hex.- Row binding:
canonicalize(reveal.row)equalsdecode_b64(reveal.row_canonical_b64). Without this, a tamperedrowfield passes the HMAC check and lies to the auditor. HMAC-SHA256(decode_b64(salt_b64), row_canonical) == from_hex(commitment_hex).- The Merkle path reconstructs to
from_hex(root_hex). sha256(canonicalize(commit_doc))matches the on-chain transaction's committed hash (resolve via/lookup_hash?sha=<commit_doc_sha>againsthttps://proof.satsignal.cloud).
All seven must hold for the reveal to verify.
4. Threat model deltas
4.1 What merkle-row-sealed-v1 adds over merkle-row-v1
The standard scheme's leaves are pure SHA-256 of canonical row bytes. For low-entropy rows (e.g., a column with only "yes"/"no" values across N bidders), an adversary who sees a leaf can brute-force the row by trying every candidate. The sealed scheme defends against this: HMAC under a per-leaf salt makes the commitment opaque without the salt.
4.2 What both schemes preserve
Selective disclosure: revealing one row's contents (and, for sealed, its salt) does not let an adversary recover any other row's contents or test candidate values against any other commitment.
Tamper evidence: the on-chain timestamp predates any reveal. If the row's bytes are mutated after disclosure, the leaf hash / commitment no longer matches and verification fails at step 5 or 6.
4.3 What neither scheme protects against
Holder dishonesty about row order or labels: if the holder constructs the table with rows the holder has chosen before anchoring, an auditor cannot tell whether the holder dropped or re-arranged unrevealed rows. Adversaries who anticipate this should use commit-reveal (/commit_reveal.py) to commit to the table's intended scope (e.g., a list of bidder IDs) before anchoring, then verify the disclosed row's place against that scope.
Leaf-level metadata leaks for merkle-row-v1: the schema label ("q1", "customer-42") is recorded on the receipt bundle's leaves, not the row contents. Choose labels that don't themselves disclose row content ("row-7" rather than "row-7-customer-acme-corp").
4.4 Salt provenance for merkle-row-sealed-v1
A skeptical auditor might worry that the holder picked the salt after the fact in a way that lets them deny a row or fit a self-serving disclosure. The cryptographic answer is preimage resistance: once a leaf commitment HMAC(salt_i, canonical_row_i) is folded into a Merkle root that is on chain, no different (salt_i', canonical_row_i') pair satisfies it. The holder cannot revise the binding after anchor.
For audit contexts that require explicit proof the salt was fixed before any data was known, anchor a commit-reveal of sha256(canonicalize({nonce_hex, payload: {master_salt_hex, ...}})) before the table anchor; chain-order then enforces salt-first discipline. The full protocol, including what it does and does not defend against, lives in SPEC_v2_sealed.md §12.
5. Live samples (anchored 2026-05-08)
Both schemes have a real on-chain anchor with all artifacts (rows, commit doc, single-row reveal, API response) hosted publicly.
merkle-row-v1 (5 eval-result rows, evidence_bundle category):
- txid
4fc609d83c59fb173848f879ff78148dea3f797ddc2bf65a8c670ba84a8d383d - root
46788ed6fed1498de0b8af5129479afc08b76f67f812702db416501eb358f216 - bundle_id
de04a33002354793 - artifacts at
/samples/merkle-row/
merkle-row-sealed-v1 (5 sealed bids, commitment category):
- txid
d623cb94d09c6d02b11fcd239667e12fab0929ce281b79b7e0964053a6c4dcc8 - commit-doc sha (the on-chain commitment)
f3dd742039a4c287d23f735cb59833f669d1ff96dc50fab0c173a24608ef3584 - root
a67956a2d0ca871b665da9d99fb1ac2dc3690d9484bcac23dd09c2c5ae477bf7 - bundle_id
007dff5816814594 - public lookup
/lookup_hash?sha=f3dd7420…608ef3584resolves to bundle_id + txid without auth
For both samples, the client-side helper computes a Merkle root that matches the on-chain anchor exactly. Drop a *-reveal-row-2.json and the matching commit doc into merkle_row.py verify-sealed (or the JS module's verifySealedReveal) and all four binding checks pass: root_match, row_binding, leaf_commitment, merkle_path.
5.1 Chain confirmation (default on)
Changed 2026-05-08 (commit
581225e): earlier CLI helper versions verified cryptographic checks only — a locally-fabricated commit doc + reveal pair would have passed asverified: true. The chain-confirm step is now the default; offline use requires explicit--no-chain-confirm.
merkle_row.py verify-sealed defaults to confirming the commit doc's sha256 is actually anchored on chain — it hashes the canonicalized commit doc, calls lookup_hash, and reports the result under chain_check:
chain_check.state = "confirmed"(withtxid+bundle_id): the commit doc is on chain.verified: true.chain_check.state = "missing": cryptographic checks pass, but no on-chain anchor exists for this commit doc. The result isverified: falsewithforgery_suspected: true— either the anchor has not yet been broadcast, or the bundle was fabricated locally. Cryptographic verification alone CANNOT distinguish these two cases; only the chain check can.chain_check.state = "error": network-level failure reachinglookup_hash. Verifier fails closed by default; re-run when network is available, or pass--no-chain-confirmto accept the crypto-only result deliberately.
Pass --no-chain-confirm for offline use (e.g. CI without network access). The helper then verifies cryptographic checks only and emits a loud stderr warning: a locally-fabricated commit doc + reveal pair would also pass crypto, so this mode is safe only when you have out-of-band confirmation of the on-chain anchor.
The browser verifier at proof.satsignal.cloud/verify performs the equivalent chain-resolution step automatically (it hits lookup_hash during the merkle-row card flow). The CLI helper now mirrors that discipline rather than diverging.
6. Out of scope
- Server-side enforcement. Satsignal's anchors API does not validate that an
items[]body conforms tomerkle-row-v1or that acategory=commitmentbody is amerkle-row-sealed-v1commit doc. The schemes are interop conventions, not protocol upgrades. - Whole-table reveal. To unseal the entire table publicly, publish the master record (master_salt + all row canonicals). Anyone can recompute every commitment, walk the tree, and confirm the root. No special endpoint or path needed.
- Schema evolution. A
merkle-row-v2would require a new scheme string. Verifiers MUST refuse mismatched scheme strings — do not silently accept future versions.
7. Related specs
/spec-mbnt— the on-chain MBNT OP_RETURN wire format (28-byte header + TLV section). Read this if you're walking from a txid back to adoc_hash./spec— sealed-mode bundle format, HKDF-derived salt protocol, and threat model.
8. Registered schemes
The two scheme strings specified above — satsignal-merkle-row-v1 and satsignal-merkle-row-sealed-v1 — are listed in the public scheme registry at https://satsignal.cloud/schemes.json. The registry carries each scheme's spec link, reference-helper URLs, and the txid of the first on-chain anchor. New schemes are added once they have a stable spec, a published helper, and a real on-chain anchor.
A bundle that declares an unlisted scheme is not automatically invalid — the cryptographic checks in the helper still run — but auditors should treat it the same way they would treat any other operator-supplied verification code: ask for re-issuance under a registered scheme, or for independent reproduction of the verification logic, before trusting the result.
Source: docs/notary_spec/SPEC_merkle_row.md.
Email hello@satsignal.cloud
for clarifications.