The problem in the field
I spent a career handing evidence to people who had every reason to doubt it. Chain of custody was the answer: who had it, when, and what they did with it, written down in a way a stranger could check. Agents are now doing things that used to need a person with a badge. Unlocking a door. Moving a camera. Tagging a clip that ends up in a case file. The question I keep coming back to is what must be independently provable about what an agent saw, inferred, was authorized to do, and actually did, before its output triggers physical-world action or becomes evidence.
Why the usual answers fail
The audit trail lives in the platform that did the work, so a compromised or careless platform grades its own homework. Reading the trail often needs an account and a license from the vendor. And JSON logs are easy to write and hard to sign, because two encoders will serialize the same record two ways.
What I built
A small wire spec and a reference verifier. Receipts are deterministic CBOR with a detached COSE_Sign1 signature (ES256), closed schemas, and normative CDDL. The verifier, pyref, is Python 3.11 standard library only. No network, no wall clock, no dependencies. Verifying receipts is free, forever, offline, with no account and no license from anyone. The conformance vectors are byte-pinned known-answer tests generated by a second, clean-room implementation in TypeScript, which I also wrote, and the threat model, v0.1, lists 30 findings. The reference producer is onvif-mcp. Since October 12 it writes v0.2.3. All 22 live bundles it has written since, snapshots from my three lab cameras, verify as conformant in pyref v0.4.0, re-run October 29. Its older receipts stay in the August pre-release format they were signed in.
How it works
A producer signs each receipt and links it to the previous one. The verifier runs twenty checks in a fixed order, from the size of the bundle to the signed verdict, and names the first one that fails. This is a test bundle from the repo, so you can run it yourself:
$ python3 -B -m pyref verify kats/positive/bundle-valid-subset.cbor --at 1735689800 AAR offline verification report Bundle: kats/positive/bundle-valid-subset.cbor Evaluation time: 1735689800 Network: not used (all inputs are local) Trust policy: producer-declared only Result: conformant Steps: 01 PASS Bundle byte limit 02 PASS Bundle CBOR 03 PASS Bundle schema 04 PASS Static resource counts 05 PASS Trust-policy input 06 PASS Envelope mechanics 07 PASS Content commitments and IDs 08 PASS Credential lifecycle 09 PASS Emission identity 10 PASS Receipt schema semantics 11 PASS Replay and freshness 12 PASS Referential closure and graph 13 PASS Authorization dominance 14 PASS Epoch state machine 15 PASS Manifest index 16 PASS Merkle batches 17 PASS Anchors 18 PASS Bundle ranges and coverage 19 PASS Evidence-class qualification 20 PASS Verdict First failure: none
What that gives you: attribution to a signer, tamper evidence after emission, and tamper evidence over the declared contents of an epoch. What it does not give you: it can't stop a compromised signer from lying before it signs, it can't prove an inference was correct, and it doesn't establish legal compliance with anything. I'd rather say that here than have someone find out in a hearing.
Where it stands
A stable experimental spec, not an adopted standard. Both implementations are mine, so nobody independent has built one yet, and that gap matters, because the best bug I've found so far came from a cross-check. The reference CBOR encoder wrote every unsigned integer between 2^24 and 2^32 wrong. That range covers every real Unix timestamp. All 188 fixtures stayed green because they came from the same encoder, and pyref read those bytes back the same way. It took a demo run with a real, current timestamp: pyref decoded the bundle and disagreed with the time it was handed. One implementation agreeing with itself proves nothing. An October 8 audit found the two still differed on 40 of 232 test fixtures, signing different verdicts, and on one of those the result itself was different. v0.2.5 closed that. Given the same inputs and the same verifier identity, every one of the 478 fixtures in the suite got the same signed verdict from both, byte for byte; v0.3.0 added 35 more, run under an outside party's own trust file and key, v0.3.1 added 19 that are checked against an examiner's record of what they had already seen, and v0.4.0 added 18 more. The count is 550. That claim stops at what the fixtures pin. A case nobody wrote a fixture for isn't covered, and I don't claim it.
What's next
v0.4.0 is tagged, as of October 29, and it settles the gap v0.3.1 left open. An examiner who saw only the newest receipt, and later got the full set, was told the older ones were a rollback. Nothing in a receipt says when a receipt you never saw was made, so calling that tampering was a guess presented as a finding. On the stand, that's the difference between "someone altered this" and "I never had those." Now it doesn't fail the bundle. The signed verdict carries a note instead. It's worth a question. It proves nothing. In place of the guess, the verifier uses evidence the examiner actually kept. At the end of each period the enforcement point seals a log that lists every receipt in it and signs the list. An examiner who saved earlier sealed logs can hand them back to the verifier, which checks each one again as of its own date and then holds the new bundle against them. A receipt that claims a spot in a saved log but isn't on its list fails. So does a second, different log for the same period. Periods the examiner never saved are marked not established in the signed verdict, so nobody reads more into it than is there. Every comparison against the examiner's history now runs last, so two examiners holding different history files still agree on what's wrong with the bundle itself. The reviews also turned up older problems. A bundle could carry a receipt its own sealed log didn't list and pass in both verifiers. A text field starting with an invisible character got different answers from the two. And a 16 MB file built the right way could push one verifier past 4 GB of memory. Those are fixed and pinned by tests. Every older test case keeps its outcome except twelve the new rules move on purpose, and the 22 live onvif-mcp bundles still verify as conformant. One cost is written down for the next round. That verifier still needs about 2 GB of memory for one kind of 12 MB file, several times what the other needs.
v0.3.1 is tagged, as of October 26. It came out of a fuzz tester I added after v0.3.0. The tester takes real bundles, changes them, and checks whether the two verifiers still agree. In two overnight runs it found six bundles where both verifiers said no, but for different reasons. One called it a rollback. The other said two receipts were claiming the same place in line. On the stand, that is the other side asking why your own tools can't agree. The spec named both problems and never said which one wins, so I wrote the rule: check the bundle against itself first, then against what the examiner has already seen, one kind of problem at a time. Writing the rule down, and the reviews after it, turned up three more problems. A new receipt could pass even when it claimed a place in line the examiner had already seen taken by a different receipt. The same receipt shown a second time, byte for byte, was flagged as a rollback. And a damaged history file could get a conformant verdict signed over it by one verifier while the other refused it. All three are fixed and pinned by tests, and both verifiers now check the examiner's history file the same way before they start checking the bundle. Older bundles get the same result they got before; their signed verdicts change only because the verifier's version is inside them. One gap stayed open: an examiner who saw only the newest receipt, and later got the full set, saw the older ones flagged as a rollback.
v0.3.0, tagged October 21, was the first release that lets someone other than the producer check a bundle on their own terms. Until now the verifier could only check a bundle against the trust roots the producer declared inside it, and could only sign its result with a public test key. Now a relying party, a defense examiner for instance, supplies their own list of acceptable roots, sites and key usages, and their own signing key, in a small file the verifier validates before it reads a single bundle byte. Every credential in the bundle has to fit inside that list, the verifier's own keys can never appear on the artifact side, and the signed verdict names the examiner's key and binds a digest of the file that governed it. The examiner's package for a court is three things: bundle, policy, verdict. The same release adds one line to every verdict, control_path_completeness: not_established: the record cannot know about a camera moved from a VMS console or by hand, and now it says so in the signed bytes rather than in a document. Two things it does not do, stated in the spec: an examiner's own key has no revocation yet, only a validity window, and nothing proves which verifier code ran. Before v0.3.0, since v0.2.0 in September, there were five numbered releases, each closing a hole a review found: the signed verdict now commits to the prior history it was judged against (v0.2.1), every signed object has to be signed by the key it names (v0.2.2), and a receipt can declare where an action's inputs came from, such as a camera, the operator or a third party, which the verifier passes on as a stated limit rather than a fact (v0.2.3). v0.2.4 closed three more: a permission can only be used by the enforcement point it was issued to, the piece that actually sends the command; a key change has to be signed by whoever issued the old key, and the key changes a bundle carries for one site have to be in order; and a receipt signed before its key was revoked still verifies, while one signed after does not. v0.2.5 changed nothing on the wire. It made the two verifiers agree. The conformance suite runs in CI on every change. The part of what's left that I can't do myself is one external producer emitting receipts that pyref verifies without my help. That external run, or an acknowledgement from the people writing the adjacent standards, is the signal I'm watching for. The wire may still change in response to it.
- github.com/oneshot2001/aar
- onvif-mcp, the reference producer