A notary with offline-verifiable receipts
A service that accepts the SHA-256 digest of a document with the submitter’s Ed25519 signature over it, checks the signature, and appends a record of the two to a transparency log on SQLite. It answers with a receipt, a C2SP tlog-proof, that anyone can check later with no network: given the document, the receipt and the notary’s public key, a small CLI says whether the notary logged that document, signed by whom, and when.
Source in examples/notary; runs on Node.js, Bun and Deno
; its guide is A notary .
The document never leaves the submitter’s machine; only its digest does. And because the record
is in a public, append-only log, the notary cannot quietly back-date, withdraw or rewrite a
notarization it has handed out: every receipt is a proof against a signed checkpoint, and the log
can be monitored (../monitor).
How it works
submitter notary (Node | Bun | Deno) anyone, later
───────── ────────────────────────── ─────────────
sha256(document) ─┐
sign(statement) ─┴─ POST /notarize ─▶ verify Ed25519 signature (WebCrypto)
{ sha256, record = v1 ‖ time ‖ digest ‖ key ‖ sig (137 bytes)
publicKey, log.append(record) → receipt (webtessera/server)
signature } receipt + record in its extra data
document.tlog-proof ◀───────────────┘
verify.ts, offline:
1. record is in the log ✓
2. digest = sha256(doc) ✓
3. signature by the key ✓
Run it
Build the library once at the repository root (bun run build), then:
node scripts/keygen.ts notary.localhost/v1 > .env # NOTARY_SKEY (secret), NOTARY_VKEY (publish)
node --env-file=.env src/main.ts # Node.js 22.18+, on 127.0.0.1:8081
bun src/main.ts # Bun
deno run --env-file --allow-net --allow-read --allow-write --allow-env src/main.ts # Deno 2
Notarize a document, as a submitter (the key is generated into submitter.key on first use):
$ echo "Lease agreement, signed 3 October 2026" > lease.txt
$ node scripts/notarize.ts http://127.0.0.1:8081/ lease.txt
notarized lease.txt; receipt saved to lease.txt.tlog-proof
signed by o0XDtFRrWhzzDUneXtfGhcSCWcbJEZwfetWhbHqYPhs=
The receipt is a tlog-proof whose extra line carries the record (see below):
c2sp.org/tlog-proof@v1
extra AQAAAaED/umvzVNFoy3dQV50EBF/OgU0uVH3khrKBIHk0S6jjZAjniqjRcO0VGtaHPMNSd5e18aFxIJZxskRnB961aFsepg+G0/MSZZRxbug1P8SXKPvkp1vj9eGDRoNOQsk+jnkalJ+AE8Gik0ZDLq18LSz16gmZUnlgfuWfWmoU4Vtd1/aSA8=
index 0
notary.localhost/v1
1
fMNWfhyVvH8wOyJ8NNBuNWFEQJ0+HT+pZ0LM7vM1Yig=
— notary.localhost/v1 IGZQ4bQjzyS96NSUBNeCCbV1NZnJwVZTpnMBSp31caUPU84wenl0s+op5UWLSHrp76P3wmxyHOZcx7YGaSY0udcbkQY=
Verify it, offline, with the notary’s public key (NOTARY_VKEY from .env):
$ node scripts/verify.ts lease.txt.tlog-proof lease.txt "$NOTARY_VKEY"
OK: lease.txt is entry 0 of the notary's log, notarized at 2026-10-03T23:00:00.047Z
signed by o0XDtFRrWhzzDUneXtfGhcSCWcbJEZwfetWhbHqYPhs=, proven against a checkpoint of 1 entries
What failure looks like
Every way to forge or alter a receipt fails, and says which check caught it:
$ echo "Lease agreement, signed 4 October 2026" > lease.txt # another document
$ node scripts/verify.ts lease.txt.tlog-proof lease.txt "$NOTARY_VKEY"
FAIL (document): the document's SHA-256 is not the digest this receipt notarized
$ sed 's/^index 0$/index 1/' lease.txt.tlog-proof > altered.tlog-proof # an altered receipt
$ node scripts/verify.ts altered.tlog-proof lease.txt "$NOTARY_VKEY"
FAIL (receipt): inclusion: receipt: the entry is not at index 1 of the log at size 1 (index is beyond size: 1 >= 1); check that you are verifying the receipt against the exact bytes that were logged
$ node scripts/verify.ts forged.tlog-proof lease.txt "$NOTARY_VKEY" # from a notary with another key
FAIL (receipt): signature: receipt: the checkpoint is not a checkpoint of notary.localhost/v1 signed by notary.localhost/v1+206650e1 (failed to verify signatures on checkpoint: note has no verifiable signatures); check that you are verifying with the log's own vkey
Pass the submitter’s public key as a fourth argument to also require that they notarized it
(FAIL (signer): the document was notarized by o0XD…=, not … otherwise). The exit code is 0 for
a receipt that holds and 1 otherwise. Editing the record in the extra line fails like the index
does: the record is the entry, and a changed entry is not the one in the log.
The record
The receipt’s extra line carries the 137-byte record
(src/record.ts): version, time, digest, public key, signature, all fixed-length,
so any implementation rebuilds the exact entry. The notary puts it there with
log.append(record, { extraData: record }). The tlog-proof format does not authenticate extra
data, and the verifier does not trust it: verifyReceipt(receipt, { vkey, dataInExtra: true })
first proves that exactly these bytes are an entry of the notary’s log, and only then hands them
back for the verifier to read a field.
Outside this repository
Inside the repository this example uses the library it sits in: it declares webtessera as an
optional peer dependency, which Bun’s workspace never installs, and bun install links the
repository root in its place. Copied out on its own, it needs a real dependency instead. Make the
change once, in the copy:
npm pkg delete peerDependencies peerDependenciesMeta && npm install --legacy-peer-deps webtessera@^0.1.0
--legacy-peer-deps works around npm 10, the npm bundled with Node.js 22, which fails to install
Vitest 4.1 with Cannot read properties of null (reading 'edgesOut'); npm 11, Bun and pnpm do not
need it. To try a build that is not released yet, run bun pm pack at the repository root and give
npm install the tarball’s path in place of webtessera@^0.1.0. Everything above then works as
written, bun run build aside.
Trust model
- The notary’s key signs the log’s checkpoints;
NOTARY_VKEYis all a verifier needs. A receipt made with any other key fails, as does any change to the record, the proof or the checkpoint. - The submitter’s key is what makes a record theirs: the notary logs only signatures that
verify, over a domain-separated statement (
webtessera-example-notary/v1\n‖ digest) that cannot be replayed as a signature over anything else. - The time is the notary’s claim, covered by the log’s signature. A notary that lies about time is caught only by comparison with when its checkpoints were first seen, which is what witnesses and monitors record.
- The notary cannot withdraw a notarization: the receipt proves it against a signed checkpoint,
and the log is append-only and publicly readable (the server serves the tlog-tiles API next to
/notarize), so a monitor sees a log that rewrites its history.
Test
npm run ci # tsc --noEmit && vitest run
src/notary_test.ts, on an in-memory node:sqlite database: a receipt that
verifies offline; a submission with someone else’s signature refused, with nothing logged; malformed
submissions refused; and a receipt that fails for another document, an altered record, proof, index
or checkpoint, a forging notary, missing extra data, and an unexpected signer; plus the CLI’s output
and exit codes. scripts/smoke.ts runs the real server on Node, Bun or Deno.
Files to read first
src/notary.ts: thePOST /notarizehandler.src/verify_notarization.ts: offline verification, in order.src/record.tsandsrc/submission.ts: the entry and the request.scripts/verify.ts: the CLI.
This page is generated from examples/notary/README.md
. Edit it on GitHub.