A browser's own tamper-evident log
A page that keeps a transparency log of its own, entirely in the browser: in IndexedDB, signed by a key generated on the device that no script can export. Every event the page logs gets a receipt, a C2SP tlog-proof, that anyone holding the log’s public key can verify offline. The log survives reloads, and every tab of the page writes the same log: Web Locks make them take turns, and without Web Locks the log refuses to open rather than risk a fork.
Source in examples/client-only; runs on browsers
; its guide is A client-only log .
No server is involved. This is the starting point for logs that a device keeps about itself:
consent records, audit trails of what an app did locally, signed activity histories. To have the
record witnessed by your server, see ../session-receipts.
How it works
tab A ──┐ ┌── openDeviceKey(origin)
│ log.append(event) ─▶ Web Lock "…/log" ───┤ non-extractable Ed25519 CryptoKey,
tab B ──┘ (one writer at a time, across tabs) │ kept in IndexedDB "webtessera-keys"
│ └── openBrowserLog({ key })
▼ tiles, bundles, checkpoint in IndexedDB
signed checkpoint covers the entry "webtessera-log:<origin>"
│
▼
receipt (tlog-proof) ─▶ verifyReceipt(receipt, { vkey, data }) ✓ offline, anywhere
Run it
Build the library once at the repository root (bun run build), then here:
bun x vite # or: npx vite — then open http://localhost:5173
Log a few events, open the page in a second tab and log from there too: both tabs list the same
events, each with a verified receipt. Reload: the key and the log are found again. “Under the hood”
re-verifies the newest receipt by hand with the ported API (webtessera/client’s proof builder and
webtessera/merkle/proof), and shows that it rebuilds the same proof the receipt carries.
Without Web Locks. Browsers provide Web Locks in secure contexts (HTTPS and localhost). Open
http://localhost:5173/?no-web-locks to see a page without them: the log refuses to open, because
two tabs appending at once with nothing to keep them apart would fork it. The page shows the
refusal, which names the option that lifts it, and offers to open the log as this tab’s alone
(storage: { indexedDB, singleWriter: true }). The device’s lock scope then reads “This tab only”.
npx vite build produces a static site in dist/ that any static host can serve over HTTPS.
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 key cannot leave the device.
openDeviceKeygenerates a non-extractable WebCrypto Ed25519 key and stores the CryptoKey itself in IndexedDB; no script, this library included, can export it. A script injected into the page could still use it while the page is open: Content Security Policy remains the first defence. - The log is tamper-evident, not tamper-proof. The device holds its own key, so it could sign a
rewritten history; what it cannot do is make that history agree with receipts and checkpoints
already handed out. Anyone holding an older receipt detects the rewrite. Witnessing makes that
check happen as the log grows, by someone else: see
../session-receipts. - Storage can be lost. Clearing site data deletes the log and the key, and browsers may evict
storage under pressure; the page asks for persistent storage (
navigator.storage.persist()). Receipts already handed out keep verifying with the public key, which is public and can be kept anywhere.
Test
npm run ci # tsc --noEmit && vitest run (headless Chromium) && vite build
The tests run in a real Chromium through Vitest’s Playwright provider (npx playwright install chromium once). src/device_log_browser_test.ts checks: a device
key that cannot be exported, and receipts that verify; a tampered receipt, a receipt for other
data, and one checked with another key, all refused; a log that survives a reopen; two tabs (the
page and a worker) appending at once and making one log with no index handed out twice; a log
that refuses to open without Web Locks unless the page promises a single writer; the hand-made
verification agreeing with the receipt; and forgetting the device.
Files to read first
src/device_log.ts: the device key and the log, with the safe API.src/main.ts: appending, receipts, two tabs, and the no-Web-Locks path.src/history.ts: reading the log back withlog.entries, and verifying a receipt by hand with the ported API underneath.
This page is generated from examples/client-only/README.md
. Edit it on GitHub.