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:


2. Threat model

2.1 What sealed mode protects against

2.2 What sealed mode does NOT protect against


3. Cryptographic construction

3.1 Master salt

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

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 pathPOST body key
byte_exact.commitmentbyte_exact_commitment
content_canonical.commitmentcontent_canonical_commitment
content_canonical.schemecontent_canonical_scheme
chunk_merkle.rootchunk_merkle_root
chunk_merkle.schemechunk_merkle_scheme
chunk_merkle.leaf_countchunk_merkle_leaf_count
session_commitment.schemesession_commitment_scheme
session_commitment.algosession_commitment_algo
session_commitment.rootsession_commitment_root
session_commitment.leaf_countsession_commitment_leaf_count
session_commitment.commitmentsession_commitment_commitment
session_commitment.salt_versionsession_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>"
}

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

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:

  1. Collect per-anchor leaves. During the session, record each sealed proof's canonical.json sha256 in commit order. These are the leaves of the closing Merkle tree.
  2. 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 returns sha256(leaf || leaf), never the bare leaf, so a root cannot be confused with a leaf hash.
  3. POST the closing handoff to /api/v1/anchors mode=sealed (mirror or blind), with the structured proof_set envelope:

``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.

  1. Verify offline. Re-compute merkle_session_root over the leaves listed in the handoff document and compare to the session_commitment.root in canonical.json. The standard sealed verification of byte_exact then 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.

  1. Open .mbnt; parse manifest.json.
  2. If manifest.mode != "sealed", fall through to public-mode verification (this spec covers sealed; public is SPEC_v2.md).
  3. Decode master_salt = base64url_decode(manifest.salt_b64).
  4. Re-canonicalize the file according to the bundle's declared scheme.
  5. 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_i via HKDF (Section 3.3), compute HMAC-SHA256(salt_i, chunk_i_canonical), build the Merkle root, compare to canonical.json's chunk_merkle.root.
  6. Compare each computed value to canonical.json's stored commitment / root. All must match.
  7. Re-canonicalize canonical.json, SHA-256, truncate to 20 bytes; compare to manifest.doc_hash_expected.
  8. Fetch the on-chain transaction; confirm OP_RETURN's document_hash matches 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:

Holder withholds: master_salt, the original file, all other chunks, the manifest's bearer-secret-flagged salt_b64.

Verifier:

  1. Compute leaf_commitment_i = HMAC-SHA256(salt_i, chunk_i_canonical).
  2. Walk the Merkle tree using sibling commitments; derive a root.
  3. Compare the derived root to canonical.json's chunk_merkle.root.
  4. Re-hash canonical.json, truncate, compare to on-chain document_hash.
  5. 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:

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:

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:

retain_days == 0 if and only if blind is an enforced invariant, symmetric in both directions:

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 page MUST NOT show:

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:

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 the mode field — 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 at proof.satsignal.cloud/sealed and sealed.satsignal.cloud is a 308 redirect alias.


8. Backwards compatibility


9. Versioning

Additive changes within mbnt v2.1 (no version bump):


10. Out of scope for this version


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:

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

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:

  1. manifest.json — fields per §4.1, with salt_b64 set to the client's salt (base64url, unpadded), salt_version: "salt_v1", bearer_secret: true, and doc_hash_expected copied from the response. server_retain_until_utc is set to the submit time (already-elapsed) to signal "no mirror".
  2. canonical.json — the bytes decoded from canonical_b64, verbatim. The client MUST NOT re-encode.
  3. proofs.json — built locally from the same chunk-merkle leaves the client computed before submitting (§3.3); identical shape to §4.3. Omitted when no chunk_merkle_root was 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:

  1. Unzips the .mbnt.
  2. Reads manifest.json for mode, txid, salt_b64, salt_version.
  3. Reads canonical.json raw bytes, computes sha256(canonical_bytes)[:40], compares to the on-chain commitment.
  4. On the user-supplied candidate file, recomputes HMAC(salt, candidate) and compares against subject.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:

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


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:

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:

  1. Holder generates master_salt (32 bytes, crypto.getRandomValues).
  2. Holder builds a {nonce_hex, payload} wrapper where payload = {"master_salt_hex": hex(master_salt), "purpose": "<scheme name>"}. Anchors sha256(canonicalize(...)) under category="commitment". Call this txid T_salt.
  3. Time passes. The data is collected. The holder cannot revise master_salt after step 2 without losing the ability to reveal it (the on-chain hash is binding, per the commit-reveal spec).
  4. Holder constructs the sealed bundle (§3) or merkle-row-sealed-v1 table (SPEC_merkle_row.md §3) and anchors the root under category="commitment". Call this txid T_root.
  5. 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 the merkle-row-sealed-v1 HKDF 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

Questions about this specification? Email hello@satsignal.cloud.