Skip to content

MD5 and SHA-1

For the auditor, or the scanner, asking why a 2026 codebase computes MD5.

A dependency scan of BatleHub reports uses of MD5 or SHA-1 (SonarCloud raises them as rust:S4790, CRITICAL). This page is the register: every one of them, what it is for, and — rechecked against each protocol's current specification — whether that protocol would accept something stronger.

There were thirteen. Four turned out not to be required by anything and were deleted; nine remain, and this page says why each is there.

The line that separates them

None of these values is what BatleHub trusts. Artifact integrity that is a security decision rides on SHA-256, computed independently when bytes are first written and re-checked on every later serve, and on the OpenPGP signatures over the repository indexes.

The weak digests are all one of three things: a field a wire format names as MD5 or SHA-1, a cache validator a client compares byte-for-byte, or the verification side of a checksum some upstream registry advertised in SHA-1. A collision against any of them buys an attacker nothing they could not get by serving different bytes, because nothing gates on them.

That is the argument for the nine that remain. It is not the same as saying every one is unavoidable, which is what the recheck was for — and four did not survive it.

The register

#WhereAlgoVerdict
1core/services/integrity.rssha1_hexSHA-1Mandated by Composer
2core/services/integrity.rsverify, StreamingVerifierSHA-1Verification, not emission
3core/…/local_registry/eco_rubygems.rs/versionsMD5Mandated by the compact index
4web/…/proxy/rubygems/range.rs — ETagMD5Legacy clients only
5adapters/repo/deb.rsMD5, SHA-1Removed — optional in Debian
6adapters/repo/pacman.rsMD5Removed — gone from the format
7adapters/repo/openpgp.rs — fingerprintSHA-1Immutable by definition

Entries 5 and 6 are struck through because the code no longer computes them. Entry 4 is bolded because it is a compatibility choice rather than a requirement, and is the one left worth revisiting. See Rechecked.

1. Composer dist.shasum

Composer's dist object carries a single checksum field, shasum, and it is a SHA-1. Publishing a SHA-256 in it does not degrade to "unverified" — Composer hashes the downloaded zip with SHA-1 and compares, so every download fails.

A sha256 dist field has been an open feature request since 2017 and is not implemented. Nothing stronger is available.

Scope note: sha1_hex is called from exactly one place, the Composer publish handler. It is not used for npm — the npm proxy prefers dist.integrity (SSRI, usually SHA-512) over dist.shasum and falls back only when the upstream omits it, and the local npm packument passes through whatever dist the publishing client sent, rewriting only the tarball URL.

2. Verifying what an upstream advertised

verify and StreamingVerifier accept SHA-1, SHA-256 and SHA-512 and pick the algorithm from the checksum the upstream registry published. When a registry advertises a SHA-1, hashing with SHA-1 is the only way to compare against it.

This is the one entry where removing the weak algorithm makes things strictly worse: the alternative to verifying a SHA-1 checksum is not verifying at all.

3. The RubyGems compact index

The compact index /versions document is a line per gem ending in a digest, and the format defines that digest as an MD5 of the gem's /info document:

RUBYGEM [-]VERSION_PLATFORM[,VERSION_PLATFORM],...] MD5

Bundler recomputes it to decide whether its cached /info copy is current. It is a cache validator, in the algorithm the format names, and it is still current. Nothing stronger is available for this field.

Note that the Repr-Digest header BatleHub sends on the same documents is already SHA-256 — that is the modern, RFC 9530 digest, and it is the one the specification actually requires.

4. The compact index ETag

compact_response sets the ETag to an MD5 of the document body, and holds_our_prefix re-derives the MD5 of a prefix to answer Bundler's resumable range requests.

The value has to be MD5 because Bundler computed it that way: older versions run SharedHelpers.digest(:MD5).hexdigest(File.read(path)) over the local cached file and send it as If-None-Match alongside bytes=<size - 1>-. A server ETag in any other algorithm never matches, and the client refetches the whole file.

But Bundler 2.7.0 removed MD5 digesting of compact index responses (2025-07-16, a breaking change). Current Bundler uses the SHA-256 Repr-Digest. So this MD5 now serves only Bundler older than 2.7, and the cost of changing it is a full refetch for those clients — degraded, not broken. It is a deliberate compatibility retention, which is a weaker claim than "the format requires it".

