How It Works
Overview
simple-ots turns a set of files into a single 32-byte root hash and anchors it to the Bitcoin blockchain. The pipeline has three stages:
files → leaf hashes → Merkle root → OpenTimestampsStage 1: Leaf hash generation
For each file, simple-ots generates multiple leaf hashes — one per combination of path variant and DID. This is what enables selective disclosure.
File metadata extracted
| Field | Value |
|---|---|
content_sha256 | SHA-256 of raw file bytes |
datetime | File mtime, ISO 8601 UTC (e.g. 2026-06-26T12:00:00Z) |
Combinations generated per file
For each path_variant × each DID (including null DID if include_no_did = true):
- Build a JSON object with four fixed keys:
content_sha256,datetime,did,path - Serialize with sorted keys (Go's
json.Marshalon a map sorts keys lexicographically — effectively RFC 8785 JCS for our flat structure) - SHA-256 the resulting bytes → leaf hash
{
"content_sha256": "e3b0c44298fc1c149afbf4c8996fb924...",
"datetime": "2026-06-26T12:00:00Z",
"did": "did:web:alice.example.com",
"path": "src/main.go"
}Keys are always alphabetical: content_sha256, datetime, did, path. Null values are present as JSON null, never omitted. This makes the hash fully reproducible on any platform with any JSON library.
Why multiple leaves per file?
Selective disclosure without zero-knowledge proofs. If you later want to prove that src/main.go existed at timestamp T, you pick exactly one leaf and its Merkle proof:
- Want to reveal filename but not directory? Use the
"filename"path variant leaf. - Want to reveal your identity? Use the leaf that has your DID.
- Want to reveal nothing but content? Use the
"null"path,nullDID leaf.
The verifier sees only what you give them. All other combinations in the tree are invisible.
Stage 2: Merkle tree
All leaf hashes are sorted by their natural ordering (leaves are already deterministic; files are sorted alphabetically before processing), then combined into a binary Merkle tree using SHA-256 as the hash function.
root
/ \
n01 n23
/ \ / \
L0 L1 L2 L3For an odd number of leaves, the last leaf is duplicated:
root
/ \
n01 n22
/ \ / \
L0 L1 L2 L2 ← L2 duplicatedTree depth = ⌈log₂(N)⌉ leaves. For 60,000 leaves: depth ≈ 16.
Proof size for any single leaf: 16 hashes × 32 bytes = 512 bytes.
The root hash is always 32 bytes regardless of how many files or combinations are in the tree.
Determinism
The same inputs always produce the same root:
- Files are sorted alphabetically before processing
- Paths are normalized to forward slashes and
filepath.Clean'd - mtime is formatted as RFC 3339 UTC (second precision)
- JSON keys are always alphabetically ordered
- Null values are always included (never omitted)
You can rebuild the tree on a different machine and get the same root hash.
Stage 3: OpenTimestamps
The root hash is passed to ots stamp, which:
- POSTs the 32-byte hash to multiple Bitcoin calendar servers (Alice, Bob, Finney)
- Receives a commitment that will be included in an upcoming Bitcoin block
- Writes a binary
.otsreceipt file
The .ots file is initially pending — it contains a commitment to the calendar servers but not yet a confirmed Bitcoin block hash. After ~10 minutes (one block), the receipt can be upgraded:
ots upgrade root.hash.ots # poll calendars for Bitcoin confirmation
ots verify root.hash.ots # verify the hash is in the Bitcoin chainAfter upgrade, the .ots file contains a Merkle path from your root hash all the way to a Bitcoin block header — verifiable by anyone with a Bitcoin node, with no trusted third party.
Selective disclosure: end-to-end example
Suppose you timestamped 1,000 files. Six months later you want to prove that contracts/draft-v1.pdf existed at timestamp T, under your identity did:web:example.com, without revealing any other files.
What you share:
{
"file_path": "contracts/draft-v1.pdf",
"content_sha256": "3f4a...",
"datetime": "2026-06-26T12:00:00Z",
"did": "did:web:example.com",
"path": "contracts/draft-v1.pdf",
"path_variant": "relative",
"leaf_hash": "a1b2..."
}Plus: the file itself, the Merkle proof (≈16 sibling hashes), root.hash, and root.hash.ots.
What the verifier does:
- SHA-256 the file → compare to
content_sha256 - Canonicalize the JSON and SHA-256 it → compare to
leaf_hash - Walk the Merkle proof from
leaf_hashup to root → compare toroot.hash - Run
ots verify root.hash.ots→ confirms root hash is in Bitcoin block #N
What the verifier cannot see: every other file, every other DID combination, every other path variant. They get exactly what you gave them.
Security properties
| Property | How achieved |
|---|---|
| Tamper-evidence | Any byte change in a file changes its content_sha256, invalidating its leaf hash and the root |
| Non-repudiation | Bitcoin block timestamp is immutable; the root hash is anchored to a specific block |
| Selective disclosure | Merkle proofs reveal one leaf without exposing siblings |
| No trusted party | Bitcoin consensus is the only trust anchor; OTS calendar servers are not trusted after verification |
| Determinism | Sorted paths + canonical JSON + fixed mtime format = same root from same inputs, always |
What simple-ots does NOT do
- Sign — the root hash is anchored but not signed. Add a DID signature separately if authorship proof is required.
- Encrypt — file contents are never hidden; only path and identity metadata can be withheld via selective disclosure.
- Store files — outputs are hashes and receipts only. The files themselves must be kept separately for verification.
- Provide Merkle proofs automatically — proof extraction from
manifest.jsonlis currently manual. A futuresimple-ots provesubcommand is planned.