Skip to content

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 → OpenTimestamps

Stage 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 ​

FieldValue
content_sha256SHA-256 of raw file bytes
datetimeFile 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):

  1. Build a JSON object with four fixed keys: content_sha256, datetime, did, path
  2. Serialize with sorted keys (Go's json.Marshal on a map sorts keys lexicographically — effectively RFC 8785 JCS for our flat structure)
  3. SHA-256 the resulting bytes → leaf hash
json
{
  "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, null DID 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  L3

For an odd number of leaves, the last leaf is duplicated:

         root
        /    \
      n01    n22
      / \    / \
    L0  L1  L2  L2  ← L2 duplicated

Tree 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:

  1. POSTs the 32-byte hash to multiple Bitcoin calendar servers (Alice, Bob, Finney)
  2. Receives a commitment that will be included in an upcoming Bitcoin block
  3. Writes a binary .ots receipt 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:

bash
ots upgrade root.hash.ots   # poll calendars for Bitcoin confirmation
ots verify  root.hash.ots   # verify the hash is in the Bitcoin chain

After 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:

json
{
  "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:

  1. SHA-256 the file → compare to content_sha256
  2. Canonicalize the JSON and SHA-256 it → compare to leaf_hash
  3. Walk the Merkle proof from leaf_hash up to root → compare to root.hash
  4. 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 ​

PropertyHow achieved
Tamper-evidenceAny byte change in a file changes its content_sha256, invalidating its leaf hash and the root
Non-repudiationBitcoin block timestamp is immutable; the root hash is anchored to a specific block
Selective disclosureMerkle proofs reveal one leaf without exposing siblings
No trusted partyBitcoin consensus is the only trust anchor; OTS calendar servers are not trusted after verification
DeterminismSorted 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.jsonl is currently manual. A future simple-ots prove subcommand is planned.

Released under the Apache License 2.0.