5. Debian Packages and Release — removed

parse_deb used to compute MD5, SHA-1 and SHA-256 over each .deb; the Packages stanza emitted all three, and Release listed every index under an MD5Sum: section as well as SHA256:.

The Debian repository format makes the weak ones optional and says plainly what a client may do with them:

Clients may not use the MD5Sum and SHA1 fields for security purposes, and must require a SHA256 or a SHA512 field.

So they bought nothing: apt accepts an index carrying only SHA-256, the SHA-256 is what it verifies, and the OpenPGP signature over Release is what makes the index trustworthy in the first place. DebPackage and ReleaseFile no longer carry an md5 or sha1 field, the MD5sum:/SHA1: lines are gone from each stanza, and the MD5Sum: section is gone from Release.

An uploaded .deb whose own control carries an MD5sum or SHA1 field still has it dropped — those are repository-level fields, and an upload does not get to reintroduce one.

The cost is compatibility with apt too old to support SHA-256 — far older than anything still receiving security updates.

6. Pacman %MD5SUM% — removed

This one was not merely optional. pacman 6.1.0 dropped md5sum support from repository databases — repo-add stopped writing it and libalpm dropped both reading and validation — and alpm-repo-desc(5) states:

The section %MD5SUM% has been removed.

BatleHub was writing a field current pacman ignores. PacmanPackage no longer carries an md5, and desc_entry no longer emits %MD5SUM%; %SHA256SUM%, which pacman does read, is unchanged.

PacmanPackage is stored as JSON, and the struct has no deny_unknown_fields, so metadata written before this change still deserializes — the extra key is ignored.

7. The OpenPGP v4 fingerprint

A version-4 OpenPGP key fingerprint is defined as SHA-1 over 0x99 || len16 || pubkey_body (RFC 4880 §12.2), and the key ID is its low 64 bits. This is an identifier with a specified construction, not a choice of hash: computing it any other way produces a fingerprint no client recognises, and apt's Signed-By: and rpm --import both key off it.

RFC 9580 v6 keys use SHA-256, but apt and rpm do not consume v6 keys today. Immutable while the key is v4.

Rechecked 2026-08-31

The original triage recorded all thirteen as "mandated by a wire format". Three entries did not survive checking that against the specifications, and two of the three were then deleted:

  • Pacman's %MD5SUM% (#6) — the field is gone from the format. Removed.
  • Debian's MD5/SHA-1 (#5) — optional, and the specification forbids clients from relying on them. Removed.
  • The compact index ETag (#4) — required only by Bundler older than 2.7. Kept: it still buys resumable range requests for those clients, and unlike the other two it is not dead output.

That took thirteen findings to nine. Nothing here was a vulnerability — no attack was enabled by any of them, which is why they were a register rather than an incident — but four of them were output no current client reads, and the difference between "the format requires this" and "we have always emitted this" is exactly what a recheck is for.

How the removals were verified

Real apt accepts the resulting repository: apt-get update verifies the Ed25519 OpenPGP signature over InRelease, indexes the package, and apt-get download fetches it. Corrupting the .deb makes apt reject it with a hash mismatch reported against SHA256, which is the point — the check that was doing the work is still doing it.

.github/workflows/repo-interop.yaml runs that end to end for real apt, dnf and pacman in containers, and it triggers on any change under crates/adapters/src/repo/.

How these are handled in the scanner

Each has a rust:S4790 entry in sonar-project.properties, pinned to the single file that speaks the protocol, with its reasoning inline. They are configured ignores rather than per-issue dashboard resolutions so that the justification is version-controlled and reviewable.

The scoping is deliberate: a weak hash outside those seven files is a real finding. Do not widen a resourceKey to a directory, and do not add an eighth entry without an argument of the same kind — which, as this page shows, means checking the specification rather than repeating what the last comment said.

Related: Security scanning for the full scanner matrix and the project's no-suppressions stance on dependency advisories.

Released under the Apache 2.0 License. Made with ❤️ and too much ☕.