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):
- 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 receipt 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. - Never transmitted to the server in raw form. Only the resulting commitments cross the wire.
- 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 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):
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 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.
- 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 (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 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 retain-window indicator (e.g., "this server-side copy expires YYYY-MM-DD")
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
/receipt/<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 (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:
proof.satsignal.cloudrejects POSTs that carry sealed-mode fields (mode=sealed,salt_b64, etc.).sealed.satsignal.cloudrejects POSTs that carry naked-hash fields (sha256_hex,file_size).- Cross-mode submissions are rejected at the routing layer (4xx).
This eliminates "wrong-mode-by-toggle" failure modes where a user might accidentally submit sensitive material in public mode.
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.
10. Out-of-scope (future revisions)
- 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. Defer until a real customer asks.
- Multi-party sealed receipts (k-of-n disclosure). Useful for legal/compliance scenarios but adds significant scheme complexity.
- Server-side reveal endpoints. Per D6, unsealing is user-controlled and out-of-scope for the server.
- Asymmetric commitments (e.g., signed instead of HMAC'd). Useful for issuer-binding but a different product. Out-of-scope for the sealed envelope tier.
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:
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).- All other commitment + scheme validation is identical to §4–§5.
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:
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 withretain_days >= 1by default — humans need recovery, and the dashboard is the human surface. - 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 the root is on chain.
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:
- 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 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
- 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.
Source: docs/notary_spec/SPEC_v2_sealed.md.
Email hello@satsignal.cloud
for clarifications.