in-toto attestations
Subject, predicate, and composing evidence.
A wax seal on a letter tells you who closed the envelope. It tells you nothing about what somebody slipped inside before the wax went on. That is the gap attackers walked through in March 2023, when a backdoored build of the 3CX desktop phone app shipped to customers. It was correctly code-signed by the vendor, downloaded from the vendor's own update servers, and trusted by every endpoint that installed it. The signature proved who signed the installer. It said nothing about how the installer was built, from which commit, or with which dependencies, so tampering injected upstream of the signing step sailed straight through. in-toto closes that gap. It is an open framework for making signed, machine-checkable claims about an artifact. Instead of "someone signed these bytes", the claim reads "this exact digest came out of this pipeline, from this source commit, using these inputs". SLSA provenance (SLSA is Supply-chain Levels for Software Artifacts), SBOMs (software bills of materials), scan results and verification summaries are all in-toto attestations, so learning the wire format once lets you produce and verify every kind of evidence in this course.
The envelope, and what the signature really covers
Every in-toto attestation rides inside a DSSE envelope. DSSE stands for Dead Simple Signing Envelope, and it is a JSON object with three fields: payloadType (a string saying what kind of thing is inside), payload (the claim itself, base64-encoded) and signatures (an array). The design choice worth slowing down for is what actually gets signed. Not the raw payload bytes. Not the pretty-printed JSON either, because two libraries will order keys and place whitespace differently, and that kind of ambiguity is a signature bypass waiting to be found. What gets signed is a Pre-Authentication Encoding, written PAE: a deterministic, length-prefixed rewrite that pins down both the payload and the type it claims to be.
PAE is assembled like a shipping label glued shut with the box, so the label cannot be peeled off and stuck on a different parcel. First the ASCII string DSSEv1, then the byte-length of the payloadType followed by the payloadType itself, then the byte-length of the payload followed by the payload, one space between each field: DSSEv1 SP len(type) SP type SP len(payload) SP payload. Because the payloadType sits inside the signed bytes, nobody can take a valid signature over an SBOM and relabel the envelope as provenance. The declared type is part of the message that was signed. And because every field carries its own length up front, verification never depends on how some JSON library canonicalizes whitespace. To check a signature, a consumer rebuilds the identical PAE bytes from the envelope it received and tests them against the signer's public key, or with keyless signing, against the public key in the attached Fulcio certificate (Fulcio is Sigstore's certificate authority, which hands out short-lived certificates bound to an identity).
# cosign stores each attestation as an OCI artifact attached to the image digest.cosign download attestation \ghcr.io/acme/api@sha256:9f2c3d1b7e4a2c8f6b0d5e9a1c4f7b2e8d3a6c0f5b9e2d7a4c1f8b3e6d9a2c5f
{"payloadType": "application/vnd.in-toto+json","payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiZ2hjci5pby9hY21lL2FwaSIsImRpZ2VzdCI6eyJzaGEyNTYiOiI5ZjJjM2QuLi4ifX1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YxIiwicHJlZGljYXRlIjp7Li4ufX0=","signatures": [{"keyid": "","sig": "MEUCIQD8n0m6Yb1r2Qp9c...c2VkX2J5X0Z1bGNpbw=="}]}
Open it up: the in-toto Statement
Base64-decode the payload and out comes the in-toto Statement, the actual claim, in a shape that stays the same no matter what the claim is about. _type pins the schema version (https://in-toto.io/Statement/v1). subject is an array of artifacts, each one named and, more importantly, fingerprinted with a cryptographic digest. That digest welds the claim to specific bytes rather than to a tag or a filename, either of which anyone with push access can move somewhere else. predicateType is a URI naming the kind of claim, and predicate carries its body. In the decode below the predicate is SLSA provenance v1: a buildDefinition (the buildType, the externalParameters that drove the build, and resolvedDependencies, meaning the pinned source commit and inputs) plus runDetails (the builder.id and run metadata). Change predicateType to https://cyclonedx.org/bom or https://spdx.dev/Document and the very same envelope now carries an SBOM. One shape, many claims. That is why a single verifier can reason over all of them.
cosign download attestation ghcr.io/acme/api@sha256:9f2c3d... \| jq -r '.payload' \| base64 -d \| jq .
{"_type": "https://in-toto.io/Statement/v1","subject": [{"name": "ghcr.io/acme/api","digest": { "sha256": "9f2c3d1b7e4a2c8f6b0d5e9a1c4f7b2e8d3a6c0f5b9e2d7a4c1f8b3e6d9a2c5f" }}],"predicateType": "https://slsa.dev/provenance/v1","predicate": {"buildDefinition": {"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1","externalParameters": {"workflow": {"ref": "refs/heads/main","repository": "https://github.com/acme/api","path": ".github/workflows/release.yml"}},"resolvedDependencies": [{"uri": "git+https://github.com/acme/api@refs/heads/main","digest": { "gitCommit": "3f9a1c0d7b4e2a6c8f1b5d0e9a3c7f2b4d6e8a10" }}]},"runDetails": {"builder": { "id": "https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main" },"metadata": {"invocationId": "https://github.com/acme/api/actions/runs/1029384756/attempts/1","startedOn": "2026-07-14T09:22:41Z"}}}}
Verification asks three separate questions
Who signed it, which bytes does it cover, and what does it actually say. Plenty of teams assume one command settles all three. Question one: who signed it? cosign rebuilds the PAE and, for keyless signing, checks the DSSE signature against the short-lived Fulcio certificate carried in the envelope. It then checks that certificate's Subject Alternative Name (the identity field baked into the certificate, here the workflow reference) against whatever you pin with --certificate-identity, and the certificate's issuer against --certificate-oidc-issuer (OIDC is OpenID Connect, the login protocol your CI system uses to prove which workflow it is). Pinning that name is how you state exactly which pipeline is allowed to make this claim. Leave the pins off and any identity in the public Fulcio root satisfies the check. Question two: which bytes? You pass the image by digest, so cosign fetches only the attestations attached to that digest. Question three: what does the claim say? Here is the trap. cosign confirms the envelope is provenance-typed, signed by a trusted identity, and recorded in Rekor (Sigstore's public, append-only transparency log). It does not read the predicate. Whether the builder id, the source repository and the SLSA level clear your bar is a policy question, and you enforce it on the decoded payload yourself or hand it to a policy engine.
cosign verify-attestation \--type slsaprovenance1 \--certificate-identity "https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main" \--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ghcr.io/acme/api@sha256:9f2c3d... \| jq -r '.payload | @base64d | fromjson | .predicateType'
Verification for ghcr.io/acme/api@sha256:9f2c3d... --The following checks were performed on each of these signatures:- The cosign claims were validated- Existence of the claims in the transparency log was verified offline- The code-signing certificate was verified using trusted certificate authority certificatesCertificate subject: https://github.com/acme/api/.github/workflows/release.yml@refs/heads/mainCertificate issuer URL: https://token.actions.githubusercontent.comhttps://slsa.dev/provenance/v1
Where this came from: layouts, links and MATCH rules
The attestation framework above grew out of classic in-toto's layout model, which is still running at projects like Debian and the reproducible-builds rebuilders. It works like the chain-of-custody form taped to a shipment. Every handler signs for what they received and what they passed on, and the next handler's line has to match the previous one. A project owner writes a signed layout declaring the ordered steps of the supply chain (clone, build, package) and, for each step, which functionary (a person or a machine, identified by key) is allowed to run it and which artifact rules must hold. When a functionary runs a step, in-toto-run wraps the command and emits a signed link file recording the materials it consumed and the products it produced. in-toto-verify then checks the layout's signature against the owner's key, checks that each link is signed by an authorized functionary, and enforces the artifact rules. MATCH rules require the products of one step to equal the materials of the next, so nothing was swapped in between the clone and the build, or the build and the package. The modern attestation framework replaces the link with typed predicates (provenance, SBOM, or a VSA, short for verification summary attestation), but the idea underneath is the same: signed, digest-bound claims chained end to end, with policy deciding which combination is enough.
# A functionary runs the build step; in-toto-run records materials + products# and signs a link file (build.<keyid>.link).in-toto-run --step-name build --signing-key functionary.key \--materials . --products dist/api \-- make build# The owner (or a downstream consumer) verifies the layout end to end.# --verification-keys holds the owner public key(s) that signed root.layout;# --verbose surfaces the INFO-level progress log below.in-toto-verify --layout root.layout --verification-keys owner.pub --verbose; echo "exit=$?"
Verifying layout metadata signatures...Verifying layout expiration...Reading link metadata files...Verifying link metadata signatures...Verifying sublayouts...Verifying alignment of reported commands...Verifying threshold constraints...Verifying Step rules...Executing Inspection commands...Verifying Inspection rules...The software product passed all verification.exit=0
Try this
Work through “Where this came from: layouts, links and MATCH rules” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: passing verify-attestation is not passing your policy. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.