A pile of signatures is not a chain
One sentence on this site said: every action lands in a tamper-evident record that can be sealed on a Securosys HSM. An engineer at Securosys read it and asked two questions. The first was about a word. The second went straight through the middle of the feature, and they were right.
The two questions
Paraphrased, because they were asked in Swiss German over a chat window: the sentence sounds like the record is already tamper-evident before the HSM touches it. And: sealing normally means encrypting, not signing. So which is it? Do you sign the record with an HSM key, or is it a database encrypted under an HSM key?
The second one is a word, and the word was wrong. Here is what actually happens. The plugin hashes the content locally with SHA-256. Only the 32-byte digest leaves the machine. It goes to the Securosys Transaction Security Broker as POST /v1/synchronousSign with SHA256_WITH_RSA, and the signature comes back and is appended to attestations.jsonl. There is no encryption anywhere in that path, and no unsealing, because there is nothing to unseal. It is signing. So "sealed" goes, and "signed" stays.
Encrypting the log would also have been the wrong feature. An audit log you have to decrypt before anyone can audit it is worse than one anybody can read, and it would have killed the part that makes this useful: a receipt you can hand to a third party who verifies it in their own browser against a public key, with nothing of ours involved.
The first question was not a word
It was a defect, and the sentence was the only place it was visible.
Every receipt was signed on its own. Content in, digest out, digest signed, line appended. Each line was individually valid and nothing bound line N to line N-1. So you could open attestations.jsonl, delete the receipt for the thing you would rather nobody saw, save the file, and every remaining signature still verified. Reorder them and it still verified. The public verifier at xnaut.dev/attest would have shown a clean green tick for a log with a hole in it.
That is a set of individually valid records. It is not a record of what happened. And the failure mode is silence: nothing throws, nothing warns, the page says valid, and it is telling the truth about the only question it was asked.
Why we, of all people, had this bug
Two days before, we published Proofs, not promises, which argues this exact point at length for NautGate's routing log: chain every row into the one before it, then let the HSM sign the head, because a signature over an unchained pile proves nothing about the pile.
Then the attestation plugin shipped one directory over without the chain. We wrote the argument down and did not apply it to the next thing we built. That is worth saying out loud, because the interesting part is not that we made the mistake. It is that we had already written the correct answer, in public, and still could not see it from inside our own repository. It took someone outside reading one sentence.
What we changed
Every receipt now carries a seq and a prev. prev is the SHA-256 of the previous receipt's canonical JSON, sixty-four zeros at genesis. And the HSM no longer signs the bare content digest. It signs:
sha256( prev || content_digest )
Position and content together. Which means the newest signature commits to the entire history behind it, and there is no separate anchoring pass to run, forget to run, or run too late. Delete a receipt, move one, edit one in place, or cut the front off the file, and the walk breaks at the first receipt that no longer matches.
Four details in there are load-bearing, and three of them are the kind that would have quietly ruined it:
- The link hashes only the fields we publish. Local context like the TSB endpoint and the caller's metadata stays out of it. A third party has to be able to recompute the link from the file they were given, and a field they never receive must not change a hash they have to reproduce.
- Python and JavaScript had to agree on one byte sequence. The writer canonicalises in Python, the verifier in the browser. Python's
json.dumpsescapes non-ASCII by default andJSON.stringifydoes not, so the two would have produced different hashes for the first receipt whose subject contained an umlaut, and only for that one. A chain that breaks for one entry in a thousand is worse than one that never worked, because you will believe the file was tampered with. It is pinned now, and a cross-check lifts the field list and the hash function out of the live verifier page and compares them against the Python, so a future edit to one side fails a check instead of a verification. - Read the tail, sign, and append under one lock. Two agents attesting in the same second would otherwise read the same tail, compute the same
prev, and one of them writes a link nobody can reproduce. - Receipts written before the chain existed carry no
seq. They are left exactly as they are and the chain restarts at genesis after them. Retro-fitting links onto them would have been easy and would have been a lie: it would claim coverage over a period nothing was covering.
The verifier walks it now
The public page checked each signature and would have shown all green for a log with a receipt cut out of the middle. It now walks the chain as well: seq must equal its own index, and prev must be the hash of the receipt before it. The index check is what catches a truncated head, where a walk that only compared each row to the previous row would happily start halfway through a file and call it intact.
We know the check bites because we broke it on purpose, twice. Weakening the walk to compare only prev let a deleted receipt through. Leaving the content digest out of the link let an edited receipt through. Each one was caught by a different assertion. A test that still passes on deliberately broken code is worse than no test, because it is a claim.
What it still does not prove
The chain proves the log has not been edited since it was written. It does not prove something was written in the first place: an action that never reached the log leaves no gap to find. It does not say who is allowed to ask for a signature, which is a separate piece of work and the honest reason the next thing on our list is the credential, not more cryptography.
And unlike the NautGate log, which signs only the head of its chain, receipts are signed one by one. Each attestation is already a network round trip to the HSM, so the head-only optimisation buys nothing here, and per-receipt signatures mean a single receipt can be handed to somebody and verified entirely on its own.
So the sentence now reads: every action is hashed locally, signed on a Securosys HSM, and chained to the receipt before it. Remove one and the chain says so. Longer than the original. It has the advantage of being true.