What a signature says: this report came from that deployment, and the document it covers has not changed since. What it does not say: that the findings are correct, or that the run was certified.
That distinction is the whole reason this document exists. A green signature is the easiest thing in a report to over-read, and reading it as an endorsement of the analysis is reading it backwards.
A report carries input.source_sha256, analysis_context.instruction_sha256
and an execution_fingerprint per node. All three are unkeyed digests: a
reader recomputes a value from the artifact and notices when the two disagree.
Anyone who edits the report can recompute them too. They establish internal
consistency, and nothing whatsoever about origin.
An attestation is a signature by a key a deployment holds. It answers the two questions a digest cannot — which deployment and has it changed — and no others.
A detached signature, beside the report rather than inside it. A field inside the report would have to be excluded from its own coverage, and "everything except this one field" is a rule that grows exceptions.
{
"canonical_version": 1,
"payload_type": "application/vnd.work-agent.report+json",
"report_sha256": "…",
"key_id": "deployment-2026-09",
"signature": "…",
"signed_at": "2026-09-01T12:00:00Z"
}The signature is not over the report bytes. It is over the report digest bound to the canonicalization version, the payload type, the key id and the signing time, so a signature cannot be lifted onto another document, replayed under a different canonicalization, re-attributed to another key, or moved in time to predate a retirement.
What is digested is the canonical form of the parsed document, which is what
lets a verifier check a report without this project's models: a re-serialized
report is the same report. That puts the one remaining disagreement in the
parser rather than in the signature, so the verifier refuses a file that names a
key twice. json keeps the last of a repeated key and other parsers keep the
first, so such a file can say two things and verify as the harmless one. Nothing
this service produces repeats a key, so refusing costs a real report nothing.
signed_at is when the deployment signed. It is not evidence of when: a
machine's clock is not a trusted timestamp and nothing countersigns it. It is
recorded because a rotation policy is stated in time. Signing it makes it the
key holder's own claim and nobody else's; it does not make the claim true. A
deployment needing a trustworthy time needs a timestamping authority, which
this is not.
JSON, sorted keys, no insignificant whitespace, UTF-8, no ASCII escaping. That
is version 1, and the version is in the signed payload. A verifier meeting an
envelope on a version it does not implement returns unsupported and stops — it
does not guess, because a guess that produced the same bytes by luck would
validate a report nobody signed.
Ed25519, because it needs no parameter choices: no curve to pick, no hash to pair, no padding mode. The scheme with fewest ways to be configured wrongly is the right one for a signature nobody will tune.
python -m analysis_service.verify_report report.json \
--keyring keyring.toml --attestation report.sig.jsonIt reads JSON and never loads this project's models, so a report from a build whose schema has since moved still verifies, and a verifier written in another language can follow the same rules from the description above.
Six verdicts, each with its own exit code, because the states a caller must act on differently are more than two:
| Verdict | Exit | Meaning |
|---|---|---|
verified |
0 | Origin and integrity established. Nothing more. |
unsigned |
10 | No attestation supplied. An ordinary artifact from a deployment that does not sign — not a failure, and not trust. |
unknown-key |
11 | The key id is not in this keyring. The signature may be good to somebody who holds it. |
unsupported |
12 | A canonicalization or payload type this verifier does not implement. |
revoked |
13 | The signature checks out and the key is revoked. |
invalid |
14 | The signature fails, or a covered byte moved. |
Collapsing these into 0/1 would make "we do not sign" and "this key was compromised" the same event to anything automated.
Held by the verifier, never shipped beside a report — a report carrying the key that validates it would verify against itself.
version = 1
[[keys]]
key_id = "deployment-2026-09"
public_key = "<base64 raw Ed25519 public key>"
status = "active"
[[keys]]
key_id = "deployment-2026-03"
public_key = "<base64 raw Ed25519 public key>"
status = "retired"
retired_at = 2026-09-01T00:00:00ZLoading fails closed: an unreadable file, invalid TOML, a malformed key, a
retired key with no date, or a keyring naming no keys at all. The last one
matters — an empty keyring would answer unknown-key to everything, which reads
as a configuration problem rather than as the absence of every key.
| Status | What it means | What happens to old signatures |
|---|---|---|
active |
Currently signing. | Verified. |
retired |
Stopped signing at retired_at. |
Still verified, if they predate it. |
revoked |
Compromised. | Refused, including ones that predate the revocation. |
Two statuses would force a deployment to choose between keeping history verifiable and being able to disown a leaked key.
A retired key still verifies what it signed before retirement, because refusing it would invalidate every historical report on the day a deployment rotated. A revoked key verifies nothing, because an attacker holding it could backdate, so what it signed before anyone noticed is no safer than what it signed after.
Revocation is checked after the cryptography, not before. A revoked key id
over a forged document returns invalid, not revoked — an operator needs to
know whether the revoked key really signed this or whether somebody is replaying
its id, and those are different incidents.
- Generate a new key pair. The private key never enters this repository, a report, or a log.
- Add the new public key to every verifier's keyring as
active. - Switch the signer to the new key.
- Mark the old key
retiredwith the moment step 3 happened.
To revoke, set status = "revoked" instead and re-issue anything that mattered
under a key you still trust.