Security

The safe API is built so that the obvious call is the safe one, and a divergence from Tessera that changes a verification outcome is treated as a security bug. To report one, use a private security advisory on GitHub.

Safe defaults

Each entry point of the safe API refuses the mistakes that are easy to make with a transparency log. Its module documentation lists them:

webtessera/server

Runs in your server's private environment: Node.js 22.18+, Deno, Bun, Cloudflare Workers, Vercel Edge and other edge runtimes. A bundle built under the “react-native” and “browser” conditions gets a module that fails the build.

  • Run in a browser: a browser bundle resolves this module to one that fails the build, and a browser that loads it anyway gets an error at import.
  • Keep a log in memory by accident: storage is a required choice, and memory must be asked for by name.
  • Fork a log across processes: SQLite storage uses lease locking for every database another process could reach, unless told the process is the only writer; and if that declaration is wrong, the earlier writer stops as soon as a second one starts.
  • Lose track of publication: `append` resolves only once a checkpoint commits to the entry, and hands back a receipt it has verified.
  • Change a log's key: a log refuses to open with a key that did not sign its checkpoint.
  • Leak the key: keys are non-extractable WebCrypto keys wherever the runtime supports Ed25519, and no error, `toString` or JSON ever contains key material.

webtessera/browser

Runs in the browser's public environment: windows, workers, service workers.

  • Hand it a private key string: a browser log's key is generated on the device with `openDeviceKey`, or imported as a `CryptoKey` with `fromCryptoKey`.
  • Keep a persistent log with a key that does not persist with it.
  • Change a log's key, or open a log another key created.
  • Lose track of publication: `append` resolves with a receipt it has verified.

Reviews

The safe API and the storage drivers went through security reviews. These decision records state what the reviews required, and how each finding was fixed:

  • ADR-0152: Lock SQLite stores in memory or with fenced leases in the database, per store
  • ADR-0154: `webtessera/storage/sqlite` exports `newSqliteDriver`, the store, and one adapter per engine
  • ADR-0155: Test the SQLite backend on every engine it claims, under production limits, with a live rqlite
  • ADR-0170: Serve the tlog-tiles read API from any LogReader as a fetch-style handler
  • ADR-0171: Add a C2SP tlog-witness server, `webtessera/witness`
  • ADR-0176: Verify what a mirror copies before writing it, outside the faithful port
  • ADR-0210: Default SQLite stores to lease locking unless the database is provably private
  • ADR-0211: Fence lease-mode writes on a NOT NULL column, at schema version 2
  • ADR-0212: Take only origin-form request-targets, validate every numeric limit, and keep 500 bodies generic
  • ADR-0213: rqlite and S3 requests omit credentials and refuse redirects; rqlite reads only at linearizable or strong

Hardening beyond Tessera

