Developer Tools · Package Managers

Integrity Hashes and Registry Trust

What a package digest actually covers, where it is recorded, and which attacks a lockfile digest stops and which it cannot, from republishing to dependency confusion.

A digest turns “this package” into a fixed-length string, and a lockfile that records the digest turns “install version 2.3.0” into “install the bytes that hashed to this value”. The distinction matters because a version number is a name that a registry controls, while a digest is a claim about content that the client can check without trusting the registry’s honesty at that moment. What the check buys depends entirely on what was hashed, where the expected value came from, and whether anything ran before the check.

The digest covers bytes, not meaning

The simplest construction hashes the archive exactly as the registry serves it. npm’s metadata carries an integrity field in the Subresource Integrity syntax, sha512- followed by the base64 encoding of the digest, computed over the tarball. The same metadata carries a legacy shasum field holding a SHA-1 hex digest, kept for older clients. A 64-byte SHA-512 digest encodes to 88 base64 characters including padding, and the algorithm prefix allows a client to migrate by listing several values separated by whitespace.

Hashing the archive has a property that surprises people: the digest is sensitive to the archive’s framing, not only to its contents. Gzip records a modification time in its header, and tar records a modification time and an ordering of entries. Two archives with identical file trees can therefore have different digests if they were produced at different times or by different tools. For a registry that stores the exact bytes it serves, this is fine — the point is to pin those bytes. For a consumer trying to verify that a rebuild produced the same package, it is a problem, because byte-identical archives require the packer to be deterministic as well as the build.

Go’s module system avoids the issue by hashing the extracted tree instead. The h1: digest in go.sum is computed over the sorted list of file names with each file’s SHA-256 hash, so it is invariant to how the archive was framed and to the order in which entries appear. The cost is that verification requires extracting the archive or computing the same listing, and the benefit is that the digest means “this tree of files” rather than “this sequence of bytes”. Both designs are defensible; they answer different questions, and mixing them up leads to false alarms when a mirror re-packs an archive without changing its contents.

Three places a digest can live

The first place is the registry’s own metadata, signed or not. It tells the client what the registry believes the package is, and it is only as trustworthy as the channel that delivered it. Over TLS, it defends against a network attacker who cannot present a valid certificate. It does not defend against the registry itself, or against anyone who can publish under the name.

The second place is the lockfile. The client recorded a digest at the time it first resolved the package, and every later install compares the served bytes against that record. This defends against a registry that changes what it serves for an existing version, which is the republish attack: an attacker who gains publish rights, or a registry operator, replaces the archive behind a version number that consumers have already approved. With a recorded digest, the replacement fails verification. Without one, every install silently receives the new content.

The third place is a signature over the digest, checked against a key that the client trusts independently of the registry. This is the only one of the three that can establish who published a package, because the first two establish only consistency. The Update Framework formalises the arrangement with separate roles — root, targets, snapshot, timestamp — so that a compromise of the key that signs the package list does not also allow an attacker to rewrite the list of trusted keys, and so that a client can detect a rollback to an older, validly signed snapshot. The separation of roles is what makes key rotation and revocation possible without a flag day.

Trust on first use and the window it leaves

A lockfile digest is a trust-on-first-use record. The first resolution fetches the package and writes down what it received. Every subsequent install is checked against that record. The scheme detects any change after the first fetch, and it says nothing about whether the first fetch was correct.

The window is not small. An attacker who publishes a malicious version before anyone legitimately depends on it, or who compromises the registry before the first resolution by a given project, is recorded as the trusted value. Subsequent verification then protects the attacker’s content rather than exposing it. The same applies to a mirror that served altered content once: the lockfile freezes the alteration.

Closing the window requires a check that does not derive its expectation from the same fetch. A signature verified against a key the client already trusts does that, provided the key distribution is independent. A reproducible build does it differently: rebuild the package from source and compare the result with the published artifact, which detects a compromised build pipeline as well as a modified archive. Both require infrastructure that most projects do not run, which is why trust-on-first-use remains the default in practice despite its known limit.

Names are not identities

Hashing assumes the client has resolved the right package. Several attacks target the resolution step instead, and no digest helps because the content that arrives is exactly the content the attacker published under a name the client asked for.

Dependency confusion exploits a namespace that spans two registries. An organisation publishes internal packages under unscoped names on a private registry and configures its client to prefer that registry. A dependency, or a transitive dependency, names a package that exists only internally. If the client falls back to the public registry for names the private one does not have, an attacker who publishes a package with that name publicly gets installed. The defences are at the resolver and configuration level: scope all internal names so that the public registry cannot own the namespace, or configure the client to fail rather than fall back.

Typosquatting needs no misconfiguration. A name one character away from a popular package, or with a hyphen moved, is published with malicious content. A digest records it faithfully. The only defences are review of new names, tooling that flags names close to existing ones, and lockfiles that make an added transitive dependency visible in review rather than absorbed silently.

Both attacks illustrate a boundary: integrity checking is about content once a name is chosen, and name-to-package binding is a separate problem with separate mechanisms. Registry scoping, namespace ownership, and review processes address it; hashes do not.

What runs before the check

Package installation is not only extraction. Many ecosystems run lifecycle scripts — pre-install, post-install, build — with the installing user’s privileges. A digest covers the archive, and the archive contains the script. Verifying the hash proves the script is the one that was published; it does not restrict what the script does when it runs, and it does not help if the publisher intended the script to be malicious.

The same gap applies to anything the script writes outside the package directory, to environment variables it reads, and to network access it performs during installation. Sandboxing the install step is the mitigation, and it is a property of the installer rather than of the digest. Some ecosystems allow scripts to be disabled by default and enabled per package, which converts the default from “execute everything” to “execute what was reviewed”. The trade is that packages which legitimately need to compile native code stop working until they are allowed.

Verification order also matters. If the installer unpacks an archive before checking its digest, a malicious archive can exploit the unpacker, and the check happens after the damage. The correct order is to hash the received bytes, compare, and only then extract. This sounds obvious and is easy to get wrong in a streaming implementation that writes as it reads.

The limits worth stating plainly

A digest recorded in a lockfile authenticates content relative to a previous fetch, not relative to a publisher. It detects republishing, mirror substitution, and corruption in transit. It does not detect a malicious first fetch, a name that was never the right name, or a script that behaves badly after verification. It also does not cover dependencies that are not archives: a git dependency is pinned by commit hash, which is a content identity of sorts, but the tree behind a commit can include submodules and generated files whose provenance is outside the hash.

The strongest practical arrangement combines the mechanisms rather than choosing one. A lockfile pins versions and digests; signatures bind digests to publishers; a transparency log makes publication visible and auditable after the fact; reproducible builds check the pipeline end to end; and the installer sandboxes scripts. Each covers a different gap, and the gaps are not interchangeable. A project that records digests but never verifies them on install has the cost of the mechanism and none of the benefit, which is why the check must be enforced by the command that continuous integration runs rather than by a command that developers can skip. The enforcement path, and what it means for the artifact’s reproducibility, is where Lockfile Determinism and Merges and What a Lockfile Records meet the security question.

References

  1. Subresource Integrity — W3C
  2. The Update Framework Specification — CNCF
  3. Go Modules Reference: Module authentication — Go project
  4. A Look in the Mirror: Attacks on Package Managers — J. Cappos, J. Samuel, S. Baker, J. H. Hartman, CCS 2008