Sealed mode — anchor a commitment whose hash itself stays private

Standard mode commits to naked hashes: anyone who independently has the file's hash can confirm an anchor exists for it (this is what makes standard proofs hash-discoverable via /lookup_hash). If the existence of the hash itself is sensitive — sealed-bid auctions, eval scoreboards, rate-card tables, yes/no surveys, low-entropy data the auditor could brute-force — use sealed mode. Each commitment is an HMAC under a 32-byte salt; the chain reveals only that some commitment was anchored at time T. This guide covers both mirror mode (salt sent on the wire; the notary holds the salt-bearing bundle on disk — until you delete the proof by default, or for an explicit retain window — and returns a download URL) and blind mode (the salt never crosses the wire and the notary holds nothing).

Companion docs: API reference · OpenAPI spec · What to hash · Sealed-mode threat model · merkle-row schemes · Production checklist · Compatibility map

1. The 60-second framing

A sealed anchor publishes a 32-byte HMAC — not a SHA-256 — to the chain. The HMAC is keyed by a 32-byte master salt that the holder generates and (in mirror mode) sends once, or (in blind mode) never sends. The on-chain commitment is a salt-keyed function of the underlying bytes; a third party with the bytes alone can't verify, can't enumerate, can't grind.

Two submission protocols, same anchor format:

Both produce bit-identical .mbnt bundles. The verifier accepts either transparently — bundle shape is independent of which assembly path ran.

2. Use this when

Don't use this when:

3. What you send

Mirror mode (salt sent on submission)

{
  "mode": "sealed",
  "folder_slug": "agent-runs-prod",
  "salt_b64": "<32-byte salt, base64url>",
  "byte_exact_commitment": "<HMAC-SHA256 of the canonical bytes>",
  "file_size": 208,
  "category": "policy_snapshot"
}
fieldtyperequiredmeaning
modestringyesexactly "sealed".
folder_slugstringyesfolder slug.
salt_b64stringmirror onlybase64url of 32 random bytes (the master salt).
byte_exact_commitmentstringyes64 lowercase hex chars = HMAC-SHA256(master_salt, canonical_bytes).
file_sizeintegeryesbyte length of the canonical artifact. Stays top-level; does NOT enter the on-chain envelope (sealed canonical docs strip leak fields).
categorystringyesone of the standard enum values.
retain_daysintegernoMirror only (salt-bearing). Omit it (the default) and the notary keeps the bundle until you delete the proof — retain_until comes back null. Send >= 1 for an explicit auto-delete window, honored as given on every plan. An explicit 0 with a salt is rejected — to retain nothing, use blind mode (omit salt_b64). The server-side mirror is a recovery convenience, at the cost of letting the notary hold a copy of your salt-bearing bundle while it is retained.

The notary refuses sha256 in a sealed body — sending it 400s before the wallet is touched (sending it is almost always a sign that the client built the body for the wrong mode). Algos in sealed bodies must be hmac-sha256 or merkle-hmac-sha256; plain sha256 is rejected at the same layer.

Blind mode (salt never sent)

{
  "mode": "sealed",
  "folder_slug": "agent-runs-prod",
  "byte_exact_commitment": "<HMAC-SHA256 of the canonical bytes>",
  "file_size": 208,
  "category": "policy_snapshot"
}

Same body as mirror, minus salt_b64. Its absence IS the blind signal — there's no separate mode: "sealed-blind" flag. retain_days must be 0 or absent (nothing to retain — the notary never holds the salt and so can't build a bundle).

Multi-proof / selective disclosure (proof_set)

A sealed anchor can carry the same proof_set envelope as standard mode, with HMAC algos inside. proof_set.byte_exact is required; its commitment MUST equal the top-level byte_exact_commitment.

