Headless anchor — seal a deep-content-hashed .txt, no upload, no browser
Satsignal's
text-tree-v1profile commits one merkle leaf per node of a.txtfile — the whole file, every paragraph, every sentence, every token — so a single sealed anchor can later disclose a lone token, a whole sentence, a paragraph, or the file, each proving into the one on-chain root. This guide anchors such a file headlessly (plain Node, no browser): you generate a master salt, build the sealed envelope with the frozen client-side leaf computer, and POST it. The deep hash is computed entirely on your machine — the notary never sees the raw text and never recomputes a hash.text-tree-v1is sealed-only by construction; there is no standard path, and this guide explains why.
Companion docs: API reference · Sealed mode · text-tree-v1 profile · Disclosure spec · Bundle spec · Headless redaction · What to hash
1. The 60-second framing
You have a plain-text file and you want multi-tier selective disclosure later: the ability to reveal one sentence, or one token, or one paragraph from it — each provable against the same on-chain anchor, with everything else staying private and unguessable. That needs a deep anchor: one merkle leaf per decomposition node, not one per line. text-tree-v1 is that scheme.
The whole hashing happens client-side:
(file bytes) + (32-byte master salt) ──► sealed envelope ──► POST /api/v1/anchors
local local (yours) local notary anchors the root
The notary receives the envelope — a per-node merkle root plus the off-chain leaf HMACs — and the master-salt-derived commitments. It never receives the raw file bytes and never recomputes a hash: it rides the client-supplied chunk_merkle.root verbatim, assembles the canonical source .mbnt, and broadcasts. You are the only party that ever holds the text and the salt.
This is the anchor half of the deep-content lifecycle. The disclosure half — turning the anchored file + its .mbnt into a redacted copy that reveals chosen nodes — is Headless redaction; the profile spec is the byte-level canon for both.
2. Why sealed-only (and what a standard carrier does)
text-tree-v1 is accepted only in sealed mode (chunk_merkle.algo == "merkle-hmac-sha256", salt_version: "salt_v1"). A standard (algo: "sha256") text-tree-v1 carrier is rejected at submit with scheme_requires_sealed.
The reason is entropy. At token granularity almost every leaf is a single low-entropy unit — a digit, a name, a word, a punctuation mark (yes, $, 12, go). A bare sha256 of "<path>":"<token>" published in the bundle is trivially guess-and-confirmed offline from a tiny candidate set, which would unseal every withheld node. So text-tree-v1 has no unsalted mode at all: every leaf is an HMAC under a per-leaf HKDF salt derived from your master salt, which makes withheld nodes unguessable and stops equal withheld values from colliding. This is the same rationale sealed mode documents generally — see Sealed mode §2 — taken to its conclusion: for this profile, privacy is the only path.
3. Prerequisites
- Node 18 or newer. The leaf computer uses only platform globals Node 18+ provides natively —
crypto.subtle(WebCrypto, for HKDF + HMAC + sha256),TextEncoder,atob/btoa. No npm install, no bundler, no DOM. Run files as ES modules (.mjs, or"type": "module"). - The anchor-pack helper.
buildSealedTextTreeEnvelope(for.txt) and its siblingbuildSealedJsonAstEnvelope(for.json, §4b) live inanchor-pack.mjs, along with thebuildSealedEnvelope({ ..., granularity })dispatcher. Each wraps the frozen leaf computer for its profile (the same pure module the redaction tool and the/verifypage use) so you do not hand-write the decompose → HKDF → HMAC → merkle glue. All cryptography lives in the disclosure-builder modules it imports; the helper adds only the envelope assembly.
> Availability: anchor-pack.mjs is served publicly (no > auth, no key, no repo access) from > https://proof.satsignal.cloud/static/disclosure-builder/anchor-pack.mjs. > It is still not on npm (§6b). Its imports are relative (./), > so fetch it together with its six transitive imports into one > directory and every import resolves — the same pattern §2 of the > headless redaction guide > uses. The complete set the recipe below needs is these 7:
`` https://proof.satsignal.cloud/static/disclosure-builder/anchor-pack.mjs https://proof.satsignal.cloud/static/disclosure-builder/text-line-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/text-tree-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/json-ast-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/json-keypath-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/merkle.mjs https://proof.satsignal.cloud/static/disclosure-builder/hex.mjs ``
With the set fetched into one directory, the §4 snippets import ./anchor-pack.mjs. Working from a repo checkout instead, import ./packages/disclosure-redact/anchor-pack.mjs — a thin re-export of the same single source (the module body lives in src/satsignal_notary/web/static/disclosure-builder/, which is the directory the URLs above serve).
- API credentials + a funded workspace for the actual POST. The on-chain anchor spends sats and needs a
SATSIGNAL_API_KEY. The POST in §5 is therefore shown but not exercised in tests — the envelope build (§4) is pure and self-checked; the broadcast is a live operator gesture.
4. Build the sealed envelope
4.1 Generate the master salt — the bearer secret
The master salt is 32 random bytes you generate and keep. It is the bearer secret of the whole anchor:
- Anyone with the salt (plus the file) can recompute every leaf and produce disclosures.
- Losing it does not destroy the chain record — the txid still resolves and the on-chain root is still anchored — but it makes the anchor unverifiable-against-content: you can never again prove what a given node was, and you can never mint a disclosure. The nodes stay private and unprovable. That is the deliberate sealed trade-off; treat the salt exactly as Sealed mode §4 prescribes (never log it, never commit it, never paste it into a ticket or screenshot), and back it up under your bearer-credential practice.
// 32 cryptographically-random bytes — the master salt you keep forever.
const masterSaltBytes = crypto.getRandomValues(new Uint8Array(32));
4.2 Call buildSealedTextTreeEnvelope
One call does the whole client-side deep hash — decompose the canonical text into the file / paragraph / sentence / token tree, derive a per-leaf HKDF salt for every node, HMAC each leaf, and build the duplicate-last merkle root:
// anchor.mjs — node anchor.mjs mydoc.txt
// (fetched-module layout per §3; from a repo checkout import
// ./packages/disclosure-redact/anchor-pack.mjs instead)
import { readFileSync } from "node:fs";
import { buildSealedTextTreeEnvelope }
from "./anchor-pack.mjs";
const fileBytes = new Uint8Array(readFileSync(process.argv[2]));
const masterSaltBytes = crypto.getRandomValues(new Uint8Array(32));
const envelope = await buildSealedTextTreeEnvelope({
fileBytes,
masterSaltBytes,
});
It returns the sealed envelope, exactly:
{
mode: "sealed",
salt_b64, // base64url of the 32-byte master salt
byte_exact_commitment, // HMAC of the canonical file bytes
proof_set: {
byte_exact, // { algo:"hmac-sha256", commitment }
content_canonical, // canonical-text HMAC for this profile
chunk_merkle, // { algo:"merkle-hmac-sha256",
// scheme:"text-tree-v1", leaf_count, root }
},
proof_leaves: {
scheme: "text-tree-v1",
merkle_leaves, // the per-node leaf HMACs (ride OFF-chain)
},
}
Every leaf — one per file / paragraph / sentence / token node — is computed here, on your machine, by the frozen text-tree-v1 leaf computer. The decomposition rules (NFC + line-ending canon, paragraph / sentence / token boundaries, the frozen tokenizer, the "<path>":"<value>" entry preimage, the per-leaf HKDF salt) are the protocol surface and are pinned byte-for-byte in the profile spec §§2–5b. Field-level shape of each envelope member is the helper's JSDoc plus that spec — this guide intentionally does not restate them.
proof_leavesrides off-chain. The leaf HMACs travel in the bundle'sproofs.json, never on chain — onlychunk_merkle.rootis committed. That is what makes selective disclosure the holder's later decision: the notary structurally cannot recompute a leaf (it has neither the bytes nor the salt), so it cannot expand the tree. See Sealed mode §3.
4b. The same flow for JSON — json-ast-v1
A .json file gets the deep equivalent of text-tree-v1: json-ast-v1 commits one merkle leaf per node of the parsed document — the whole document (RFC-6901 pointer ""), every object, every array, every key, every scalar — so one sealed anchor can later disclose a single field, a sub-object, or the whole document, each proving into the same on-chain root. Like text-tree-v1 it is sealed-only by construction (low-entropy scalar leaves would be guessable as bare sha256).
The build is the mirror of §4 — same master salt, the sibling buildSealedJsonAstEnvelope helper, same returned envelope shape:
// anchor-json.mjs — node anchor-json.mjs config.json
// (fetched-module layout per §3; from a repo checkout import
// ./packages/disclosure-redact/anchor-pack.mjs instead)
import { readFileSync } from "node:fs";
import { buildSealedJsonAstEnvelope }
from "./anchor-pack.mjs";
const fileBytes = new Uint8Array(readFileSync(process.argv[2]));
const masterSaltBytes = crypto.getRandomValues(new Uint8Array(32));
const envelope = await buildSealedJsonAstEnvelope({
fileBytes,
masterSaltBytes,
});
It returns the identical envelope shape as §4.2 — mode: "sealed", salt_b64, byte_exact_commitment, proof_set (byte_exact, content_canonical, chunk_merkle), and proof_leaves. Only two fields differ by profile:
| field | text-tree-v1 (§4) | json-ast-v1 |
|---|---|---|
proof_set.chunk_merkle.scheme | text-tree-v1 | json-ast-v1 |
proof_set.content_canonical.scheme | text-norm-v1 | json-jcs-v1 |
proof_leaves.scheme | text-tree-v1 | json-ast-v1 |
The content_canonical commitment for JSON is the HMAC of the JCS-canonical form of the parsed document (JSON.parse then the same jcsCanonicalize the disclosure builder uses) — so re-serialized whitespace / key order never changes the canonical commitment. The byte_exact_commitment is still the HMAC of the raw file bytes. Everything else — the per-leaf HKDF salt, the duplicate-last merkle root, the off-chain merkle_leaves — is computed by the frozen json-ast-v1 leaf computer exactly as text-tree-v1 is, and the POST in §5 is byte-for-byte the same for both schemes. A .json file anchored this way is redacted later with the json-ast-v1 worked example in Headless redaction.
One dispatcher.
anchor-pack.mjsalso exportsbuildSealedEnvelope({ fileBytes, masterSaltBytes, granularity })wheregranularity: "tree"routes tobuildSealedTextTreeEnvelopeand"ast"tobuildSealedJsonAstEnvelope. That is what the packaged CLI (§9) calls, auto-selecting the granularity from the file extension.
5. POST the envelope (sealed mode)
Add the destination folder and POST. Mirror mode (salt on the wire, notary retains the bundle until you delete the proof, or for an explicit retain_days window if you set one) is shown; for blind mode omit salt_b64 and assemble the .mbnt locally exactly as Sealed mode §6 describes — the envelope above is unchanged either way.
// ...continues anchor.mjs — requires SATSIGNAL_API_KEY + spends sats,
// so this is shown but NOT run in tests.
const body = JSON.stringify({
...envelope,
folder_slug: "agent-runs-prod", // your destination folder
category: "policy_snapshot",
file_size: fileBytes.length,
});
const resp = await fetch("https://app.satsignal.cloud/api/v1/anchors", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SATSIGNAL_API_KEY}`,
"Content-Type": "application/json",
},
body,
});
const out = await resp.json();
console.log(out.proof_id, out.txid);
What the server does with the envelope:
- Validates the sealed body (HMAC algos only; a bare
sha256400s, and atext-tree-v1carrier withalgo:"sha256"is rejectedscheme_requires_sealed). - Rides
chunk_merkle.rootverbatim — it does NOT recompute any leaf or root from content. It has no content and no master salt to do so with. - Anchors the canonical-doc hash on chain, assembles the source
.mbnt(manifest withsalt_b64+salt_version, canonical doc,proofs.jsoncarryingmerkle_leaves), and broadcasts. - Returns
proof_id/txidand (mirror mode) abundle_urlto download the salt-bearing.mbnt(kept until you delete the proof, or until any explicitretain_dayswindow you set lapses). Persist that bundle — it is what the redaction tool reads later.
What the server sees / does NOT see
| the notary sees | the notary never sees |
|---|---|
chunk_merkle.root (the per-node merkle root) | the raw .txt bytes — no upload, ever |
byte_exact_commitment + the per-leaf HMACs | any plaintext token, sentence, paragraph |
salt_b64 only in mirror mode (blind: never) | the master salt as a hashing input — it never recomputes a leaf |
file_size, category, the folder slug | a decomposed node, a path, or a leaf preimage |
The notary is a broadcaster of a client-computed commitment, not a content processor. It cannot expand the tree, cannot enumerate nodes, and cannot brute-force a withheld leaf — it never holds the two inputs (bytes + salt) that the deep hash needs. This is the whole point of deferring all hashing to the client.
6. What you store
- The 32-byte master salt — the bearer secret (§4.1). Lose it and the anchor is content-unverifiable forever.
- The source
.mbnt— download it frombundle_url(mirror — stored until you delete the proof, or until an explicitretain_dayswindow lapses) or assemble it locally (blind). It carriessalt_b64, the canonical doc, andproofs.json(the leaf HMACs). The redaction tool reads the master salt out of this bundle. - The original
.txt— sealed mode hides the hash, not the bytes; redaction (and any re-verification) recomputes leaves from the original file, so keep it. proof_id+txid— your stable handles.
Retention, the 10-minute sealed-TTL sweep, mirror-vs-blind trade-offs, and salt-loss policy are all the standard sealed-mode rules — Sealed mode §§4–7.
6b. Packaged form — the satsignal-anchor CLI
Status: not yet published.
satsignal-disclosure-redact/satsignal-anchorare not on npm yet —npm installwill 404. Until they ship, use the in-repo helper path in §4–§6 (the no-package recipe). The SDK/CLI shapes shown below are stable and forward-compatible. Note the reference package's option / flag / output names (matterSlug,--matter,bundle_id) predate the canonicalproof/foldervocabulary and keep the legacy spellings — see the Compatibility map.
Everything in §4–§6 — generate the master salt, build the sealed envelope, POST it, retrieve and write the source .mbnt — is also wrapped in a small reference package, so you don't hand-write the build-POST-retrieve-verify glue. It is the same pure modules this guide uses: no new cryptography, no extra upload. It is the symmetric twin of the satsignal-redact SDK/CLI (Headless redaction §11) — anchor then redact is the two-command deep-content lifecycle.
JS API. One async call does the whole §4–§6 flow — read the file, generate the salt, build the envelope, POST /api/v1/anchors, write the source .mbnt, and self-verify it:
import { anchorToMbnt } from "satsignal-disclosure-redact";
const out = await anchorToMbnt("mydoc.txt", {
apiKey: process.env.SATSIGNAL_API_KEY,
matterSlug: "agent-runs-prod",
// granularity: "tree" | "ast", // default from extension (.txt/.json)
// storage: "mirror" | "blind", // default "mirror"
// base, createMatter, category, outDir, dryRun, selfVerify
});
// out.txid, out.bundleId, out.root, out.leafCount, out.scheme,
// out.sourceMbntPath, out.masterSaltHex,
// out.verify === { ok: true, fail_code: null, ... }
fetchImpl and randomBytes are injectable for testing (no real network, no spend). dryRun: true returns { body, root, leafCount, scheme, masterSaltHex, dryRun: true } with no network call and no file written.
CLI. One command for both file types — the granularity is auto-selected from the extension (.txt → text-tree-v1, .json → json-ast-v1):
export SATSIGNAL_API_KEY=... # the key is env-only, never a flag
satsignal-anchor mydoc.txt --matter agent-runs-prod # -> text-tree-v1
satsignal-anchor config.json --matter agent-runs-prod # -> json-ast-v1
satsignal-anchor <file> [--granularity tree|ast] [--matter SLUG] [--create-matter] \
[--storage mirror|blind] [--category CAT] [-o DIR] [--base URL] [--dry-run]
It writes <file>.source.mbnt (the full input filename + .source.mbnt, e.g. mydoc.txt.source.mbnt, so same-stem .txt/.json inputs don't collide), self-verifies the served bundle (the served carrier root equals the locally built root, and a fresh recompute from your original bytes + the manifest salt equals the on-chain root), and prints the txid, bundle_id, root, leaf_count, the .mbnt path, a one-line bearer-secret reminder, and the ready next step — satsignal-redact <file> <source.mbnt> --list — so you can pick reveal indices and disclose. A failed self-verify exits non-zero.
--storage mirror(default) sendssalt_b64so the server disk-mirrors the source.mbnt;--storage blindomits it (the master salt never leaves the machine — assemble/keep the.mbntlocally per Sealed mode §6).--dry-runbuilds the envelope and prints the scheme, leaf_count, root, and the POST body withsalt_b64redacted — no network, no spend, no file written. Use it to inspect exactly what would be sent.- The API key is read from
$SATSIGNAL_API_KEYonly, never a flag; a missing key on a non---dry-runrun errors and exits. - The master salt is never printed and never written to a standalone file — it rides only inside the source
.mbntmanifest (mirror mode), the same bearer secret §4.1 and §6 describe. Back the.mbntup.
7. What this does NOT do
- It does not disclose. This guide only anchors. To produce a redacted copy + a disclosure
.mbntthat reveals chosen nodes, see Headless redaction (it has atext-tree-v1worked example). - It does not upload content. No raw bytes leave the machine; the notary receives only the envelope.
- It does not work in standard mode. Both deep profiles are sealed-only: a standard
text-tree-v1orjson-ast-v1carrier is rejected at submit (scheme_requires_sealed, §2 / §4b). - It does not let the server recompute anything. The root rides verbatim; leaf recompute structurally cannot happen at the notary's edge — the leaves never reach it.
8. Where this fits
- The byte-level decomposition, frozen tokenizer, sealed leaf rule, and worked example (the canonical
Hi there. Don't go.\n\nBye-bye!vectors): text-tree-v1 profile. The JSON equivalent (node decomposition, RFC-6901 pointers, the JCS-canonicalcontent_canonical): thejson-ast-v1profile spec linked from the Bundle spec. - The sealed-mode threat model, retention, mirror vs blind: Sealed mode.
- The disclosure block + verification contract: Disclosure spec · Bundle spec.
- Revealing nodes from this anchor, headless: Headless redaction.
- Choosing canonical bytes for an artifact: What to hash.
Questions about this specification? Email hello@satsignal.cloud.