Virtualization · Image Supply Chain

Content Digests and Why an Image Tag Is Not an Identity

How a digest names image bytes rather than a name, what the verification chain from tag to unpacked filesystem actually checks, and where that chain is weaker than it looks.

A container image is fetched by name and verified by number. The name is a repository and a tag, something like library/nginx:1.27. The number is a digest, something like sha256:9f2c.... These two identifiers have almost nothing in common, and the entire trust story of image distribution turns on the difference. A tag is a mutable pointer stored in a registry’s database. A digest is a claim about bytes, and it can be checked by anyone holding the bytes, without asking the registry whether the claim is true.

The registry treats a tag as a row it can overwrite

The distribution API exposes tags through a single manifest endpoint. Pushing a manifest to /v2/<name>/manifests/<reference> creates or replaces whatever was there; there is no compare-and-swap in the base protocol, and no history. A registry that accepts latest today will accept a different manifest under latest a minute later, and a client that resolves the tag twice can receive two unrelated artifacts. Nothing in the protocol reports that the substitution happened. The tag’s meaning is whatever the registry says at the moment of the lookup.

Registries commonly keep a small amount of tag metadata (a creation timestamp, sometimes an immutability flag on specific repositories), but these are registry policy, not protocol semantics. A mirror or a different vendor’s implementation may not offer them at all. Code that treats myapp:prod as a stable reference is relying on an operational convention enforced by whoever holds the push credentials.

Digests are different in kind. When a client asks for a manifest by digest, the registry returns the bytes and the client hashes them. If the hash does not match the requested digest, the client discards the response. The registry cannot lie successfully about content it has already committed to by hash, because the hash is computed over the content by the client. This is the property that makes a digest usable as a deployment reference in a way a tag is not: it is self-certifying.

A digest names a serialized object, not an abstract one

The hash input is the exact byte sequence of a stored object, and this has consequences that surprise people.

A layer digest is computed over the compressed blob as it sits in the registry, not over the tar stream inside it. Two builds that produce byte-identical filesystem contents can produce different layer digests if their compression settings differ, because gzip output depends on the deflate implementation, the compression level, and the header fields. A tar archive that stores mtimes, uid/gid, or file ordering differently is a different tar archive, and therefore a different blob even before compression. Content addressing identifies bytes, and the bytes carry metadata.

The same applies to manifests. The OCI image specification requires that a digest be taken over the manifest document exactly as transmitted. A producer that parses a manifest into a data structure and re-serializes it before hashing will usually produce a different byte sequence (key order, whitespace, and number formatting all change) and therefore a different digest for what is semantically the same document. JSON canonicalization schemes exist, and RFC 8785 defines one, but the image format does not require canonical JSON. It requires byte preservation. Tooling that rewrites manifests must carry the original bytes forward if it wants the digest to survive.

The digest string itself has structure: an algorithm component, a colon, and lowercase hexadecimal. The algorithm prefix is not decorative. sha256:abc and sha512:abc are different identifiers, and a client that compares only the hexadecimal portion is comparing the wrong thing.

// Verify a fetched blob against its descriptor before use.
func verify(r io.Reader, d Descriptor) error {
    alg, hex, ok := strings.Cut(d.Digest, ":")
    if !ok {
        return fmt.Errorf("malformed digest %q", d.Digest)
    }
    h, ok := hashFor(alg) // sha256, sha512, ...
    if !ok {
        return fmt.Errorf("unsupported algorithm %q", alg)
    }
    n, err := io.Copy(h, r)
    if err != nil {
        return err
    }
    // The size field is a second, independent check on the same bytes.
    if d.Size >= 0 && n != d.Size {
        return fmt.Errorf("size %d, descriptor says %d", n, d.Size)
    }
    if !strings.EqualFold(hex.EncodeToString(h.Sum(nil)), hex) {
        return fmt.Errorf("digest mismatch for %s", d.Digest)
    }
    return nil
}

The Size field in a descriptor is redundant with the digest in the sense that a correct hash implies correct length, but it is not redundant in practice: it lets a client enforce a bound before reading, and it catches transport truncation that a lazy reader might otherwise treat as a short but valid stream.

