Why we publish a tamper-evident audit log

End-to-end encryption solves one problem completely and another one not at all. We cannot read your files — that part is settled by mathematics. But we still write the record of what happened to them: when a file was uploaded, when it was downloaded, when it expired or was deleted, when an access attempt was refused. And a record written by us can, in principle, be edited by us.

“Trust us not to” is not an answer we find satisfying, so the audit log is built to make edits detectable rather than merely forbidden.

The problem with an ordinary log

A normal audit table is a list of rows in a database. Anyone with write access can change a row, delete one, or insert one with a backdated timestamp, and the result looks exactly like a log that was always that way. There is nothing in the data itself that distinguishes the original from the revision.

That is fine for debugging. It is not a basis for accountability, and under GDPR it is not much of an answer either — a data subject asking for their access history deserves something better than a query result we could have shaped.

Chaining the entries

Every audit entry includes a hash of the entry before it, computed with BLAKE3. Each account's history is not a list; it is its own chain, where each link commits to the entire history behind it.

entry[n].hash = BLAKE3( entry[n-1].hash || entry[n].fields )

The fields go into the hash in a fixed, canonical serialization, and the sensitive ones go in as ciphertext — so the chain stays verifiable even after we crypto-shred the key that protected an entry's contents. Change one field in one old entry and its hash changes. That breaks the link stored in the next entry, which breaks the one after, and so on to the present. To alter a single line of history convincingly you must recompute every entry that followed it.

Entries are also periodically batched into a Merkle tree, which yields a single root hash summarising a whole window of activity, and lets us produce an inclusion proof: a short list of sibling hashes proving that your specific entry is part of that root, without disclosing anyone else's entries.

Anchoring: the part that stops us recomputing

Hash-chaining alone has a gap. If we control the whole chain, we could rebuild it from scratch — recompute every hash from the altered entry onwards — and present a chain that is perfectly self-consistent and entirely fictional.

Closing that gap requires committing to a root at a point in time, somewhere we do not control. Every fifteen minutes the fresh entries are rolled into a Merkle root, and those roots are anchored via OpenTimestamps — each root's digest is submitted to public calendar servers that aggregate digests into the Bitcoin blockchain and return an .ots proof file attesting that the root existed before a given block. That anchoring is switched on, and the proof for any root can be downloaded without an account.

In the spirit of this post, two precisions about what those proofs mean. A freshly returned proof is a pending calendar attestation; it becomes a full Bitcoin proof once it is upgraded, and that upgrade is performed by the standard ots client rather than reported by us — so the confirmation itself is never something you take on our word. And the roots that predate the day anchoring was switched on were submitted in a single catch-up pass, which timestamps them as of that pass rather than as of their original period.

Once a root is anchored, rewriting the history behind it means producing a chain whose root matches one already embedded in Bitcoin's history. That is not a policy we could quietly change or a key we could be compelled to hand over — it would require rewriting the Bitcoin blockchain.

To be precise about what this proves: it establishes that a particular log state existed at a particular time and has not been altered since. It does not prove the entries were truthful when written. Anchoring makes the past immutable; it cannot police the present. That is a real limit and worth stating plainly.

The verifier is a separate program

A verification button inside our own web interface can only ever be a convenience — the same people who could alter the log could alter the code that checks it. We ship one anyway, for quick checks against your own history, but the verification that counts lives in gcn-audit, a standalone command-line tool that ships independently of the API.

It pulls the published roots, leaves and proofs from our public verification endpoints and recomputes every hash itself rather than taking the server's word for any of them. It will:

  • Rebuild each Merkle tree from the published leaves and recompute its root.
  • Verify the root-to-root chain, link by link, back to the very first root.
  • Validate inclusion proofs for individual entries you care about.
  • Check that the .ots proof is bound to the root's digest — with full Bitcoin verification of an anchored proof done by the standard open-source ots client, which owes us nothing.

Your own raw entries and their hashes are exportable too, so the per-entry chain can be recomputed entirely outside our code. Anyone can run the verifier — a customer, an auditor, a regulator, a journalist. It is deliberately boring software, and its usefulness comes entirely from not having to trust our arithmetic.

Why bother

Most of the time this machinery does nothing visible. It matters in the situations you hope never happen: an insider abusing access, an intrusion where the attacker cleans up after themselves, a dispute about whether a file really was shared on a particular date, a compliance review that needs evidence rather than assurances.

In each of those, the question is not “is the log accurate?” but “can anyone other than the provider tell?” Under an ordinary logging setup the answer is no. That asymmetry is the thing we wanted to remove.

A promise you cannot check is just a promise. The point of the audit log is to replace one with something you can test.

Related reading: what zero-knowledge actually means and how your files are encrypted before they leave the browser.