I Built a Provenance Protocol, Audited My Own Auth, and Found 2 CRITICALs
I spent weeks building VERITAS — a protocol for verifiable provenance, identity, and attestation. 25 authenticated endpoints. Ed25519 signatures. Six layers: identity, content provenance, agent trust, economic escrow, governance.
Then I audited my own auth. It was broken.
CRITICAL #1: Every signed request was replayable forever
I had Ed25519 signatures on all mutating endpoints. That proves the sender controls the private key. What it doesn't prove is that the request is fresh. A captured (did, message, signature) tuple was valid indefinitely. Someone with access to a single logged request could replay it a thousand times.
The fix: a 60-second timestamp window. Every request now includes an ISO-8601 timestamp. The server rejects anything outside the window. A signature from two minutes ago is dead. Replay protection isn't a feature you add later — it's part of the signature verification itself.
CRITICAL #2: The server trusted caller-supplied messages
This one was worse. Every endpoint accepted a message field from the client, then verified the signature against it. The problem: the message the client signed and the parameters the server actually acted on were two different things. A client could sign "amount=10" while submitting amount=1000 in the body. The signature would verify — because it verified the message, not the operation.
The fix: canonical payload binding. The server now reconstructs the message from the actual request parameters it's about to act on. Keys are sorted lexicographically. Values are serialized with a deterministic float format. The signature is verified against the reconstructed message. If the body says amount=1000, the reconstructed message says amount=1000, and the signature must match that — not whatever the caller claims it should be. The message field was deleted from every request model entirely. That field was the vulnerability.
The implementation is in identity/did.py::canonical_message(). The critical line is the float serialization: f"{float(v):.8f}".rstrip("0").rstrip(".") — if your client uses str(v) or repr(v) instead, you get mystifying 403s. That one line is documented in the README because it's the thing that will bite anyone implementing a client.
The meta-test that prevents regression
Fixing bugs is easy. Proving they stay fixed is harder. I wrote a route-enumerating meta-test that programmatically discovers every authenticated endpoint, sends a request with a bad signature to each one, and asserts 403. If I ever add a new endpoint and forget to wire auth, the test fails. The two CRITICALs above can't silently return because the test suite won't let them.
18 tests total. 4 unit tests on the trust scoring engine. 14 integration tests covering auth rejection, replay protection, dispute resolution, and the full governance→arbiter→escrow→agent trust chain.
The demo that proves it works end-to-end
A six-second asciinema recording showing all six layers firing together:
- An AI agent exceeds its 100-credit delegation cap
- The cap is enforced at the agent endpoint (403)
- The violation routes through escrow
- A dispute is filed
- Carol is elected arbiter through a real governance cycle
- Carol resolves the dispute
- The agent's trust score drops 50→40 because its side lost
Every step is a real HTTP call. The demo is a shell script in the repo. Anyone can clone and run it.
What I'd do differently
I shipped with no test suite. Don't. The first time Claude audited my code, it found 11 issues in one pass — missing auth on 20+ endpoints, private keys stored in the database, proof validation that counted instead of verifying. Every single one was fixed, and every fix got a test. But I should have written the tests first.
Repo
github.com/balbaks/veritas — please break it.
The threat model and known challenges are in docs/SPEC.md. The demo runs with bash scripts/demo.sh. If you find a vulnerability, open an issue. Public credit for every verified finding.
Top comments (0)