Search selfstore
v1.8.37

Dating a file without uploading it

A local-first app promises the file never leaves the device, and a timestamp service usually wants the file. A SHA-256 digest settles it - 69 bytes go out, a signed receipt comes back, and it lives next to the file in the user's own store.

Sooner or later a user needs to show that a file existed on a given day. An inventory before a move. An invoice before a dispute. A draft before someone else published the same idea. The usual answer is a service that receives the document and dates it.

A local-first app cannot take that answer. The whole point is that the bytes stay on the device, so a feature that ships them to a third party breaks the one promise the app makes. And “trust the device’s clock” is not an answer either: the user controls that clock, which is exactly why nobody else will believe it.

The way out is older than local-first. A notary does not need the document. It needs something that names the document and nothing else.

Send the digest, keep the file

A SHA-256 digest is 32 bytes. It names one sequence of bytes, it says nothing about what those bytes contain, and it cannot be turned back into them. Date the digest and you have dated the file, as long as the user still holds the file.

An RFC 3161 time-stamping authority works that way. The client sends a request carrying the digest. The authority signs the digest together with its own time, and sends back a token. Anyone holding the file, the token and the authority’s certificate can check the token later, with no account and no call back to the authority.

Since version 1.8.25, selfstore ships this as selfstore/proof:

import { prove, verify, rfc3161Notary, PROOFS } from 'selfstore/proof';

const notary = rfc3161Notary({ url: 'https://your-tsa.example/tsr' });
const file = await store.putFile({ bytes, name: 'inventory.pdf', mime: 'application/pdf' });

const proof = await prove(bytes, { notary, label: 'inventory', store, file });
store.get(PROOFS, file); // the receipt, next to its file

What actually left the device

We ran this against a free public authority with selfstore 1.8.35, with a fetch that logged every request body. A 55-byte text file produced one POST of 69 bytes: the DER-encoded request, which holds a version number, the digest and the name of its algorithm, a random nonce and a flag asking for the authority’s certificate. Then we did it again with a 50 MiB file. The request was 69 bytes again. The size of a request does not depend on the file, because the file is not in it.

The answer was a 4,643-byte token, signed at 2026-11-03T23:28:29Z. Before keeping anything, prove reads the token back and checks that it stamps this digest and echoes this request’s nonce. A notary that answers for another document is refused with NOTARY_REFUSED. A receipt that names the wrong bytes is worse than no receipt.

The token travels in the receipt as base64. timestampReplyOf(proof) gives the raw .tsr back, so the app can offer it as a download, and then the check needs neither the app nor this library:

openssl ts -verify -data inventory.txt -in inventory.tsr \
  -CAfile cacert.pem -untrusted tsa.crt
# Verification: OK

We appended one line to the file and ran the same command. OpenSSL answered message imprint mismatch. Inside the app, verify(bytes, proof) gives the same verdict with one bit flipped: matches: false, with the new digest next to the old one.

Two checks, two owners

That split is deliberate, and it is worth saying plainly what each side proves.

verify answers one question: are these the bytes the receipt names? It hashes the file again and compares. It does not check the authority’s signature. That is the authority’s evidence, and the standard tool above checks it better than a JavaScript library would.

The authority answers the other question: was this digest seen at this time? It cannot say what the file contained, and it never knew.

A user who wants to convince someone else hands over three things: the file, the .tsr and the authority’s certificate. Nothing in that bundle depends on the app still existing.

The receipt is data like any other

Because the app is local-first, the receipt has no server to live on, and it does not need one. With store in the options, prove writes it into the PROOFS collection, keyed by the file it belongs to. It is plain JSON, so it syncs to the user’s other devices and goes into the same backup as the file. Restore the backup on a new laptop and the file and its proof come back together. proofsIn(store) lists them, oldest first.

Some notaries do not sign on the spot. A public digest journal writes the digest as a line of a public file, folds the file into a Merkle tree and anchors the root in Bitcoin a few hours later. Its first answer is a receipt with complete: false. For those, the app runs completePending at every launch: it asks each notary for what it can now add and writes back only the proofs that came back complete. A notary that cannot be reached costs one request in that round, not one per proof.

What to be careful with

The label goes out with the digest. Keep it a type, invoice or inventory, never a file name or a phrase from the document. The digest gives nothing away. A careless label can.

Most public authorities send no CORS headers. In a browser, the url has to point at a small relay you run, or you pass your own fetch. The relay sees 69 bytes, the same as the authority.

A digest dates exact bytes. Re-save the PDF, and its metadata changes and so does the digest. Prove the file the user will keep, not a copy that a viewer might rewrite.

The full reference is on the proof guide. Every number in this post comes from one run against selfstore 1.8.35.


Try selfstore in five minutes, or read the other notes.