SPEC v2.1 — Sealed Mode

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. UX, hosting, and pricing are intentionally out-of-scope.

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 (per the segmentation discussion 2026-05-03):


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 receipt 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 receipt from a public receipt 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-...",
  "fee_sats": 25,
  "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 receipt 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, receipt page) MUST display a prominent warning on bundles flagged this way.

4.2 canonical.json

{
  "schema_version": "2.1",
  "issuer": "did:web:proof.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>"
      }
    }
  }
}

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


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 (per design decision D6, 2026-05-03). 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)

For mirror submissions (the salt-bearing flow), sealed bundles are auto-deleted from server storage after a TTL (default 7 days; configurable per-submission via a retain_days form field, max 30) — per design decision D7. This minimizes server-side blast radius: an operator-side compromise leaks at most the recently-submitted sealed bundles, not the historical archive.

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 /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 (operator storage cost is bounded by the simpler manifest-only payload, and public bundles are explicitly 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 receipts (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.

7.3 Receipt page exposure (sealed mode)

This section applies to mirror submissions only. Blind submissions (§11) do not produce a /receipt/<bundle_id> page on the notary host: there is no server-side bundle and no sidecar to render from.

The public receipt page at /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 (host-level)

Sealed mode is served on sealed.satsignal.cloud — a separate Caddy vhost from the naked-hash flow at proof.satsignal.cloud. This is a hard isolation:

This eliminates "wrong-mode-by-toggle" failure modes where a user might accidentally submit sensitive material in public mode.


8. Backwards compatibility


9. Versioning


10. Out-of-scope (future revisions)


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",
  "matter_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.3 Response

{
  "bundle_id": "f83649e3846c4ea2",
  "txid": "2e042a64...7a3db61b",
  "mode": "sealed",
  "category": "policy_snapshot",
  "matter_slug": "agent-runs-prod",
  "receipt_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 receipt 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 during retention (§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 the cost of an extra anchor is worth it. 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

Source: docs/notary_spec/SPEC_v2_sealed.md. Email hello@satsignal.cloud for clarifications.