Virtualization · Image Supply Chain
Provenance Statements and the Subject Digest
How a build attestation names the artifact it describes, what the subject field binds, and why a valid attestation is not the same as an attestation about the image being deployed.
A signature answers whether a key vouched for some bytes. A provenance statement answers a different and harder question: which inputs, processed by which build system, produced these bytes. The two are usually stored together (the provenance statement is the payload inside a signature envelope) but they establish different facts, and a verification that checks only the signature has checked only the easier half.
A statement is a typed claim with a subject
The attestation framework defines a statement as a JSON document with a type, a subject list, and a predicate. The subject is an array of objects, each with a name and a digest. The predicate is the claim, and its shape depends on the predicate type declared in the statement.
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "ghcr.io/example/api",
"digest": { "sha256": "b1e4c0..." }
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://example.dev/buildtypes/v1",
"externalParameters": {},
"internalParameters": {},
"resolvedDependencies": []
},
"runDetails": {
"builder": { "id": "https://builder.example.dev/v1" },
"metadata": {}
}
}
}
The subject digest is the binding. A verifier that receives this statement and an image digest must compare them exactly: the algorithm and the hexadecimal value, not a prefix, and not a case-insensitive approximation unless both sides have been normalized. A statement whose subject digest is the manifest digest describes the manifest. A statement whose subject digest is the config digest describes the config, which is a different object that says nothing about layers. The most common provenance verification bug is a mismatch in which digest is the subject, and it produces a check that passes while describing something other than the artifact in hand.
The name field is informational. It carries a human-readable identifier, often the repository and tag at build time, and it is not authenticated in any way beyond being inside the signed payload. A verifier must not use the name to identify the artifact, because the same bytes can be pushed under any number of names after the statement is created, and the name in the statement reflects where the artifact was when the builder wrote it.
The predicate is where the interesting claims live
The statement’s outer structure is generic; the predicate carries the substance. For build provenance, the predicate records the build definition (the external parameters the caller supplied, the internal parameters the builder used, and the resolved dependencies with their digests) and the run details, which identify the builder and record metadata about the invocation.
The distinction between external and internal parameters is a trust statement. External parameters are what the person requesting the build controlled: the source repository, the commit, the build arguments. Internal parameters are what the build system chose: the builder image version, the platform, the toolchain. A verifier that only looks at external parameters has not established what the build actually ran with, and a builder that misclassifies a caller-controlled value as internal has produced a statement that understates the caller’s influence.
Resolved dependencies are the inputs, each with a digest. For a build that fetches a base image, the base image digest appears here. For a build that clones a repository, the commit digest appears here. This is the field that makes provenance useful for supply-chain questions: it says which upstream artifacts were consumed, and those digests can themselves be checked against policy or against their own provenance.
SLSA levels are frequently invoked in this context and frequently misunderstood. The levels describe the strength of the guarantees a builder can make about its own behaviour: whether the build runs on a controlled platform, whether the provenance is generated by the platform rather than by user-supplied scripts, whether the inputs are declared. A level is a property of the build system, not of an individual artifact, and a statement from a builder that meets a level is evidence about process rather than proof of intent.
Verification is a chain of comparisons, not a single check
Checking provenance properly is a sequence, and each step can fail independently.
- The envelope’s signature verifies against a key in the trust root.
- The payload parses as a statement of a recognized type.
- The predicate type is one the policy accepts.
- The subject digest equals the digest of the artifact being deployed.
- The builder identity in the predicate is one the policy trusts.
- The resolved dependencies satisfy whatever constraints the policy imposes: permitted registries, permitted base image digests, permitted source repositories.
- The external parameters match what the policy expects for this artifact, such as a source repository and branch.
Steps 1 through 4 are mechanical and cheap. Steps 5 through 7 are policy, and they are where most of the security value sits. A verifier that performs steps 1 through 4 and reports success has established that some trusted key signed a statement naming this digest, which is close to what a plain digest signature establishes. The additional value of provenance only materializes when the builder identity and the inputs are constrained.
def verify_provenance(env, image_digest, trust, policy):
payload = json.loads(base64.b64decode(env["payload"]))
if not any(verify_sig(s, payload, trust) for s in env["signatures"]):
raise Denied("no trusted signature")
if payload.get("_type") != STATEMENT_TYPE:
raise Denied("unknown statement type")
subjects = [s for s in payload["subject"]
if s["digest"].get("sha256") == image_digest]
if not subjects:
raise Denied("statement does not name this digest")
pred = payload["predicate"]
if pred["runDetails"]["builder"]["id"] not in policy.builders:
raise Denied("untrusted builder")
for dep in pred["buildDefinition"]["resolvedDependencies"]:
if not policy.dependency_ok(dep):
raise Denied("disallowed dependency")
return pred
The comparison in subjects uses the digest map’s key for the algorithm. A statement can carry several digests for the same subject, and a verifier should require the one it can check; accepting whichever algorithm the statement offers lets a signer downgrade to a weaker one.
The builder is the trust anchor, and it is also the attack surface
Provenance is only as good as the builder’s honesty, and a builder’s honesty is a property of its isolation. If the build platform generates the statement from data the build itself can influence, the statement can be made to describe a build that did not happen.
Three concrete failure shapes appear in the design of such systems.
Self-reported inputs. If the build script writes the resolved dependency list, a compromised build can omit a dependency it actually used. The remedy is for the platform to observe the inputs (resolve the repository reference itself, pull the base image itself) rather than trusting the build to report them.
Shared builder identity. If every build in an organisation runs under one builder identity, then provenance establishes “built by the organisation’s platform” and nothing finer. A policy that requires a specific builder identity for a specific artifact cannot distinguish a build of the artifact from a build of something else on the same platform. Per-workflow or per-repository identities make the identity field meaningful.
Unsigned intermediate steps. If the platform signs only the final statement, the intermediate artifacts (the fetched source, the base image, the intermediate layers) have no attested link to the final output. A verifier that checks the final statement and stops has verified the last step of a chain whose earlier steps are asserted only by the final statement’s contents.
This is the structural argument for reproducible builds, developed in Layer Cache Keys and What Makes a Build Reproducible: where the chain of attestations has gaps, an independent rebuild can close them by regenerating the artifact from the claimed inputs and comparing digests. The comparison is the evidence, and the attestation tells the rebuilder what to rebuild.
Provenance and signature are complementary, and both are needed
A signature without provenance says “a trusted party endorsed these bytes”. Provenance without a signature says “these bytes were built from these inputs”, with no statement about who is asserting it. Together they say “a trusted party asserts that these bytes came from these inputs”, which is the claim that actually supports a policy decision.
The ordering matters in implementation. Verify the envelope signature first, because an unsigned statement is unauthenticated input and parsing it before authentication exposes the verifier to whatever the payload contains. Then compare the subject digest, then apply policy. A verifier that applies policy to an unauthenticated statement can be induced to accept a forged builder identity, and the failure is invisible because the policy check succeeded on data the attacker supplied.
Where attestations are stored and how a verifier finds the ones relevant to a digest is a registry problem rather than a cryptographic one. The referrers mechanism that indexes artifacts by their subject is covered in Referrers, Attachments, and the Artifact Graph, including what happens when a registry does not implement it and clients fall back to naming conventions that can be spoofed.
References
- in-toto Attestation Framework Specification — in-toto project
- SLSA Specification — OpenSSF
- The Update Framework Specification — CNCF