{
  "mode": "sealed",
  "folder_slug": "agent-runs-prod",
  "salt_b64": "<32-byte salt, base64url>",
  "byte_exact_commitment": "<HMAC-SHA256(salt, file)>",
  "file_size": 208,
  "proof_set": {
    "byte_exact":        {"algo": "hmac-sha256",
                          "commitment": "<same HMAC as above>"},
    "content_canonical": {"algo": "hmac-sha256",
                          "scheme": "pdf-text-v1",
                          "commitment": "<HMAC of canonicalized text>"},
    "chunk_merkle":      {"algo": "merkle-hmac-sha256",
                          "scheme": "pdf-page-v1",
                          "leaf_count": 12,
                          "root": "<Merkle-HMAC root>"}
  },
  "proof_leaves": {"scheme": "pdf-page-v1",
                   "merkle_leaves": ["<leaf HMAC>", "..."],
                   "metadata": {"leaf_count": 12}}
}

Differences from standard-mode proof_set:

There is no force_new 409 in sealed mode — sealed entries never index a naked file hash, so there's no default-dedup gate to escape.

Response

Mirror mode (here with an explicit retain_days; with the field omitted — the default — retain_until is null and the bundle is kept until you delete the proof):

{
  "proof_id": "f83649e3846c4ea2",
  "txid": "2e042a64...7a3db61b",
  "mode": "sealed",
  "category": "commitment",
  "retain_until": "2026-07-06T00:00:00Z",
  "folder_slug": "agent-runs-prod",
  "proof_url": "https://app.satsignal.cloud/w/.../r/f83649e3846c4ea2",
  "bundle_url": "https://app.satsignal.cloud/api/v1/anchors/f83649e3846c4ea2/bundle"
}

Download the bundle from bundle_url and persist it yourself: with an explicit window the server-side copy is reaped after retain_until; with the indefinite default it disappears when the proof is deleted. Either way your local copy is the durable one. (The legacy bundle_b64 field is permanently null and is never populated; the inline-bundle mode it served was removed.)

Blind mode:

{
  "proof_id": "...",
  "txid": "...",
  "mode": "sealed",
  "category": "commitment",
  "folder_slug": "agent-runs-prod",
  "proof_url": "...",
  "canonical_b64": "<base64 of canonical.json bytes>",
  "doc_hash": "<40 hex>"
}

Your client assembles .mbnt locally — see §6.

4. What you store

The salt is the bearer secret. Lose it and the proof is verifiable in chain-existence only; you can never re-prove what you anchored.

Salt-loss policy

A sealed proof without its salt:

If salt loss is a real risk for your use case, use the server-side mirror — by default the notary keeps the salt-bearing bundle until you delete the proof, so the salt is recoverable from bundle_url for as long as the proof lives. If you chose an explicit retain_days window instead, the copy is reaped when the window lapses (see the 10-minute sealed-TTL sweep below).

5. What verification needs later

Four things, every time:

  1. The .mbnt bundle — carries the manifest (with salt_b64, salt_version, mode: "sealed"), the canonical doc (with HMAC commitments), and any proofs.json.
  2. The original artifact — the bytes whose HMAC was anchored.
  3. The 32-byte master salt — included in the manifest's salt_b64 field, so step 1 covers it. But the bundle itself IS the bearer secret here; treat the .mbnt accordingly.
  4. A BSV node — any public one.

The verifier:

  1. Decodes master_salt = base64url_decode(manifest.salt_b64).
  2. Recomputes HMAC-SHA256(master_salt, canonical_bytes), compares to subject.proofs.byte_exact.commitment.
  3. If content_canonical is present, re-canonicalizes per the scheme tag, recomputes the HMAC, compares.
  4. If chunk_merkle is present, derives per-leaf salts via HKDF (literal info string "satsignal-sealed-v1/per-leaf", info = b"chunk/" || u32_be(i)), computes leaf HMACs, builds the Merkle root, compares to chunk_merkle.root.
  5. Re-encodes canonical.json to SCJ-v1, sha256s, slices to 20 bytes, compares to manifest.doc_hash_expected.
  6. Resolves the txid, parses the OP_RETURN, compares the on-chain doc_hash to the canonical doc's hash.