Hardening beyond Tessera, from input validation that upstream lacks. None of it changes the bytes of a valid log.

  • No error repeats a signer key: a signer key passed where a verifier key, an origin or a name belongs, in the safe API or in webtessera/witness, is refused with the code SIGNER_KEY_MISUSE, and no message, cause or stack contains it (ADR-0243).

  • Leases wait out a busy database: taking or renewing a lease waits on SQLITE_BUSY/SQLITE_LOCKED instead of failing. A libSQL file: database shared by several processes needs a client with a busy timeout, and fromLibsql’s busy error says so (ADR-0210). Checkpoint publication’s refusal writes nothing to the store (ADR-0205).

  • SQLite locking fails closed: stores default to lease locking unless the adapter shows the database is private, and an explicit locking: "local" declares a single writer. Otherwise two connections or processes on one file could assign one index twice, and two witnesses could roll a log back (ADR-0210). The lease fence is a NOT NULL column that PRAGMA ignore_check_constraints cannot turn off (schema version 2, ADR-0211). A database whose text encoding is not UTF-8 is refused at open (ADR-0151).

  • Mirroring: the S3 sink accepts a 412 only over identical bytes, and otherwise fails before the checkpoint is written. newSinkTarget(s3, { prefix }) keeps metadata and conditional writes. A verified mirror verifies afresh on every run, refuses overlapping runs and copies what it verifies (ADR-0175, ADR-0176).

  • HTTP and witness inputs: toNodeListener accepts only origin-form request-targets; every size cap and count must be a positive integer; addErrorResponse answers 500 without the error’s text unless asked for { detail: true } (ADR-0212). newWitnessServer requires cosignature/v1 signers, and caches its key checks for lookupLog (ADR-0171).

  • rqlite and S3 requests omit credentials; rqlite refuses redirects unless followRedirects: true, and reads only at linearizable or strong (ADR-0213).

  • Entries: newEntry throws for data longer than 65535 bytes, which an entry bundle cannot encode, where Go silently truncates the length prefix (ADR-0182). Big-endian length prefixes reject values their width cannot hold (ADR-0200).

  • Checkpoint publication fails closed: a checkpoint is published only if it parses and commits to exactly the size and root requested, and a new tree is never started over a published checkpoint when the tree state is missing (ADR-0205). When a witness fails and failOpen is set, the checkpoint published after an error other than an unmet policy is the log-signed one, not an empty one (ADR-0183).

  • Witness policies and URLs: a policy that names the same child twice, gives one Ed25519 key two witness names, or sets a threshold of 0 is rejected (ADR-0184); witness URLs must use https, or http to a loopback address (ADR-0185); witness requests do not follow redirects, and an update retries a stale tree size at most three times (ADR-0197, ADR-0198).

  • Verification: every hash in a Merkle proof, and every root passed to the verifiers, must be a node hash of the hasher’s size (ADR-0202); a parsed checkpoint’s root must be 32 bytes, and an origin must be valid UTF-8 (ADR-0202, ADR-0203); Ed25519 verification follows Go’s rules exactly, and a verifier configured with a small-order or non-canonical key is refused (ADR-0206); LogStateTracker.update throws ErrInconsistency for a checkpoint of the tracked size with a different root, or a smaller one that is not consistent with it (ADR-0196).

  • Tiles, bundles and responses: a tile or an entry bundle holding more than the tlog-tiles maximum of 256 hashes or entries is rejected, response bodies are read with a size cap, and the log fetcher and the witness client omit credentials on every request (ADR-0194, ADR-0195, ADR-0197).

  • Locks: opening an IndexedDB log without Web Locks throws, unless the caller passes singleWriter: true to promise that only one tab or worker writes the log; newIndexedDBDriver returns an IndexedDBDriver whose lockScope says which guarantee the log has (ADR-0201).

  • Numbers: the exported uint64 entry points of the Merkle, compact-range and checkpoint code throw RangeError for a value outside the uint64 range instead of computing with it (ADR-0207). parseUint, the hex decoder and quote are transcriptions of Go’s, so malformed input is accepted or rejected as Go does, in time linear in its length (ADR-0204).

  • Worker counts: entryBundles, entries and fsck reject a worker count below one with a RangeError instead of hanging (ADR-0192).

Report a vulnerability

Please do not open a public issue, pull request or discussion for a security problem.

Report it privately through GitHub Security Advisories:

  1. Go to https://github.com/diagnos-tech/webtessera/security/advisories/new (the repository’s Security tab, then Report a vulnerability).
  2. Describe the problem and how to reproduce it. A failing test or a minimal script is the most useful thing you can attach. For a divergence from Tessera, include the Go behaviour and the pinned upstream commit you compared against (scripts/upstream.json).
  3. Say whether you intend to disclose publicly, and by when.

We aim to acknowledge a report within five working days, to tell you whether we consider it a vulnerability within ten, and to keep you informed until a fix is released. Fixes are developed in a private advisory fork where possible, released with an advisory and a CHANGELOG.md entry, and credited to the reporter unless you ask us not to.

The full policy, with what is in scope and which versions are supported, is SECURITY.md.