SCITT: capsules, and the log that answers questions about them
Two things were missing, and they are the two halves of the same claim.
AEGIS produced an Agent Action Capsule payload that conformed to draft-mih-scitt-agent-action-capsule-02 and wrapped it in a signature of its own invention, which no SCITT tool can read. And it kept an append-only log with a Merkle tree head that nobody could query — an audit trail, not a transparency service. A receipt is only worth having if a third party can check it without trusting whoever handed it over, and that needs both a format their tools already read and a service they can ask.
The signed statement
signCapsuleStatement produces a tagged COSE_Sign1 (RFC 9052) whose payload is
the capsule as JSON. §3.1 of the draft fixes what the protected header carries:
| Label | Parameter | Value |
|---|---|---|
| 1 | alg |
EdDSA (-8) |
| 3 | content type |
application/agent-action-capsule+json |
| 4 | kid |
the AEGIS signing key id, as UTF-8 |
| 15 | CWT claims (RFC 9597) | iss, sub, and the closed capsule_* set |
sub is urn:agent-action-capsule:OPERATOR:ACTION_ID. The capsule_* claim set
is closed — the draft forbids extensions in the protected header — so
buildProtectedHeader refuses an unknown claim rather than passing it through.
Every COSE decoder would happily read one, which is exactly why the producer has
to be the party that refuses.
capsule_id was wrong, and is now right
It used to be the AEGIS receipt hash. That is 64 hex characters and commits to
every field of the receipt, so it looked correct. It is not what the draft says:
capsule_id is HEX(SHA-256(JCS(normalize(v)))) over the capsule itself with
capsule_id and the chain-linkage fields removed. Anyone recomputing the
identifier the way the specification describes would have got a different answer
and concluded the capsule had been tampered with.
verifyCapsuleId recomputes it, and the Rust verifier does the same
independently.
The encoder, and why it is hand-written
src/cbor.ts and src/cose.ts implement a strict subset: encoding only, no
floats, no indefinite lengths, no decoder. SECURITY.md asks for few, boring,
widely reviewed dependencies, and a CBOR library is neither small nor boring.
That is only defensible because something which is not AEGIS reads the result. Two independent checks:
test/cbor.test.tsreplays the encoding examples from RFC 8949 Appendix A, written years earlier by people with no knowledge of this code.scripts/verifier-check.shhands every envelope to the Rust verifier, which decodes it withcosetandciboriumand verifies the Ed25519 signature over aSig_structureit rebuilds itself.
A hand-rolled encoder checked by a hand-rolled decoder would agree with itself
and prove nothing. The cross-language check also refuses five deliberately
tampered envelopes — a flipped signature byte, a payload rewritten after
signing, a capsule_id that no longer matches, a claim outside the closed set,
and an untagged envelope — because a verifier that accepts everything passes the
positive case too.
The one thing that is never hand-written is a decoder for untrusted bytes. The service below does not parse what it is given, and the only CBOR parsing in the project is in a third party's library, in the verifier.
The transparency service
The shape is SCRAPI, the SCITT Reference API:
| Endpoint | Media type | Answers | |
|---|---|---|---|
GET |
/.well-known/scitt-keys |
application/cbor |
the COSE Key Set |
GET |
/.well-known/scitt-keys/{kid} |
application/cbor |
one key, or 404 |
POST |
/entries |
application/cose |
201 and a receipt |
GET |
/entries/{EntryID} |
application/cose |
200, or 404 |
These are unauthenticated on purpose. A transparency service that answers only people it already trusts is not one: the value is that somebody handed a receipt can check it without an account, and every byte returned is signed and public by construction.
EntryID is the SHA-256 of the registered bytes, so a holder computes it
themselves and can find their entry without having kept the receipt. Registering
the same bytes twice is one entry, not two.
The registration policy, stated plainly
The service does not decode what it is given. It accepts application/cose
bytes, bounds their size at 64 KiB, commits to their digest, and returns a
receipt saying so. It makes no claim about what the bytes mean, who signed them,
or whether that signature verifies.
A caller may therefore register anything that fits. The receipt proves the bytes
were registered at a position in an append-only log, and proves nothing else. A
holder who wants to know whether the statement is good decodes it themselves —
which is what aegis-verify capsule does.
Which build issued this? (the compliance profile)
Every supply-chain incident starts with the same question, and until now a
holder checking a signed AEGIS action could not ask it. The capsule payload now
carries a build_provenance block using the field names of §5.9 of
draft-marques-asqav-compliance-receipts-08 — verbatim, and naming its
own profile, so a reader knows which document defines them.
| Field | Filled from | Present today |
|---|---|---|
sbom_digest |
SHA-256 of the CycloneDX SBOM that ships with the release | yes |
release, source_commit |
the tag and commit the release was built from | yes |
executable_hash |
the container image digest the deployment supplies | only when supplied |
slsa_provenance_pointer |
an HTTPS URL to a SLSA v1.0 in-toto attestation envelope | no — see below |
supply_chain_pointer |
an HTTPS URL to a transparency-log entry | no |
Every field is derived from an artefact that shipped, or it is absent. A
configured string an operator can set to anything is worse than a missing field:
the missing one is honest and the configured one looks like evidence. So
scripts/record-build-provenance.ts hashes the SBOM file rather than accepting a
digest as an argument, and the release workflow then re-derives that digest from
the SBOM it publishes and fails the release if the two disagree. Without that
step the block would be a string somebody typed.
The block rides in the capsule payload, which is where §9 of the capsule draft
says extensions belong — the protected header is closed. It is therefore covered
by the same signature as the action, and by capsule_id.
Why slsa_provenance_pointer is absent
The profile says it resolves to a SLSA v1.0 in-toto attestation envelope. What the release writes today is a bare in-toto Statement: GitHub refuses to sign and persist attestations for a user-owned private repository, so the workflow writes the statement as a file that says, in its own text, that it is not evidence. A pointer to it would promise an envelope that does not exist.
record-build-provenance.ts refuses --slsa-provenance unless --attested is
also passed, and the release workflow passes --attested under exactly the same
condition it uses to decide whether to attest at all. There is one decision, so
the pointer and the attestation cannot disagree. The day the repository can carry
an attestation, the field appears and nothing else changes.
What else the profile defines
§5.9 is one section of a larger receipt format — a three-key payload /
signature / anchors envelope with decision fields, validity windows, RFC 3161
anchoring and a controls_evaluated set. AEGIS implements the build-provenance
extension and nothing else of it. The rest is a different document shape from
the SCITT capsule, and claiming the profile as a whole would be claiming an
anchoring story AEGIS does not have.
What is conformant, and what is not
| Status | |
|---|---|
| Capsule payload | Conformant to draft-mih-scitt-agent-action-capsule-02 |
| Signed statement envelope | COSE_Sign1 with the §3.1 protected header — conformant |
capsule_id |
Computed per the draft, and independently recomputed |
| SCRAPI endpoints, methods, media types, status codes | Follow draft-ietf-scitt-scrapi |
| COSE Key Set | RFC 9052 §7, read back by a third-party COSE library |
| Build provenance block | §5.9 of draft-marques-asqav — that section only |
| Receipt body | Not draft-ietf-cose-merkle-tree-proofs |
The last row is the one to read. A fully conformant receipt would carry its
inclusion proof in the COSE format that draft defines. AEGIS carries an AEGIS
transparency receipt in JSON, inside a COSE_Sign1 with its own content type —
application/aegis-transparency-receipt+json — so no reader can mistake it for
the standard one. A holder verifies the envelope with any COSE library and the
inclusion proof with aegis-verify inclusion, which is a complete chain. But it
is AEGIS's chain, and calling it SCITT-conformant would be the kind of claim this
project exists to refuse.
Registration in somebody else's Transparency Service is also not implemented: AEGIS is the log here, not a client of one.
Checking a receipt yourself
curl -sO https://aegis.example/.well-known/scitt-keys
aegis-verify scitt-keys scitt-keys # prints "<kid> <public key hex>"
aegis-verify capsule statement.cose key.hex # signature, header, capsule_id
aegis-verify inclusion proof.json # the Merkle path, against the head
The key comes from the service's published key set rather than from whoever gave
you the capsule — scripts/verifier-check.sh runs exactly this sequence, so the
path a holder takes is the path CI exercises.
The same sequence is available as a GitHub Action, so a consumer can put it in their own pipeline rather than in a shell script:
- uses: Oshinsu/aegis-control/.github/actions/verify@main
with:
release: v0.15.0
capsule: ./receipt.cose
scitt-keys: ./scitt-keys.cbor
See .github/actions/verify/README.md,
including the paragraph on what "anyone can check this" does and does not mean
while the repository is private.