Full per-leaf HKDF construction: see bundle-v1 spec §5.

6. Copy-paste example

Mirror mode (Python, stdlib only)

import base64, hashlib, hmac, json, os, secrets, urllib.request

API = "https://app.satsignal.cloud"
KEY = os.environ["SATSIGNAL_API_KEY"]
FOLDER = "agent-runs-prod"

# 1. Build canonical bytes from your artifact.
artifact = b'{"event":"order.completed","id":"evt_demo_123"}'
canonical = artifact  # or apply a canonicalizer; see What to hash.

# 2. Generate a 32-byte salt locally.
master_salt = secrets.token_bytes(32)

# 3. HMAC the canonical bytes under the salt.
mac = hmac.new(master_salt, canonical, hashlib.sha256).hexdigest()

# 4. Submit. Mirror mode: send salt_b64. Omitting retain_days keeps the
#    mirror until you delete the proof (the default); send retain_days=1
#    for minimal server-side exposure.
body = json.dumps({
    "mode": "sealed",
    "folder_slug": FOLDER,
    "salt_b64": base64.urlsafe_b64encode(master_salt).decode().rstrip("="),
    "byte_exact_commitment": mac,
    "file_size": len(canonical),
    "category": "commitment",
    "retain_days": 1,
}).encode("utf-8")

