# Dating a file without uploading it Source: https://selfstore.dev/blog/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`: ```ts 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: ```sh 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](https://selfstore.dev/docs/proof). Every number in this post comes from one run against selfstore 1.8.35. Map of this site for a model: https://selfstore.dev/llms.txt Every page in one file: https://selfstore.dev/llms-full.txt