Resolving nginx:1.27 to a running container passes through several objects, and each one is checked against a digest carried by its parent:

  1. The client resolves the tag to a manifest digest. This step is a registry lookup over TLS; there is no hash to check, because the digest is the answer, not the input.
  2. The client fetches the manifest by digest and hashes the body against that digest.
  3. The manifest names a config blob and an ordered list of layer blobs, each with a media type, digest, and size.
  4. Each blob is fetched and hashed before being written into the local content store.
  5. Layers are applied in order to construct the root filesystem; the config supplies environment, entrypoint, and history.

Steps 2 through 5 are self-verifying once step 1 is trusted. Step 1 is a plain authenticated lookup. An image pulled by tag is therefore only as trustworthy as the registry and the network path to it at the moment of the pull. An image pulled by digest removes step 1’s discretion: the client already knows the hash it expects, and a registry that serves anything else fails verification.

This asymmetry is why digest pinning appears in deployment manifests and admission policies. It converts a question of registry honesty into a question of hash preimage resistance.

Integrity is not authorship

A digest answers “are these the bytes that were named” and says nothing about who named them. Anyone can compute SHA-256 over a malicious layer and publish the result. A registry operator can serve a manifest under a digest of their choosing for content they wrote themselves. The hash binds content to an identifier; it does not bind the identifier to a person, an organisation, or a build process.

That gap is what signing fills, and it is why signature verification is always described in terms of a payload digest: the signature attests to a digest, and the digest attests to bytes. The three-part structure (key, digest, bytes) is the reason a signature can be checked offline, without the signer being online, and the reason a compromised registry cannot forge a valid signature over content it substituted. What an Image Signature Covers works through what exactly gets signed and which bytes the signature is bound to.

Where verification breaks in practice

Several failure modes recur, and most of them are about hashing the wrong bytes or trusting the wrong link.

Transport encoding. A registry may apply HTTP Content-Encoding: gzip to a blob response, and a layer blob is frequently gzip already. The digest is defined over the entity body after content-coding is removed. A client that hashes the bytes on the wire, or that stores them as received without decoding, computes a digest over a double-compressed stream and will reject a perfectly valid blob, or, worse, accept a blob whose stored bytes no longer match the descriptor that other tools will check.

Redirects and foreign URLs. Layer descriptors can point at URLs outside the registry, for content the registry is not permitted to redistribute. When a client follows such a URL, it is fetching from a host with no relationship to the registry, and the only protection is the digest check. If that check is skipped for “trusted” hosts, the chain is broken at that layer.

Digest of the wrong object. Manifests, configs, and layers all have digests, and the config blob’s digest appears both as a descriptor and inside provenance documents. Confusing the manifest digest with the config digest produces a verification that passes while checking an object nobody intended to check.

Prefix comparison and case. Comparing truncated hashes, or comparing hex strings case-sensitively against uppercase input, either weakens the check or rejects valid content. Truncation to a few bytes turns a 256-bit commitment into a checksum.

Garbage collection races. Because identical layers are shared across images, a registry that deletes a blob because one manifest stopped referencing it will break every other manifest that still does. Registries handle this with mark-and-sweep over all reachable manifests, and the window between marking and sweeping is a place where a pull can fail.

What content addressing buys, and what it costs

The trade is straightforward. Content addressing costs an extra indirection: every object must be fetched, hashed, and stored under its hash, which means a client cannot stream a layer directly into a filesystem without either buffering or trusting the stream. Registries pay for deduplication bookkeeping and reachability analysis. Build systems pay for the discipline of deterministic serialization.

In exchange, images become verifiable offline, layers become shareable across images without coordination, and a reference becomes something that can be copied between registries without loss of meaning. The next layer of the structure (how a manifest and an index describe their children, and how a client picks one) is where those descriptors are actually assembled into a graph. How an OCI Manifest and Index Name Their Layers covers that assembly, including the platform matching that decides which of several manifests a pull actually selects.

References

  1. OCI Image Format Specification — Open Container Initiative
  2. FIPS 180-4: Secure Hash Standard — NIST
  3. RFC 6920: Naming Things with Hashes — IETF
  4. OCI Distribution Specification — Open Container Initiative