The short answer
To verify an audit log independently, check it with code its writer didn’t write, against keys you got from somewhere other than the log itself. Recompute each record’s hash, check every signature, walk the chain for gaps and reordering, check the export’s manifest, then break a copy on purpose and make sure your verifier notices.
That’s the whole pattern. It applies to any signed, hash-chained log, whoever wrote it. The rest of this post goes through each step with real output from three verifiers run on the same records: deedglass verify, which we ship, and two verifiers written separately from the record spec.
Why bother with a second verifier? Because a log checked only by its vendor’s tool rests on the vendor’s word twice: once for writing the records, and once for saying they check out. If that tool has a bug that says “fine” when it should say “broken”, every record it ever passed is in doubt. A hash-chained log is tamper-evident only to someone who can actually evaluate it.
Deedglass
A sealed record you can check offline
Record 1043
- What ran
- Medication order, Bed 12
- Approved by
- Dr. N. Shah
- Check
- Matches
- Time
- 09:12
- The request
- Not keptA fingerprint only: 77d2…c41a
Each record is linked to the one before
- Record 1041
- Record 1042
- Record 1043
Step 1: Get the keys from somewhere else
A signature tells you a record was signed by whoever holds a key. It tells you nothing about who that is unless the key came from a source you trust. If the public keys travel inside the same export as the records, anyone who rewrote the records could have swapped the keys too.
So fetch the issuer’s published key set separately, from their website or a copy you pinned earlier, and pass it to the verifier. The independent verifiers have a --strict mode where keys found only inside a bundle aren’t enough: without the issuer’s keys the answer is “can’t decide”, exit code 2, not a pass. [2]
Step 2: Check each record on its own
Every record needs three checks before you look at its neighbours:
- Parse it strictly. Duplicate keys, odd number formats and unknown fields are where two parsers quietly disagree. A closed schema means a field the spec doesn’t define is a failure, not something to skip. [2]
- Recompute the hash. Serialize the body in canonical form (Deedglass uses JCS, RFC 8785 [7]) and hash it. It must match the hash the record carries.
- Check every signature over it, with a key that was valid and not revoked when the record was issued.
Records written today may need checking in twenty years, so check every algorithm the record carries. Both independent verifiers check the ML-DSA-65 post-quantum seal as well as Ed25519, so a dual-signed record gets the same answer as from deedglass verify.1 The post-quantum post explains why that second signature exists.
Step 3: Check the chain
Each record names the hash of the one before it, and carries a sequence number. Walk the whole chain and check that:
- every link points at the previous record’s hash;
- sequence numbers rise by exactly one, with no gaps or repeats;
- every record belongs to the same organisation;
- timestamps never go backwards. [2]
A valid record in the wrong place is still a broken log. Deleting one record leaves every remaining signature valid; only the chain shows the hole.
Step 4: Check the bundle around the records
Logs usually leave a system as an export: a file of records plus keys, a README and a manifest. The manifest lists each file’s hash and the chain’s head, and has its own signature. [6] Check that the manifest’s signature verifies, that every file matches its listed hash, that the record count and head match the records, and that the archive holds nothing unexpected: no extra entries, no symlinks, no paths that escape the folder. [2]
Step 5: Run a verifier that shares no code
Deedglass now has two verifiers besides the deedglass binary, written from the record spec rather than ported from the Go code: one in Python using only the standard library (3.10 or newer), and one single JavaScript file with no dependencies that runs on Node 20 or newer and in the browser. The browser version is tested in Chromium on our offline verifier page.1
Here are all three on the v0.9.0 release bundle, with its published test keys:
Code What the screen or file shows, copied as is.
$ deedglass verify bundle.tar.gz --keys keys.json
✓ 12 deeds · chain intact · 2 seal keys · 2026-10-02 → 2026-10-02
✓ Bundle manifest matches · sealed by sk_01m3xxf0…
○ These deeds were sealed by Deedglass's published test keys. They mean nothing outside tests.
$ python3 -m deedglass_verify bundle.tar.gz --keys keys.json
✓ 12 deeds · chain intact · 2 seal keys · 2026-10-02 → 2026-10-02
✓ Bundle manifest matches · sealed by sk_01m3xxf0m2b2bf0pn7ssc7whbb
○ These deeds were sealed by Deedglass's published test keys. They mean nothing outside tests.
$ node deedglass-verify.mjs bundle.tar.gz --keys keys.json
✓ 12 deeds · chain intact · 2 seal keys · 2026-10-02 → 2026-10-02
✓ Bundle manifest matches · sealed by sk_01m3xxf0m2b2bf0pn7ssc7whbb
○ These deeds were sealed by Deedglass's published test keys. They mean nothing outside tests.
Agreeing on one good bundle is easy. The real test is agreeing on everything, broken input included. Both verifiers match 848 of 848 checks against the spec’s test vectors and parity corpora (the two COSE vectors are skipped).2 A cross-check runs all three side by side on every release bundle, tampered copies of each, and every vector file, and fails on any different verdict; on 2026-10-20 all 288 cases agreed.22 It runs on every pull request, but it isn’t a required check yet.1
We re-ran it today against the v0.9.0 tag, which now carries ten release bundles:
Code What the screen or file shows, copied as is.
$ python3 verifiers/crosscheck.py bin/deedglass
✓ 369/369 cases agree across deedglass verify, the Python verifier and the JS verifier (278 release-bundle cases, 91 deed-vector files)
Writing them from the text alone also tested the spec. It turned up places where the spec didn’t say which field or sequence number a failure should name, and those are now written into the spec as an erratum, so the next person doesn’t need our code. [5]
Step 6: Break a copy on purpose
A verifier that has never failed in front of you hasn’t shown you much. Make two broken copies of the chain: in one, change a single value (the cost on record 7, from 120 to 12); in the other, delete record 7. All three verifiers refuse both, with the same reason:
Code What the screen or file shows, copied as is.
$ python3 -m deedglass_verify edited.jsonl --keys keys.json
✕ Deed 7 was changed after it was sealed.
next Get a fresh export from the issuer and compare deed 7.
$ node deedglass-verify.mjs cut.jsonl --keys keys.json
✕ Deed 7 is missing. The chain jumps from 6 to 8.
next A deed was removed or the export is incomplete. Ask the issuer for seq 6-8.
With --json, each prints the same report fields as deedglass verify --json: the result, and the failure’s code, sequence number and field (here body_hash_mismatch at 7). [2] That makes the comparison mechanical rather than a matter of reading two messages.
What an independent check still misses
The end of the chain. Cut the last records off a log and what’s left is a perfectly valid, shorter chain. Nothing inside it shows the cut. The defence is an outside timestamp on the head: deedglass verify fails a chain cut short before a timestamped head (chain_tail_missing).4 The independent verifiers don’t check timestamps at all. They check the file’s bytes against the manifest and warn timestamps_not_checked.3 On the cut test bundle, the difference looks like this:
Code What the screen or file shows, copied as is.
$ deedglass verify cut.tar.gz --keys test-keys.json --timestamps timestamps.json \
--tsa-roots tsa-roots.pem --scitt-key scitt-key.jwk
✕ Deeds after 17 are missing: deed 20 was timestamped at 2026-10-20 09:30:00 UTC.
$ python3 -m deedglass_verify cut.tar.gz --keys test-keys.json
✓ 17 deeds · chain intact · 2 seal keys · 2026-10-02 → 2026-10-02
The writer’s honesty. Every verifier here checks that records weren’t changed after they were signed. None can tell whether the signer recorded the truth in the first place. Whoever holds the signing key can write any record, with any date. [3] Why logs aren’t evidence covers what has to happen before signing for a record to mean something.
Shared ancestry. The two verifiers share no code with the Go one, but the same team wrote the spec, the vectors and all three. A truly independent check is one you, or someone you pay, writes from the spec. The vectors are public so you can.
A checklist
- Keys come from the issuer’s published location, not only from inside the export.
- Every record parses strictly, against a closed schema.
- Each record’s hash is recomputed from its canonical form.
- Every signature is checked, every algorithm, with a key valid at issue time.
- The chain has no gaps, repeats, wrong links or backwards time.
- The manifest’s signature, file hashes, count and head all match.
- A second verifier, with no shared code, gives the same answer.
- Tampered copies (one value changed, one record removed) fail in both.
- The chain’s head is timestamped by an outside party, or you know you can’t detect a cut tail.
To try it on records you didn’t make, open the offline verifier: it runs the same JavaScript verifier in your browser tab, on its sample bundle or one you drop in. Every release keeps one such bundle in the repository, and CI checks them all on every pull request, so old records keep verifying as the code changes.8 To make records of your own first, the playground runs the gate on a real Postgres, and npx deedglass scan --postgres-dsn-env DATABASE_URL shows which of your tables it could check (the grade, explained).
Written with AI assistance and checked against our claims register.