AEGISCONTROL

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:

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.