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.