req = urllib.request.Request(
    f"{API}/api/v1/anchors",
    data=body, method="POST",
    headers={"Authorization": f"Bearer {KEY}",
             "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
    out = json.load(resp)

# 5. Download the bundle from bundle_url and persist it locally — this
#    example chose retain_days=1, so after retain_until the server-side
#    copy is gone.
dl = urllib.request.Request(
    out["bundle_url"], headers={"Authorization": f"Bearer {KEY}"})
with urllib.request.urlopen(dl) as bresp, open("proof.mbnt", "wb") as fh:
    fh.write(bresp.read())

print(out["proof_id"], out["txid"])

Blind mode (salt never crosses the wire)

import base64, hashlib, hmac, io, json, os, secrets, urllib.request
import zipfile

API = "https://app.satsignal.cloud"
KEY = os.environ["SATSIGNAL_API_KEY"]

artifact = b'{"event":"order.completed","id":"evt_demo_123"}'
canonical = artifact
master_salt = secrets.token_bytes(32)
mac = hmac.new(master_salt, canonical, hashlib.sha256).hexdigest()

# Submit WITHOUT salt_b64.
body = json.dumps({
    "mode": "sealed",
    "folder_slug": "agent-runs-prod",
    "byte_exact_commitment": mac,
    "file_size": len(canonical),
    "category": "commitment",
}).encode("utf-8")

req = urllib.request.Request(
    f"{API}/api/v1/anchors",
    data=body, method="POST",
    headers={"Authorization": f"Bearer {KEY}",
             "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
    out = json.load(resp)

# Notary returns canonical_b64 + doc_hash; assemble bundle locally.
canonical_json = base64.b64decode(out["canonical_b64"])
salt_b64 = base64.urlsafe_b64encode(master_salt).decode().rstrip("=")

manifest = {
    "mbnt_version": "2.1",
    "mode": "sealed",
    "txid": out["txid"],
    "network": "bsv-mainnet",
    "doc_hash_expected": out["doc_hash"],
    "salt_version": "salt_v1",
    "salt_b64": salt_b64,
    "bearer_secret": True,
}

with zipfile.ZipFile("proof.mbnt", "w", zipfile.ZIP_DEFLATED) as z:
    z.writestr("manifest.json", json.dumps(manifest))
    z.writestr("canonical.json", canonical_json)

print(out["proof_id"], out["txid"])

The bundle is bit-identical-in-shape to a mirror-mode bundle. Verifiers don't know (and don't need to know) which assembly path produced it.

Bundle byte-canonicalization. Both manifest.json and proofs.json SHOULD be SCJ-v1-compact (sorted keys, no whitespace, NFC, UTF-8 — deliberately NOT RFC 8785/JCS; see /spec-provenance §3). Use json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") in Python; the browser path uses the same shape (see templates.py:compactJson). The simpler json.dumps(obj) in the example above happens to parse correctly but is NOT canonical bytes — fine for a verifier that parses then re-checks, fragile for any byte-level comparison. canonical.json MUST be the server-returned bytes verbatim; it was SCJ-v1-canonicalized server-side before the doc_hash was sliced.

Blind mode with multi-proof + selective disclosure

A blind anchor with byte_exact plus chunk_merkle (4 chunks of 16 bytes, one of the registered schemes — text-line-v1 here) is the same flow plus a per-leaf HMAC derivation and a Merkle root. The bearer can later strip proofs.json to selectively disclose only byte_exact while keeping the chunk leaves private; the canonical doc, the on-chain commit, and the byte_exact proof all still verify against the redacted bundle.

import base64, hashlib, hmac, json, os, secrets, struct, urllib.request, zipfile

API = "https://app.satsignal.cloud"
KEY = os.environ["SATSIGNAL_API_KEY"]
FOLDER = "agent-runs-prod"
SCHEME = "text-line-v1"  # one of the registered tier-1 schemes
CHUNK_SIZE = 16

# 1. Artifact + master salt, both client-side only.
artifact = b"CHUNK-00-payloadCHUNK-01-payloadCHUNK-02-payloadCHUNK-03-payload"
assert len(artifact) % CHUNK_SIZE == 0
chunks = [artifact[i:i + CHUNK_SIZE]
          for i in range(0, len(artifact), CHUNK_SIZE)]
master_salt = secrets.token_bytes(32)

# 2. byte_exact: HMAC over the full canonical bytes.
be_commitment = hmac.new(master_salt, artifact, hashlib.sha256).hexdigest()

# 3. chunk_merkle: per-leaf salt via HKDF, HMAC each chunk.
#    info = b"chunk/" || u32_be(i). salt context label is a literal:
#    b"satsignal-sealed-v1/per-leaf".
def hkdf_sha256(ikm, salt, info, length=32):
    prk = hmac.new(salt, ikm, hashlib.sha256).digest()
    return hmac.new(prk, info + b"\x01", hashlib.sha256).digest()[:length]

leaves_hex = []
for i, chunk in enumerate(chunks):
    info = b"chunk/" + struct.pack(">I", i)
    salt_i = hkdf_sha256(master_salt, b"satsignal-sealed-v1/per-leaf", info)
    leaves_hex.append(hmac.new(salt_i, chunk, hashlib.sha256).hexdigest())

# 4. Merkle root: plain sha256 binary tree, duplicate-last on odd,
#    single-leaf re-hashes with itself.
def merkle_root_sha256(leaves):
    if len(leaves) == 1:
        return hashlib.sha256(bytes.fromhex(leaves[0]) * 2).hexdigest()
    nodes = [bytes.fromhex(l) for l in leaves]
    while len(nodes) > 1:
        if len(nodes) % 2 == 1:
            nodes.append(nodes[-1])
        nodes = [hashlib.sha256(nodes[i] + nodes[i + 1]).digest()
                 for i in range(0, len(nodes), 2)]
    return nodes[0].hex()

root_hex = merkle_root_sha256(leaves_hex)

# 5. Submit. Blind: no salt_b64. Multi-proof: proof_set + proof_leaves.
body = json.dumps({
    "mode": "sealed",
    "folder_slug": FOLDER,
    "byte_exact_commitment": be_commitment,
    "file_size": len(artifact),
    "category": "commitment",
    "proof_set": {
        "byte_exact": {"algo": "hmac-sha256", "commitment": be_commitment},
        "chunk_merkle": {
            "scheme": SCHEME,
            "algo": "merkle-hmac-sha256",
            "leaf_count": len(leaves_hex),
            "root": root_hex,
        },
    },
    "proof_leaves": {
        "scheme": SCHEME,
        "merkle_leaves": leaves_hex,
        "metadata": {"leaf_count": len(leaves_hex)},
    },
}).encode("utf-8")
req = urllib.request.Request(
    f"{API}/api/v1/anchors", data=body, method="POST",
    headers={"Authorization": f"Bearer {KEY}",
             "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
    out = json.load(resp)

# 6. Assemble the full .mbnt locally — SCJ-v1-compact for manifest + proofs.
def compact_json(obj):
    return json.dumps(obj, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode("utf-8")

manifest = {
    "mbnt_version": "2.1",
    "mode": "sealed",
    "txid": out["txid"],
    "network": "bsv-mainnet",
    "doc_hash_expected": out["doc_hash"],
    "salt_version": "salt_v1",
    "salt_b64": base64.urlsafe_b64encode(master_salt).rstrip(b"=").decode(),
    "bearer_secret": True,
}
proofs = {
    "scheme": SCHEME,
    "salt_version": "salt_v1",
    "merkle_leaves": leaves_hex,
    "metadata": {"leaf_count": len(leaves_hex)},
}

with zipfile.ZipFile("proof-full.mbnt", "w", zipfile.ZIP_DEFLATED) as z:
    z.writestr("manifest.json", compact_json(manifest))
    z.writestr("canonical.json", base64.b64decode(out["canonical_b64"]))
    z.writestr("proofs.json", compact_json(proofs))

# 7. Selective-disclosure variant: same bundle, proofs.json stripped.
#    byte_exact still verifies (salt + commitment + artifact all there).
#    chunk_merkle's root remains anchored on-chain via the unchanged
#    canonical doc, but a verifier without proofs.json cannot recompute
#    the root — the chunk leaves are withheld.
with zipfile.ZipFile("proof-redacted.mbnt", "w", zipfile.ZIP_DEFLATED) as z:
    z.writestr("manifest.json", compact_json(manifest))
    z.writestr("canonical.json", base64.b64decode(out["canonical_b64"]))
    # NO proofs.json

print(out["proof_id"], out["txid"])

Both proof-full.mbnt and proof-redacted.mbnt are valid .mbnt files against the same on-chain anchor. The full bundle lets a verifier check both proofs; the redacted bundle lets them check byte_exact while honestly reporting chunk_merkle: leaves not disclosed. The canonical doc and on-chain commit are unchanged between the two — selective disclosure is a property of the bundle, not of the anchor.

Selective-disclosure / sealed rows (low-entropy data)

For tabular data where each row is low-entropy, use merkle-row-sealed-v1 (a scheme on top of chunk_merkle in sealed mode). The driver tool is at satsignal.cloud/merkle_row.py:

export SATSIGNAL_API_KEY=sk_...
curl -O https://satsignal.cloud/merkle_row.py

# 1. Seal the table.
python3 merkle_row.py build-sealed \
  --rows-jsonl bids.jsonl \
  --out-commit-doc commit_doc.json \
  --out-holder-state holder_state.json

# 2. Anchor the commit doc.
curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
     -H "Content-Type: application/json" \
     -d @commit_doc.json \
     https://app.satsignal.cloud/api/v1/anchors

# 3. Later, reveal exactly one row.
python3 merkle_row.py reveal \
  --holder-state holder_state.json \
  --leaf-index 2 \
  --out reveal-row-2.json

# 4. The auditor verifies with stdlib only.
python3 merkle_row.py verify-sealed \
  --reveal reveal-row-2.json \
  --commit-doc commit_doc.json

Each leaf is HMAC-SHA256(salt_i, canonical_row_i) with a per- leaf salt derived from the master salt via HKDF. Disclosing salt_2 leaks no information about salt_0, salt_1, salt_3, .... The auditor can't enumerate the table — sibling commitments inside the inclusion path are 32-byte HMACs under unrevealed salts.

Full byte-level scheme — canonicalization, HKDF info string, leaf encoding, tree shape, reveal payloads, registered scheme strings — is the implementer spec at /spec-merkle-row.

7. Production notes

The 10-minute sealed-TTL sweep

The notary runs a background sweep every ~10 minutes. Bundles held under an explicit retain_days window are reaped at the second of server_retain_until_utc. Past that second the server-side copy is gone — your client-side copy is the only remaining one. Bundles anchored with retain_days omitted (the default) are never swept: their manifest carries server_retention: "indefinite" and they stay re-fetchable from bundle_url until you delete the proof.

If you chose an explicit window, plan for the sweep at integration time — it is a bounded recovery window, not storage.

Post-TTL graceful degradation

After the sweep:

Idempotency

Sealed bodies don't dedup on the file hash (there is no naked hash — only an HMAC under a fresh salt). Replay-protection works via Idempotency-Key like any other anchor. A retry with the same Idempotency-Key and the same body returns the original anchor; a retry with the same key and a different body returns 409 idempotency_key_reuse_body_mismatch.

Mirror vs blind trade-off

propertymirrorblind
salt-on-wireyes (TLS-protected)no
server-side bundle (while retained)availableimpossible
client-side assemblynot requiredrequired
trust assumption on notarynotary sees salt in flightnotary never sees salt
operational simplicityhigher (notary returns bundle)lower (client builds bundle)

Pick mirror unless your threat model specifically excludes "the notary's process memory could leak the salt in flight". Blind is the right answer for ultra-sensitive workflows (regulatory compliance, true sealed-bid markets) where minimizing trust is the explicit goal.

Key rotation

salt_version is the rotation knob. Currently always salt_v1. A future salt_v2 will introduce a new HKDF derivation; the notary will accept both during the rotation window, and bundles will carry which version was used. Existing proofs verify indefinitely under whatever version they were anchored at.

Rate limits & quota

Same as standard mode. See Files §7. Sealed anchors count against the same monthly anchor quota.

For the full pre-flight checklist (key rotation, broadcast failure recovery, support flow), see Production checklist.

8. Errors you might see

codenamemeaningwhat to do
400sealed_requires_commitmentsent sha256 instead of byte_exact_commitmentuse the commitment field; algos must be HMAC
400sealed_disallows_sha256sent sha256 in a sealed bodystrip the field
400bad_salt_b64salt_b64 isn't valid base64url, or decodes to ≠ 32 bytesregenerate the salt
400proof_leaves_orphanproof_leaves present without proof_set.chunk_merklealways pair them
400proof_set_byte_exact_mismatchproof_set.byte_exact.commitment ≠ top-level byte_exact_commitmentalign them
400retain_days_blind_must_be_zeroblind submission with retain_days > 0drop retain_days or set to 0
400retain_days: 0 requires blind mode — omit salt_b64mirror submission (salt-bearing) with an explicit retain_days == 0to keep nothing, go blind (omit salt_b64); to mirror, send retain_days >= 1 or omit it
404folder_not_foundfolder doesn't existcreate it
410sealed_retain_expiredbundle_url requested after the retain windowuse your local copy
429quota_exceededmonthly anchor count exhaustedback off; email hello@satsignal.cloud for a cap lift

9. Legacy field aliases (if your code sends them)

Same as all other paths: responses emit the canonical proof / folder names only; requests written against the legacy spellings keep working forever.

Full canonical/legacy mapping across endpoints, fields, scopes, CLI flags, and error codes: Compatibility map.

10. Where this fits

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