Search selfstore
v1.8.37

Proof of existence

Sometimes a user needs to show that a file existed on a date: an inventory before a move, an invoice before a dispute, a draft before someone else published the same idea. The usual answer is to upload the file to a service that dates it, and a local-first app is built so that the file never leaves the device. selfstore/proof keeps that promise. The bytes are hashed on the device, only the SHA-256 goes to a notary, and the receipt comes back into the user’s own store, next to the file and inside the same backup.

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

const { matches } = await verify(bytes, proof); // false the moment one byte moves

What leaves the device

The digest, and the label if you pass one. Nothing else: not the name, not the size, not a byte of the content. The label is sent with the digest, so keep it a type such as invoice or inventory, never a file name or a phrase from the document.

prove(blob, options) takes ProveOptions and returns a Proof. It is the notary’s Receipt plus what the device measured: the algorithm, the size in bytes and, when you pass file, the id of the store file it belongs to. With store in the options, the proof is also written to the PROOFS collection, keyed by the file id or by the digest. Any store with a put(collection, record) method fits, which the ProofShelf type names, so the simple store works as it is.

Checking a file later

verify(blob, receipt) hashes the bytes again and compares them with the receipt. It returns a ProofCheck with three fields. matches says whether the bytes are the ones the receipt names, hash is what they hash to now, and complete says whether the notary had finished with that receipt.

That check proves the file in hand is the dated one. It does not prove that the receipt itself is genuine. That is what the notary’s evidence is for, and the notary’s own verifier checks it, without this library. A receipt that carries no digest at all throws BAD_FORMAT, because a damaged receipt is not a document that changed.

The notary is an interface

The services that date a digest differ in almost everything except one shape, so a notary is two methods:

interface Notary {
	readonly name: string;
	deposit(hash: string, label?: string): Promise<Receipt>; // a receipt now
	complete(receipt: Receipt): Promise<Receipt>; // what it adds later
}

deposit sees the digest and the label, never the bytes. Some notaries sign at once and return a complete receipt. Others anchor their digests later, in a daily tree or a public ledger. They answer complete: false, and completeProof(proof, { notary, store }) asks them for the rest the next day or at the next launch. A proof that is already complete comes back as it is, without a call.

A notary that answers for another digest, or in another notary’s name, is refused with NOTARY_REFUSED, because a receipt that does not name the bytes is worse than none. One that cannot be reached, or answers 429 or a 5xx, throws NOTARY_UNAVAILABLE, and the deposit can be tried again later.

RFC 3161 time-stamping authorities

rfc3161Notary works with any RFC 3161 authority. Its options are Rfc3161Options, which take the authority’s url, an optional name for the receipt and an optional fetch. The authority signs the digest and its own time in one token, so the receipt is complete on deposit. The reply is checked to stamp this digest and to echo this request’s nonce before anything is kept.

The token travels in the receipt as evidence.tsr, in base64. timestampReplyOf(receipt) gives the .tsr bytes back, to offer as a download. Anyone can then check it with openssl ts -verify, given the file and the authority’s certificate. readTimestampReply(bytes) reads the digest, the time and the nonce out of a reply as a TimestampToken. It does not check the authority’s signature. Its job is to tell a token for another document apart before anything is kept.

Most public authorities send no CORS headers. In a browser, point url at a relay you run, or pass your own fetch.

Public digest journals

digestJournalNotary works with a public digest journal. Such a journal takes a SHA-256 with no account, writes it as one line of a public file, folds the file into a Merkle tree and anchors the root in Bitcoin with OpenTimestamps. Its options are JournalOptions: the same three fields as above, and an optional type (since 1.8.32), sent as type with every deposit to a journal that issues typed receipts and keys them on it, such as a permit display where the label is the permit number. Without it the deposit is the journal’s ordinary one.

import { prove, completeProof, digestJournalNotary } from 'selfstore/proof';

const notary = digestJournalNotary({ url: 'https://journal.example' });
const proof = await prove(bytes, { notary, label: 'inventory', store, file });

// the next day, or at the next launch
const done = await completeProof(proof, { notary, store });

The deposit returns the journal’s own receipt at once, with complete: false. Once the first tour covering the line is in Bitcoin, completeProof adds the tour, its root, the block, the .ots proof and, when the journal also stamped that root under RFC 3161, the .tsr. Checking the path from the line to the root is the job of the journal’s own verifier.

At launch, finishing what is pending

A journal finishes a receipt hours after the deposit, so an app that offers proofs needs the same round at every launch. It finds the incomplete proofs, asks the notary that issued each one, and writes back those that are done. completePending({ store, notaries }) is that round.

import { completePending, proofsIn, digestJournalNotary } from 'selfstore/proof';

const notary = digestJournalNotary({ url: 'https://journal.example' });
const { completed, pending, refused } = await completePending({ store, notaries: [notary] });

for (const proof of proofsIn(store)) show(proof.file, proof.depositedAt, proof.complete);

It returns a PendingRound with three lists. completed holds the proofs that came back complete, and only those are written to the store. pending holds the ones to ask again on a later launch: the notary had nothing to add yet, could not be reached, or is not among the notaries given. A notary that cannot be reached is not asked again in the same round, so one dead service costs one request, not one per proof. refused holds the proofs whose completion answered for another digest. They are set apart and the receipt in the store is never overwritten by that answer.

proofsIn(store) reads every proof back, oldest deposit first, to list a series or rebuild a screen on another device. It skips any record of the PROOFS collection that is not a proof rather than trust it. The store it reads from is a ProofLibrary: a ProofShelf that can also list a collection with all(). The simple store fits as it is.

Writing your own notary

Any object with a name, a deposit and a complete is a notary. Return a Receipt that names the same digest, a depositedAt instant from the notary’s clock rather than the device’s, and complete: true once nothing is left to add. Keep evidence as strings, base64 for bytes, so the receipt stays plain JSON and survives every destination a store can write to.