TT Lab
Get started
Learn Learning paths Courses

Air-Gapped Sites — Defence and Government

The Signature Verified, but the Attestation Was About Another File

Continue in TT Lab

In one line

Supply chain attestations (SBOM · in-toto Statement · SLSA Provenance) become evidence not when the signature is valid, but only after you confirm that the digest of the subject the document points to equals the fingerprint of the file you are holding right now. A signature says the document has not changed, and what connects the artifact to the document is not the signature but the digest.

Why this was needed

An air-gapped import review usually runs like this. A single medium comes in, and inside it, along with the artifact, are a few documents. A parts list (SBOM), a build history (Provenance), a test sign-off. The reviewer runs a signature verification command once and stamps "verification passed." The procedure document says to do exactly that.

But if you take apart what that command actually confirmed, all it confirmed is that one document file has not changed since it was signed. The artifact does not even appear in that command. So if you attach a validly signed attestation to an old version that was already approved last quarter and send a new binary along with it, a procedure that looks only at the signature passes it straight through. The fact that whoever can edit a document can also re-attach a signature drops out entirely the moment you make the signature the procedure's last gate.

As attestations have multiplied, this trap has actually grown. When there is one document, a person opens it, but when there are three or five, a tool skims them and the person looks only at the result. Nobody asks again what the tool confirms and what it does not.

How it works

If you divide an attestation into three layers, you will not get confused.

The in-toto Statement v1 holds this structure as is in four fields. _type is always https://in-toto.io/Statement/v1, subject is an array of the artifacts this attestation applies to, predicateType is a URI that points to the kind of claim, and predicate is the content of the claim. The document stipulates that every element of the subject must have a digest, and separately states that artifacts are matched solely by digest, not by name. A name is only a distinguishing label, so writing it differently changes nothing. The elements of the subject are in a common format called ResourceDescriptor, and at least one of uri, digest, or content must be present.

If predicateType is https://slsa.dev/provenance/v1, the predicate is SLSA Provenance v1. It contains two blocks, buildDefinition and runDetails. buildDefinition has buildType, which points to the template of the build, externalParameters, the inputs that came from outside, internalParameters, which the platform inserted itself, and resolvedDependencies, the list of things the build pulled in. runDetails has builder.id, which points to the platform that ran the build, and invocationId, startedOn, and finishedOn in metadata. The specification states separately that externalParameters should not be trusted and must be verified downstream, and that internalParameters need not be verified because the platform is already trusted. This distinction matters for one reason — it means the document has already divided in advance the places to verify from the places to trust.

And there is something Provenance does not say. The Build track of the SLSA levels document says its purpose is to make it possible to verify "whether the artifact was built as expected." It deals with who built it and by what process, but whether the source went through review and whether the dependencies have known vulnerabilities are not the Build track's job. The document itself says that L1 requires only that the attestation exist and that forging it is easy. L2 adds dedicated infrastructure and signing, and L3 isolates the signing key so that the build step cannot reach it. So saying "a SLSA attestation exists" means almost nothing without the level.

A parts list comes as CycloneDX or SPDX. In CycloneDX 1.6 JSON, bomFormat is CycloneDX, specVersion is the version number, components is the array of components, and type and name are required for each component. The fingerprint goes into the hashes array in the form {"alg": "SHA-256", "content": "..."}. The place where people often stumble here is metadata.component. That is the subject this SBOM describes, not an element of the component list.

Finally, an air-gapped network takes away one more thing. A Sigstore-style flow assumes that signatures are posted to a transparency log and that the verifying side looks up that log to confirm, and if there is no way out of the network, that lookup cannot take place. So an import review report must separate "we confirmed it" from "there is no way to confirm it on this network."

문서 층   서명 검증        → 이 문서가 안 바뀌었다
결속 층   subject.digest   → 이 문서는 '이 파일' 얘기다     ← 여기가 비면 나머지가 다 헛것
주장 층   predicate        → 그래서 이렇게 만들어졌다고 주장한다

What it looks like in the field

The most common accident is an attestation swap. The signature is valid but the subject points to a different file. If you make it point to an old version, it passes even more easily because it is "the attestation that passed last time." There is only one way to catch this: recompute the fingerprint from the file you received and check it against the subject. If this one line is not in the procedure document, the rest of the verification is decoration.

The second is comparing an SBOM in only one direction. If you skim based on the document, files the document does not mention come in quietly, and if you skim based on the tree, you cannot see ghost entries that exist only in the document. The case where the name is on both sides but only the fingerprint differs is yet another kind, and a comparison that only matches up the lists misses it entirely.

The third is when you meet an unknown predicate. If predicateType is a URI we do not know how to read, that document cannot be a basis for passing. But it is not a basis for rejecting either. The parsing rules of in-toto recommend writing the policy monotonically — write it not as "block if there is bad evidence" but as "pass only if there is good evidence." If you write it that way, failing to read one attestation merely blocks the pass, and failing to read never opens the pass.

The fourth is the public key. If the public key used for verification came on the same medium as the attestation, whoever holds that medium can swap in the document, the signature, and the public key all at once. The signature verification result is still a pass. So the review record notes separately that the signature is valid and confirmation that the key belongs to the ordering party. The latter cannot be confirmed without a fingerprint received by a separate route.

What you will do in the next lab

You reproduce, inside the network, the artifact the ordering party sent and three kinds of attestation, and compare them in eight steps. You match the artifact fingerprint against the subject, compare the SBOM in both directions, separate the questions Provenance can answer from those it cannot, run signature verification once, and then build yourself an attestation whose signature stays valid while only the subject points to an old version, reproducing "signature OK · comparison FAIL" as two layers. At the end you leave an import review report that separates what you confirmed from what you could not confirm.