SPEC v2.1 — Sealed Mode
Terminology. The canonical vocabulary on every surface is proof (grouped in folders); API responses emit the canonical names only (proof_id, proof_url, folder_slug). Where this spec shows receipt / matter / bundle_id, it is documenting a frozen on-disk/on-chain format, a stable filename (RECEIPTS.md), or a legacy route that remains accepted inbound — proof and receipt denote the same record. Full alias map: the compatibility map.
In plain words: normally an anchor commits to a plain hash of your data, so anyone who already has that data can confirm an anchor exists for it. Sealed mode is for when the existence of the proof is itself sensitive — legal evidence before disclosure, whistleblower material, IP-dispute prep. It anchors a salted commitment instead, so the public chain shows only that some file was anchored at time T — not which file. You hold a secret salt; without it, no one (including Satsignal) can match a candidate file to the anchor. This page is the cryptographic spec for that construction and its threat model. New to the trade-offs? Start at §1 Purpose; the rest is implementer-grade. What an anchor does and does not prove is stated canonically in the bundle spec — sealed mode narrows what a chain observer learns, it does not change what an anchor proves.
This is the implementer specification for sealed-mode proofs. For the user-facing explanation and the API quickstart, see satsignal.cloud/docs.html.
mbnt_version: "2.1", salt_version: "salt_v1". Adds an opt-in "sealed envelope" mode alongside the default public-timestamp mode. Both modes coexist; verifiers auto-detect via manifest.mode.
This document specifies the cryptographic construction, bundle format, and verification flow.
For the underlying on-chain wire format (the 28-byte MBNT header + TLV section that all anchor categories share), see /spec-mbnt. For the selective row-reveal schemes layered on standard and sealed anchors, see /spec-merkle-row. For the cross-system / cross-domain Merkle-batching format that lets a single BSV anchor ride inside foreign receipt formats (AAR, C2PA, RFC 3161 dual-attest, Visa TAP), see /spec-chain-anchor.
1. Purpose
Satsignal's default mode anchors naked hashes of a file (sha256(file_bytes), a scheme-specific content-canonical hash, and a Merkle root over chunks). The on-chain transaction is a public, permanent record that — combined with anyone who has the file's hash — confirms an anchor exists. This is the right default for releases, receipts, and public timestamps.
Sealed mode anchors HMAC-based commitments instead. A high-entropy master salt held only by the user is required to verify. Without it, the chain transaction reveals only that some file was anchored at time T — not which file, and not enough to confirm a candidate file matches.
Use cases:
- Legal evidence preservation before disclosure
- Source / whistleblower material before coordinated release
- Investigative or compliance work where existence-of-proof is itself sensitive
- Trade-secret / IP dispute prep
- Personal evidence (abuse, harassment, safety) where knowledge of possession itself creates risk
2. Threat model
2.1 What sealed mode protects against
- An adversary who possesses or guesses the user's file but not the master salt cannot confirm a Satsignal anchor exists for it.
- An adversary scanning Satsignal's public bundle store or on-chain history sees only timestamps and salted commitments — never plaintext file hashes.
- The naked-hash existence oracle (
/lookup_hash) is strictly excluded for sealed entries (Section 7.2).
2.2 What sealed mode does NOT protect against
- Chain-level metadata. The transaction is public. Observers see "Satsignal anchored some sealed proof at T1, T2, ..." with sizes, timing, and broadcast patterns. Sealed mode is not Tor.
- Bundle leakage. The
.mbntholds the master salt. Anyone who obtains the user's bundle can verify the anchor against any candidate file. The bundle is a bearer secret — losing it to an adversary is roughly as compromising as losing the file. - Salt loss. Lose the bundle, lose the proof. There is no recovery path. The chain entry is permanent but unverifiable without the salt.
- Bundle replay (intentional, not a gap). Bundles are bearer-style: there is no recipient binding in
manifest.json,canonical.json, or the on-chain commitment. A bundle disclosed to one auditor can be re-presented verbatim to a second auditor, and both verifications pass — the cryptography binds the bundle to the file and to the operator who anchored it, not to the recipient who received it. This is by design: the audit anchor is theoperator_id(what was anchored, by whom, when), not a per-disclosure key. Recipients who need a binding they control should layer it on top — for example, by counter-signing or timestamping their copy on receipt — and should not assume the bundle alone prevents the operator from showing the same proof to anyone else. - Sufficiently-resourced chain analysis. Correlation of broadcasts, IP addresses, and timing patterns may infer relationships the cryptography cannot prevent.
3. Cryptographic construction
3.1 Master salt
- 32 bytes (256 bits) from
crypto.getRandomValues(browser-side) at the moment of notarization. - Encoded as base64url in the bundle's
manifest.jsonundersalt_b64. - In blind mode (§11), never transmitted to the server in raw form — only the resulting commitments cross the wire. In the default mirror flow, the raw
salt_b64IS sent to the server and retained (on disk / sidecar) as the intended bearer artifact for the retention window; use blind mode if the server must never see the salt. - Identifier:
salt_v1.
3.2 Whole-file commitments (byte_exact, content_canonical)
byte_exact_commitment = HMAC-SHA256(master_salt, file_bytes)
content_canonical_commitment = HMAC-SHA256(master_salt, content_canonical_bytes)
content_canonical_bytes is the same scheme-specific canonicalization used in public mode (text-norm-v1, json-jcs-v1, csv-norm-v1, zip-manifest-v1, image-pixels-v1, pdf-text-v1, etc.).
3.3 Per-leaf salts for chunk_merkle (HKDF derivation)
For a Merkle tree of N leaf chunks, derive each leaf's salt via HKDF (RFC 5869) so that revealing one leaf's salt for selective disclosure does NOT compromise sibling leaves:
For i in 0..N-1:
salt_i = HKDF-SHA256(
ikm = master_salt,
salt = b"satsignal-sealed-v1/per-leaf",
info = b"chunk/" || encode_u32_be(i),
length = 32 bytes
)
leaf_commitment_i = HMAC-SHA256(salt_i, chunk_i_canonical_bytes)
merkle_root = merkle_root_sha256(leaf_commitment_0, ..., leaf_commitment_{N-1})
The Merkle tree's inner-node hashing is plain SHA-256 (no HMAC at inner nodes). The per-leaf HMAC is the only salting point in the chunk_merkle structure.
Why per-leaf rather than per-tree salting: with a single salt across all leaves, revealing one chunk during selective disclosure would also leak the salt — letting an adversary brute-force candidate values for every other chunk against the published commitments. HKDF-derived per-leaf salts are one-way: revealing salt_i does not permit deriving salt_j for j ≠ i, nor recovering master_salt. See Section 5.3 for the formal argument.
3.4 What lives on chain
Identical to public mode. The canonical proof document (canonical.json, see Section 4.2) is SHA-256'd and truncated to 20 bytes; that prefix becomes document_hash in the OP_RETURN payload. The on-chain marker structure is unchanged.
The contents of canonical.json differ (commitments instead of hashes), but the on-chain shape is identical — observers cannot distinguish a sealed proof from a public proof by the chain record alone.
4. Bundle format (mbnt v2.1, sealed mode)
For implementer use (writing a verifier, CLI, or SDK), see
bundle-v1.md— it inlines field-level shape for manifest, canonical, and proofs JSON across both modes, plus the conformant verification procedure. Specifically: §3.3 supersedes §4.1 for sealed-manifest fields, §4.2 supersedes §4.2 for the sealed canonical-doc shape, §5 supersedes §4.3 for proofs.json. This section is retained as the source for sealed-mode threat-model rationale and the cryptographic construction it commits to (Section 3 above, Section 5 verification).
4.1 manifest.json
{
"mbnt_version": "2.1",
"mode": "sealed",
"salt_version": "salt_v1",
"salt_b64": "<base64url(32 random bytes)>",
"bearer_secret": true,
"txid": "<64-hex>",
"network": "bsv-mainnet",
"created_utc": "2026-...",
"doc_hash_expected": "<40-hex>",
"proof_mode_summary": {
"byte_exact": true,
"content_canonical": true,
"chunk_merkle": true
}
}
Omitted from sealed manifests (these would leak the file):
filename
The file's sha256 and file_size live in canonical.json.subject in standard mode (specifically inside subject.proofs.byte_exact), not at the manifest level — sealed bundles substitute salted commitments there. filename is the only file-identifying field that exists in the standard-mode manifest and gets omitted in sealed mode. The proof page reads filename from the manifest in standard mode and renders the salted commitments from canonical.json in sealed mode.
(Per bundle-v1.md §3.2 — the standard-mode manifest is leaner than earlier drafts of this section narrated.)
bearer_secret: true is informational; renderers (verifier, proof page) MUST display a prominent warning on bundles flagged this way.
4.2 canonical.json
{
"schema_version": 2,
"issuer": "did:web:satsignal.cloud",
"issued_at": "2026-...",
"subject": {
"kind": "file_anchor",
"proofs": {
"byte_exact": {
"algo": "hmac-sha256",
"salt_version": "salt_v1",
"commitment": "<64-hex>"
},
"content_canonical": {
"algo": "hmac-sha256",
"salt_version": "salt_v1",
"scheme": "text-norm-v1",
"commitment": "<64-hex>"
},
"chunk_merkle": {
"algo": "merkle-hmac-sha256",
"salt_version": "salt_v1",
"scheme": "text-line-v1",
"leaf_count": 17,
"root": "<64-hex>"
},
"session_commitment": {
"scheme": "merkle-session-v1",
"algo": "sha256",
"leaf_count": 17,
"root": "<64-hex>"
}
}
}
}
session_commitment is an additive optional fourth proof type that binds this proof to a session. Two schemes are defined; see §4.5. Proofs that don't opt in produce identical canonical bytes to the pre-Phase-4 sealed shape (the field is omitted, not nulled).
4.2.1 Field naming: bundle vs. POST body
canonical.json and proofs.json nest the proof fields under subject.proofs.<proof_type>.<field> (e.g. byte_exact.commitment). When submitting via POST /api/v1/anchors, these dotted paths flatten to underscore-joined body keys:
| Bundle path | POST body key |
|---|---|
byte_exact.commitment | byte_exact_commitment |
content_canonical.commitment | content_canonical_commitment |
content_canonical.scheme | content_canonical_scheme |
chunk_merkle.root | chunk_merkle_root |
chunk_merkle.scheme | chunk_merkle_scheme |
chunk_merkle.leaf_count | chunk_merkle_leaf_count |
session_commitment.scheme | session_commitment_scheme |
session_commitment.algo | session_commitment_algo |
session_commitment.root | session_commitment_root |
session_commitment.leaf_count | session_commitment_leaf_count |
session_commitment.commitment | session_commitment_commitment |
session_commitment.salt_version | session_commitment_salt_version |
Posting {"commitment": "..."} (the unflattened name) is rejected as unknown_field. The flattening is intentional — body keys are flat strings, bundle JSON is nested by structure.
/api/v1/anchors ALSO accepts a structured proof_set / proof_leaves envelope (same field names as the standard-mode JSON API for cross-mode symmetry), which mirrors the bundle JSON one-for-one and is recommended for new agent integrations. See §11.2.1 for the structured shape; it applies to both the salt- bearing (mirror) and blind submit paths.
4.3 proofs.json
When chunk_merkle is present, proofs.json records the per-leaf commitments + the original canonical bytes per chunk (same shape as public v2.0):
{
"scheme": "text-line-v1",
"salt_version": "salt_v1",
"merkle_leaves": ["<commitment_0>", ..., "<commitment_{N-1}>"],
"metadata": { "canonical_scheme": "text-norm-v1", "non_empty_lines": 17 }
}
The per-leaf salts themselves are NOT stored — they are deterministically derivable from master_salt + index via HKDF. This keeps proofs.json the same size as in public mode (only the leaves carry HMAC values instead of plain hashes).
4.5 session_commitment (Phase 4.2 additive, 2026-05-20)
session_commitment is the optional fourth proof type. It binds a sealed proof to a multi-anchor session. Two schemes are defined behind an explicit scheme discriminator; both are payload-free — they carry a fixed-size commitment, never raw session data or the plaintext session identifier.
Proofs that don't opt in omit the field entirely. The legacy 1-proof and 3-proof sealed shapes canonicalize to byte-identical bytes under a verifier that knows about session_commitment. This follows the additive-only versioning rule: new fields arrive as optional keys and never alter the legacy canonical bytes.
4.5.1 merkle-session-v1 (default)
Emitted on a session's closing-handoff anchor — a single end-of-session anchor distinct from the per-anchor sealed proofs that came before it. Carries the Merkle root over all session leaves (typically the per-anchor canonical-doc hashes of the session).
"session_commitment": {
"scheme": "merkle-session-v1",
"algo": "sha256",
"leaf_count": 17,
"root": "<64-hex>"
}
rootissha256over the session-leaf Merkle tree, computed at session close.leaf_countis the number of leaves the root commits to.- Algo is plain
sha256, not HMAC — session-leaf inputs are themselves already-committed canonical-doc hashes, so the binding derives from the closure act + the leaves, not from a re-keying.
Streaming Merkle-prefix is explicitly out of scope — committing an intermediate prefix root mid-session complicates completeness semantics and trades a clean "the session looks like this" claim for a fragmented "the session looked like this prefix at time T" claim. See Phase 2 design lock §3 (the Phase 2 design locks (internal record)).
A pre-close (intra-session) sealed proof does not carry merkle-session-v1 — the root isn't computable yet. If a session emitter needs intra-session linkage between proofs, it uses hmac-session-v1 (§4.5.2).
4.5.2 hmac-session-v1 (optional)
Emitted on every per-anchor sealed proof of a session. The commitment is HMAC-SHA256(session_salt, session_identifier_bytes) where session_salt is held by the emitter and session_identifier is a stable per-session value. The same commitment appears on every proof of the session.
"session_commitment": {
"scheme": "hmac-session-v1",
"algo": "hmac-sha256",
"salt_version": "salt_v1",
"commitment": "<64-hex>"
}
Trade-off. This scheme is documented as leaking linkage while hiding the plaintext session identifier. Anyone reading two hmac-session-v1 proofs with identical commitment values learns they belong to the same session, but learns nothing about which session or what the identifier was. Callers that need stronger unlinkability between proofs of a session must wait for the closing-handoff anchor (§4.5.1) and avoid this scheme.
The session_salt is not the bundle's master_salt — it is a caller-held secret specifically scoped to the session. Reusing master_salt would re-derive the same commitment from the file's own HMAC keys, making the link inferrable from observed proof-set entries.
4.5.3 Wire-shape rules
schemeis enum-checked against{merkle-session-v1, hmac-session-v1}.- If
algois supplied on the wire, it MUST match the scheme's required algo (sha256formerkle-session-v1,hmac-sha256forhmac-session-v1). Cross-scheme mismatches 400. - Crossed fields are rejected: a
merkle-session-v1proof must not carrycommitment; anhmac-session-v1proof must not carryrootorleaf_count. leaf_countMUST be1..MAX_LEAF_COUNT(100,000).
4.5.4 Minting a closing handoff today (Phase 4.3, 2026-05-20)
Phase 4.3 is intentionally light: a pure Merkle-root helper + client-orchestrated POST through the existing /api/v1/anchors mode=sealed surface. There is no dedicated POST /sessions/<id>/close route; one would only add value if Satsignal also asserted server- side which leaves belong to the session, and that authoritative variant is deferred (no driver yet — adapters that have been asking for this all want client-orchestrated for the same reason they want sealed mode: salt + identifier stay client-side).
The recipe a session-aware client follows is:
- Collect per-anchor leaves. During the session, record each sealed proof's
canonical.jsonsha256 in commit order. These are the leaves of the closing Merkle tree. - Compute the root. Use
satsignal_notary.notary.sealed.merkle_session_root(leaves)— stdlib-only, deterministic sha256 binary tree with duplicate-last on odd levels (Bitcoin / standard convention). A single-leaf session returnssha256(leaf || leaf), never the bare leaf, so a root cannot be confused with a leaf hash. - POST the closing handoff to
/api/v1/anchorsmode=sealed (mirror or blind), with the structuredproof_setenvelope:
``json { "mode": "sealed", "folder_slug": "<your folder>", "byte_exact_commitment": "<HMAC over the closing artifact>", "salt_b64": "<32-byte b64url, omit for blind>", "session_id": "<your session id>", "proof_set": { "byte_exact": {"algo": "hmac-sha256", "commitment": "<same HMAC>"}, "session_commitment": { "scheme": "merkle-session-v1", "algo": "sha256", "leaf_count": <int>, "root": "<merkle_session_root output, 64-hex>" } } } ``
byte_exact is still required by the sealed schema — the closing anchor IS itself a sealed proof. The natural choice is a HMAC over the canonical bytes the client chooses as the closing artifact (e.g. a small "session-closing handoff" JSON document that lists the leaves, the root, session_id, and closure time). That handoff document then rides in the proof's proofs.json sidecar so an offline verifier can re-derive everything.
- Verify offline. Re-compute
merkle_session_rootover the leaves listed in the handoff document and compare to thesession_commitment.rootincanonical.json. The standard sealed verification ofbyte_exactthen ties the handoff document's canonical bytes to the on-chain commitment.
A reference helper in Python is the merkle_session_root() function in notary/sealed.py; the contract tests in scripts/test_sealed_session_commitment.py pin the algorithm (single-leaf rule, duplicate-last invariant, leaf hex hygiene, leaf-count cap).
5. Verification
5.1 Whole-bundle verification
Inputs: .mbnt bundle + the original file.
- Open
.mbnt; parsemanifest.json. - If
manifest.mode != "sealed", fall through to public-mode verification (this spec covers sealed; public is SPEC_v2.md). - Decode
master_salt = base64url_decode(manifest.salt_b64). - Re-canonicalize the file according to the bundle's declared scheme.
- Recompute:
byte_exact_check = HMAC-SHA256(master_salt, file_bytes)content_canonical_check = HMAC-SHA256(master_salt, content_canonical_bytes)- For chunk_merkle: derive each
salt_ivia HKDF (Section 3.3), computeHMAC-SHA256(salt_i, chunk_i_canonical), build the Merkle root, compare tocanonical.json'schunk_merkle.root.
- Compare each computed value to
canonical.json's storedcommitment/root. All must match. - Re-canonicalize
canonical.json, SHA-256, truncate to 20 bytes; compare tomanifest.doc_hash_expected. - Fetch the on-chain transaction; confirm OP_RETURN's
document_hashmatches the value from step 7.
A valid match-on-all means: at the block timestamp of the transaction, the user provably possessed a file whose canonical forms HMAC under the bundle's master_salt to the committed values.
5.2 Selective disclosure of a single chunk
Holder wants to publish chunk i (a specific PDF page, CSV row, ZIP file entry) without revealing the rest of the file or the master salt.
Holder publishes:
- The chunk's canonical bytes (
chunk_i_canonical) - The leaf index i
- The derived
salt_i(32-byte value, one-way derivable frommaster_salt; revealing it does NOT permit recoveringmaster_saltor anysalt_jforj ≠ i) - The Merkle path: sibling commitments along the way from leaf i up to the root (each is 32 bytes, ⌈log₂N⌉ siblings)
- The full
canonical.json - The on-chain
txid(or, equivalently, anything that resolves to it)
Holder withholds: master_salt, the original file, all other chunks, the manifest's bearer-secret-flagged salt_b64.
Verifier:
- Compute
leaf_commitment_i = HMAC-SHA256(salt_i, chunk_i_canonical). - Walk the Merkle tree using sibling commitments; derive a root.
- Compare the derived root to
canonical.json'schunk_merkle.root. - Re-hash
canonical.json, truncate, compare to on-chaindocument_hash. - Fetch the tx; confirm.
Result: the verifier confirms chunk i was part of the originally-anchored file, learning nothing about the file's other chunks, the master salt, or the byte_exact / content_canonical commitments.
5.3 Why HKDF per-leaf salts preserve secrecy on partial reveal
Claim: revealing salt_i does not allow an adversary to derive any other salt_j (j ≠ i) or master_salt.
Argument:
- HKDF-SHA256 is modeled as a pseudo-random function with HMAC-SHA256 as the underlying compression. Inverting HKDF to recover the input keying material (
master_salt) from any number of output blocks would reduce to inverting HMAC-SHA256, which is widely held infeasible. salt_iandsalt_jare derived from the samemaster_saltbut with distinctinfostrings (b"chunk/" || encode_u32_be(i)vs the same withj). HKDF'sinfoparameter mixes into the expansion via HMAC; under PRF assumptions, output blocks for distinctinfostrings are indistinguishable from independent random — an adversary cannot derivesalt_jfromsalt_iwithout knowingmaster_salt.- Therefore: revealing
salt_i(and the chunk + path) lets a verifier confirm chunk i, but the bundle's other leaf commitments remain opaque to brute-force; a candidatechunk_jcannot be tested againstleaf_commitment_jwithoutmaster_salt.
A formal reduction is straightforward and is left to a future appendix if the construction needs external review.
6. Whole-document reveal (unsealing publicly)
Holder publishes:
- The original file
- The full
.mbntbundle (containingmaster_salt) - Optionally, a short verification walkthrough
Anyone can verify per Section 5.1.
This is the primary unsealing path. There is no server-side endpoint that "promotes" a sealed entry to public mode. The user controls disclosure entirely; "unsealing" is just publishing the bundle to whoever needs to verify.
7. Operational considerations
7.1 Server-side bundle persistence (sealed mode)
The two modes' security contract. Sealed mode has exactly two shapes, distinguished by whether the salt crosses the wire:
- MIRROR (salt-bearing). The client sends
salt_b64; the server holds the bearer bundle — indefinitely by default (until the workspace owner deletes the proof), or forretain_days(>= 1) days when the caller chooses an explicit window — and CAN verify a candidate file against the anchor while it holds the bundle. When an explicit window lapses (or the proof is deleted) the server deletes the bundle and its sidecar; from then on only the client's local copy can verify. Mirror trades a declared server-side exposure for human-recoverable convenience; callers who want that exposure bounded set an explicitretain_days. - BLIND (§11). The salt is never sent. The server never holds it, writes no bundle and no sidecar, and CANNOT verify any candidate file — ever, including under subpoena, because it does not have the secret. the
/proof/<proof_id>page (legacy/receipt/<bundle_id>) does not resolve.
retain_days == 0 if and only if blind is an enforced invariant, symmetric in both directions:
- Blind (salt absent) ⇒
retain_daysMUST be0or absent (§11.2); the server has nothing to retain. - Mirror (salt present) ⇒
retain_daysMUST be>= 1. A salt-bearing submission with an explicitretain_days == 0is rejected with400 "retain_days: 0 requires blind mode — omit salt_b64". The server MUST NOT clamp0up to1: clamping would silently retain a bearer secret the client asked it to drop — strictly worse than the error. A salt-bearing submission that OMITSretain_daysis retained INDEFINITELY (the mirror default — never 0, and not a plan-tier window).
The rationale: a non-blind retain_days == 0 would write the client's 32-byte master salt into the operator sidecar with no on-disk bundle to justify it, where it survives until the next TTL sweep — contradicting the promise that a non-retained submission leaves no server-side copy of the salt. The middle "salt-bearing but keep nothing" mode therefore ceases to exist; "keep nothing" is blind.
For mirror submissions (the salt-bearing flow), the default is indefinite retention (there is no plan-tier ceiling): the bundle stays on disk until the workspace owner deletes the proof. A caller that wants a bounded server-side exposure window sends an explicit retain_days >= 1 — honored as given on every plan (only an ~100-year arithmetic sanity clamp applies) — and the TTL sweep deletes the bundle after the window lapses. Bounding the operator-side blast radius is therefore a per-anchor caller choice, not a plan default.
For blind submissions (§11), no .mbnt is written to disk and no sidecar is recorded. The salt never reaches the server. There is no server-side copy to age out, and the /proof/<proof_id> page (legacy /receipt/<bundle_id>) does not resolve.
The chain anchor remains permanent regardless; the user's local copy is the durable artifact.
Public-mode bundles continue to retain indefinitely; they are designed for re-fetch.
7.2 /lookup_hash exclusion
Sealed-mode entries MUST NEVER appear in /lookup_hash. The endpoint keys on naked sha256(file_bytes); sealed manifests do not store that field, so they would not match a lookup query in any case. Implementation MUST additionally skip records where mode == "sealed" as defense in depth.
The duplicate-detection preflight that runs client-side on file-pick is disabled in sealed mode. Submitting the same file twice produces two independent sealed proofs (different salts, different commitments) — by design.
Manifest-mode bundles (mode == "manifest", e.g. the manifest-items-v1 scheme) are likewise not resolvable via /lookup_hash. The endpoint keys on sha256(file_bytes) (or, for merkle-row schemes, the commit-doc sha — see SPEC_merkle_row.md); manifest-mode bundles bind multiple files via a Merkle root and do not populate file_sha256_hex, so no query against the naked-sha oracle can find them. Verify a manifest-mode anchor by holding the bundle and chain-confirming the txid directly, or by re-deriving the root from the leaves the customer holds locally and matching against manifest.root in the bundle JSON.
Provenance bundles (satsignal.provenance.v1, internally mode == "manifest") follow the same rule: the canonical provenance manifest's sha256 is anchored, file_sha256_hex is not populated, and the proof is intentionally not /lookup_hash-resolvable — the endpoint returns a deliberate miss for these. Verify a provenance anchor offline from the .mbnt per provenance-v1.md §5.
7.3 Receipt page exposure (sealed mode)
This section applies to mirror submissions only. Blind submissions (§11) do not produce a /proof/<proof_id> page on the notary host: there is no server-side bundle and no sidecar to render from.
The public proof page at /proof/<proof_id> (legacy /receipt/<bundle_id>) for a sealed bundle SHOWS:
- The salted commitments (opaque to chain observers)
- The timestamp
- The on-chain txid
- A prominent bearer-secret warning explaining that anyone with the bundle ID can fetch this page and that the bundle file is itself sensitive
- A retention indicator — the explicit window's expiry date when the caller chose one (e.g., "this server-side copy expires YYYY-MM-DD"), or "kept until the proof is deleted" for the indefinite default
The page MUST NOT show:
- The file's plain sha256
- The file label (filename) or memo (these stay only in the user's local manifest, never reach the server)
- Anything that would let a
/proof/<id>-discovering adversary correlate the entry to a known file
7.4 Form privacy in sealed mode
The /seal form's client-side JS MUST NOT transmit the file's plain sha256, the content-canonical hash, or any naked-hash form to the server. Only the salted commitments and the canonical-doc commitment (document_hash) cross the wire.
7.5 Mode separation
Sealed and standard (naked-hash) modes are selected by the mode request field, not by host. The public anchor form (served at proof.satsignal.cloud) submits the standard mode; the sealed form (served at proof.satsignal.cloud/sealed) submits mode=sealed. The two are processed by distinct server pipelines:
- A
mode=sealedsubmission is handled only by the sealed pipeline, which takes salted commitments + a master salt (salt_b64, commitment fields), never the naked-hash fields, and rejects disallowed field combinations (4xx). - A standard submission is handled by the naked-hash pipeline, which takes
sha256_hex+file_size.
Because each form fixes its own mode, this prevents the "wrong-mode-by-toggle" failure where a user might accidentally submit sensitive material through the public flow.
Historical note: sealed mode was previously served from a separate host,
sealed.satsignal.cloud. The mode has always been chosen by themodefield — the submit endpoint was never host-gated — so this is a documentation correction, not a behavior change. As of 2026-06-01 the sealed form is served atproof.satsignal.cloud/sealedandsealed.satsignal.cloudis a 308 redirect alias.
8. Backwards compatibility
- Public bundles (
mbnt v2.0) verify unchanged. Their canonical docs carry plain hashes, not commitments. Verifiers detect mode viamanifest.mode(absent or"public"→ public;"sealed"→ sealed). - Sealed bundles (
mbnt v2.1) require the verifier to carry the HMAC + HKDF code paths. - The verifier MAY refuse to load a bundle whose
mbnt_versionis newer than its supported set. The existing v2.0 verifier will refuse v2.1 bundles cleanly (not silently misverify).
9. Versioning
- This document specifies
mbnt v2.1andsalt_v1. - Salt format changes (e.g., switching to a hash-based KDF other than HKDF, changing the per-leaf info encoding) → bump
salt_version(salt_v2, etc.) without bumpingmbnt_version. Verifiers handle multiplesalt_versionvalues via dispatch. - Major schema changes (new commitment types, new bundle layout) → bump
mbnt_version. - A bundle's
mbnt_versionandsalt_versiontogether fully determine its verification procedure.
Additive changes within mbnt v2.1 (no version bump):
- 2026-05-20 (Phase 4.2):
session_commitmentproof type added as an OPTIONAL fourth entry insubject.proofs. Two schemes —merkle-session-v1andhmac-session-v1— behind an explicitschemediscriminator. Pre-Phase-4 proofs omit the field; the legacy canonical bytes are unchanged. See §4.5; this change ships under the additive-update rule, where new fields arrive as optional keys without altering existing canonical bytes.
10. Out of scope for this version
- Passphrase-derived salts. v1 uses random 256-bit salts only. A human-memorable passphrase mode would require a strong KDF (Argon2id) and careful UX to avoid weak-passphrase footguns.
- Multi-party sealed proofs (k-of-n disclosure). Adds significant scheme complexity.
- Server-side reveal endpoints. Unsealing is user-controlled; there is no server-side promotion path.
- Asymmetric commitments (e.g., signed instead of HMAC'd). A different construction from the HMAC commitment specified here.
11. Blind submission (server never sees the salt)
The shapes in §4–§7 describe the salt-bearing flow: the client sends salt_b64, the server stores or relays the salt-bearing zip, and the on-chain commitment is built from those inputs. Section §11 specifies a parallel blind flow that closes the only remaining window in which the salt sits in server memory (between request parse and response send). In blind mode the salt never crosses the wire and never enters the server's process at all.
The bundle format, on-chain payload, and verifier are unchanged. Blind is a wire-protocol option, not a new cryptographic scheme.
11.1 Trigger
A request is blind iff salt_b64 is absent or empty. The server MUST NOT use any other signal (no version flag, no Accept-header magic). Rationale: a single self-evident trigger lets the three sealed shapes coexist on /api/v1/anchors (and on the form-handling /notarize endpoint) without API-version churn.
11.2 Request
POST /api/v1/anchors (or POST /notarize for the form surface)
{
"mode": "sealed",
"folder_slug": "<workspace-scoped slug>",
"byte_exact_commitment": "<HMAC-SHA256, 64-hex>",
"file_size": 208,
"category": "policy_snapshot",
// Optional, same as the salt-bearing shape:
"content_canonical_commitment": "...",
"content_canonical_scheme": "json-jcs-v1",
"chunk_merkle_root": "...",
"chunk_merkle_scheme": "...",
"chunk_merkle_leaf_count": 12,
"chunk_merkle_leaves": [...]
}
Validation rules specific to blind:
salt_b64MUST be absent or empty.salt_versionMUST be absent. The server defaults it tosalt_v1for the on-chain canonical doc; the client uses the matching value when assembling the manifest.retain_daysMUST be0or absent. Any non-zero value is rejected with400(the server has nothing to retain). The converse holds for mirror submissions: a request that carriessalt_b64MUST sendretain_days >= 1(or omit it for indefinite retention, the default); a salt-bearingretain_days == 0is rejected400 "retain_days: 0 requires blind mode — omit salt_b64"(§7.1).retain_days == 0⇔ blind is a two-way enforced invariant.- All other commitment + scheme validation is identical to §4–§5.
11.2.1 Structured proof_set envelope (JSON API, mirror + blind)
/api/v1/anchors ALSO accepts the standard-mode-symmetric structured envelope (proof_set + proof_leaves, same field names as the public-mode path documented at app.*/docs#proof-set) in lieu of the flat chunk_merkle_* / content_canonical_* fields shown in §11.2. The two shapes are mutually exclusive on a single request; the structured shape is recommended for new agent integrations because it mirrors the canonical-doc layout one-for-one.
{
"mode": "sealed",
"folder_slug": "<slug>",
"salt_b64": "<32-byte base64url — omit for blind>",
"byte_exact_commitment": "<HMAC-SHA256, 64-hex>",
"file_size": 208,
"proof_set": {
"byte_exact": {"algo": "hmac-sha256",
"commitment": "<same HMAC as top-level>"},
"content_canonical": {"algo": "hmac-sha256",
"scheme": "json-jcs-v1",
"commitment": "<HMAC of canonicalized bytes>"},
"chunk_merkle": {"algo": "merkle-hmac-sha256",
"scheme": "pdf-page-v1",
"leaf_count": 12,
"root": "<Merkle-HMAC root>"},
// Phase 4.2 additive — optional fourth proof type (§4.5).
// Choose ONE scheme per proof; the wire form must match.
"session_commitment": {"scheme": "merkle-session-v1",
"algo": "sha256",
"leaf_count": 17,
"root": "<session Merkle root, 64-hex>"}
},
"proof_leaves": {"scheme": "pdf-page-v1",
"merkle_leaves": ["<leaf HMAC>", "..."],
"metadata": {"leaf_count": 12}}
}
Differences from the standard-mode envelope (see bundle-v1.md §5 for the public-mode shape):
proof_set.byte_exactusescommitment(HMAC), nothash/size. The file size stays top-level (file_size) and does NOT enter the on-chain envelope — sealed canonical docs strip leak fields per §4.2.- All
algofields are HMAC variants. A standard-modealgo(sha256) on a sealed body is rejected at the API edge before the wallet is touched (and would be rejected again by the canonical-doc validator if it slipped through). proof_set.byte_exact.commitmentMUST equal the top-levelbyte_exact_commitment. Single source of truth in the body, cross-check on the way in.proof_leavesis required whenproof_set.chunk_merkleis present (D1: leaves ride off-chain inproofs.jsonso leaf recompute is structurally impossible — the on-chain envelope carries only{scheme, algo, leaf_count, root, salt_version}). An orphanproof_leaves(noproof_set) is rejected.salt_versionis NOT on the wire in the structured shape — server-defaulted tosalt_v1(only one supported version today). The on-chain envelope binds it so a future version flip stays cleanly partitioned.
The response shape (§11.3 for blind, §11 prologue for the salt- bearing in-memory mirror) is unchanged. There is no force_new 409 path on sealed — sealed entries never index a naked file hash, so default-dedup does not apply.
11.3 Response
{
"proof_id": "f83649e3846c4ea2",
"txid": "2e042a64...7a3db61b",
"mode": "sealed",
"category": "policy_snapshot",
"folder_slug": "agent-runs-prod",
"proof_url": "https://app.satsignal.cloud/w/.../r/f83649e3846c4ea2",
"dry_run": false,
"canonical_b64": "<base64 of canonical.json bytes>",
"doc_hash": "<sha256(canonical_bytes)[:40] hex>",
"acceptance": { ... } // optional, ARC-only
}
canonical_b64 carries the verbatim bytes the server hashed for doc_hash and committed on chain. The client MUST embed those bytes into canonical.json without re-canonicalization (no JCS round-trip, no whitespace normalization). doc_hash is supplied as a convenience so the client can populate manifest.doc_hash_expected without re-hashing.
A blind response MUST NOT include bundle_b64 or retain_until. The proof has no server-side bundle.
11.4 Client-side bundle assembly
The client holds the salt + commitments in browser/process memory from §3. After receiving the blind response, it builds an mbnt v2.1 zip locally with the same three files specified in §4:
manifest.json— fields per §4.1, withsalt_b64set to the client's salt (base64url, unpadded),salt_version: "salt_v1",bearer_secret: true, anddoc_hash_expectedcopied from the response.server_retain_until_utcis set to the submit time (already-elapsed) to signal "no mirror".canonical.json— the bytes decoded fromcanonical_b64, verbatim. The client MUST NOT re-encode.proofs.json— built locally from the same chunk-merkle leaves the client computed before submitting (§3.3); identical shape to §4.3. Omitted when nochunk_merkle_rootwas sent.
Container-level details (zip-deflate ordering, extra-field bytes, timestamps) MAY differ from a server-built bundle. The verifier reads files by name and recomputes hashes from their contents, so container bytes are not part of the cryptographic chain. Verifier-equivalent, not byte-equivalent, is the requirement.
11.5 Verifier compatibility
None of §5 changes. The verifier:
- Unzips the
.mbnt. - Reads
manifest.jsonformode,txid,salt_b64,salt_version. - Reads
canonical.jsonraw bytes, computessha256(canonical_bytes)[:40], compares to the on-chain commitment. - On the user-supplied candidate file, recomputes
HMAC(salt, candidate)and compares againstsubject.proofs.byte_exact.commitment.
A blind-assembled bundle satisfies all four checks without any verifier-side awareness of how it was assembled.
11.6 Threat-model delta
Blind closes one attack the salt-bearing flow leaves open: a live RCE or process-memory disclosure during the request window. In the salt-bearing flow the salt sits in server memory between request parse and response send (~10ms but real); in blind it never enters the process at all.
Blind does NOT change:
- Chain-level metadata visibility (sealed-anchor existence at T1, T2, ... is still public per §3.4).
- Bundle leakage on the client side (a stolen
.mbntis still a bearer secret per §6). - TLS / network observer protection (TLS terminates at the reverse proxy regardless; the salt was never visible to network observers in the salt-bearing flow either).
The pitch claim blind unlocks: the server cannot disclose your bearer secret to anyone, ever — including under subpoena — because it does not have it.
11.7 Out-of-scope for v1 of the blind protocol
- CLI sealed flow.
mbCLI's sealed path retains the salt-bearing shape; CLI users typically run on disposable hosts where the wire-blindness gain is small. - Workspace dashboard form on
app.satsignal.cloud. The customer dashboard stays salt-bearing (mirror) by default — humans need recovery, and the dashboard is the human surface. The mirror is retained indefinitely unless the user picks an explicit window. - Coordinated multi-party blind submissions. Out of scope until a real use case appears.
12. Salt provenance (the "salt-after-the-fact" concern)
A skeptical auditor evaluating any sealed-mode disclosure may ask: "how do I know the holder didn't pick the salt after seeing the data, in a way that lets them pretend a row contained something else, or hide rows that don't fit a desired narrative?" This section is the formal answer for both whole-file sealed bundles (§3) and merkle-row-sealed-v1 tabular commitments (SPEC_merkle_row.md §3).
12.1 Cryptographic binding (always holds)
HMAC-SHA256 is preimage-resistant. Once a leaf commitment HMAC(salt_i, canonical_bytes_i) is folded into a Merkle root that is on chain, only one (salt_i, canonical_bytes_i) pair satisfies it. A holder who anchored an honest table cannot, after the fact, find a different pair (salt_i', canonical_bytes_i') that produces the same commitment — that would be a SHA-256 preimage.
In particular, the holder cannot:
- Reveal a different
canonical_bytes_i'and claim "the salt wassalt_i', not what we said." The auditor's binding check (HMAC(reveal.salt_b64, canonicalize(reveal.row)) == reveal.commitment_hex) fails for any(salt_i', canonical_bytes_i') != (salt_i, canonical_bytes_i). - Substitute a different inclusion path. The path is fixed by the leaf's index in the tree; any sibling change yields a different root, and that root is bound on chain (via the canonical doc's 20-byte commitment).
So "the holder lies about what was committed at index i" is ruled out by the cryptography alone — regardless of when or how the salt was generated.
12.2 Operational binding (depends on the flow)
The cryptographic argument doesn't say when the salt was chosen or who chose it. The salt-bearing flow has the salt in server memory briefly between request parse and response (§11.6), and in the persisted .mbnt while the server retains it — indefinitely by default, or for the caller's explicit window (§7.1). The blind flow (§11) never exposes the salt to the server at all.
In both flows, the salt is generated client-side via crypto.getRandomValues, not by the operator. An adversary who controls only the operator therefore cannot grind the salt to fit a desired commitment shape — they never had it. The blind flow shrinks the window in which the salt is exposed at all, from "during retention" to "never," which closes the salt-grinding attack against a fully-compromised operator with disclosure power (subpoena, RCE, insider access).
If the auditor's threat model includes the user as the adversary (rather than the operator), then §12.1's preimage-resistance argument is the only thing that matters: the user did choose the salt, but they chose it before the data was bound, and the cryptography prevents them from revising the binding later.
12.3 H(salt) pre-commit (strongest binding)
For audit contexts that require explicit proof that the salt was fixed before any data was known — e.g., a regulatory regime that treats both holder and operator as untrusted, and wants timestamp ordering to enforce salt-first discipline — use commit-reveal (§8c, commit_reveal.py) to anchor a hash of the salt before the data is collected.
Protocol:
- Holder generates
master_salt(32 bytes,crypto.getRandomValues). - Holder builds a
{nonce_hex, payload}wrapper wherepayload = {"master_salt_hex": hex(master_salt), "purpose": "<scheme name>"}. Anchorssha256(canonicalize(...))undercategory="commitment". Call this txid T_salt. - Time passes. The data is collected. The holder cannot revise
master_saltafter step 2 without losing the ability to reveal it (the on-chain hash is binding, per the commit-reveal spec). - Holder constructs the sealed bundle (§3) or
merkle-row-sealed-v1table (SPEC_merkle_row.md§3) and anchors the root undercategory="commitment". Call this txid T_root. - At disclosure time, the holder reveals
master_salt(with the nonce from step 2) plus the targeted row. The auditor checks:sha256(canonicalize({"nonce_hex": <revealed>, "payload": {"master_salt_hex": ..., "purpose": ...}}))matches the on-chain hash for T_salt.block_time(T_salt) < block_time(T_root). (BSV chain order is total; both txids are easily resolved against any public explorer.)salt_i = HKDF(master_salt, ...)per §3.3 (or themerkle-row-sealed-v1HKDF info string) produces the per-leaf salt the row reveal carries.
This is overkill for most audit settings — §12.1 plus client-generated salts (§12.2) already defeat the "salt grinded post-hoc" claim. But the pre-commit pattern is available for adversarial settings where an additional independent commitment is justified. The pattern is purely client-side and does not require any new server endpoint.
12.4 What H(salt) pre-commit does NOT defend against
- Holder-chosen rows / scope. The holder still picks what goes into the table. If they want to omit a bidder, they omit the row from
rows[]; no salt argument changes that. To bind the table's intended scope, anchor a separate commit-reveal of the expected scope (e.g.{"expected_bidder_ids": [...]}) before the data anchor — same pattern, different payload. - Master salt theft. If
master_saltis ever leaked, every per-leaf salt is derivable via HKDF. The holder must keepmaster_saltconfidential after anchoring (or accept that all rows are publicly recoverable). The pre-commit binding does not change this — it only proves the salt was fixed early, not that it remains secret. - Bundle theft. A stolen
.mbntcarrying the salt is a bearer secret per §6. Treat it accordingly.
Questions about this specification? Email hello@satsignal.cloud.