Solving for faster SHA-1 collision detection

Hacker News Top Tools

Summary

The author developed a faster Rust crate, sha1dc, for SHA-1 collision detection that runs at 68–81% of plain SHA-1 speed, which can make git pack verification twice as fast compared to existing solutions.

No content available
Original Article
View Cached Full Text

Cached at: 09/24/26, 04:03 AM

# Solving for faster SHA-1 collision detection — sam reis Source: [https://sam.dev/blog/faster-sha1-collision-detection](https://sam.dev/blog/faster-sha1-collision-detection) **tl;dr:**I discovered collision\-detecting SHA\-1 is slow and decided to build my own\.[`sha1dc`](https://crates.io/crates/sha1dc)is a rewrite of SHA\-1 with collision detection, whose code generator uses a solver to fit collision tests into SIMD lanes\. It runs at 68–81% of plain SHA\-1's speed where the existing crate runs at 28–29%, and can make git pack verification twice as fast\. ## [How git uses SHA\-1 and why it has to be slow\(er\)](https://sam.dev/blog/faster-sha1-collision-detection#how-git-uses-sha-1-and-why-it-has-to-be-slower) As part of working on[Enroute](https://github.com/enroute-sh/enroute), I'm currently deep into optimizing the performance of a git server backend\. One thing you do a lot in a git server is accepting pack files from git clients\. These pack files are untrusted input and have to be validated, which includes checking the SHA\-1 hash of the included git objects\. I'm using[`gitoxide`](https://github.com/gitoxidelabs/gitoxide)and as it turns out, pack verification can be quite slow\. Verifying the pack of a bare[git/git](https://github.com/git/git)clone \(421,292 objects, 305 MiB compressed, 7\.7 GiB inflated\) on my M4 takes 12\.5 seconds, while spending**84%**of that in SHA\-1\. Underneath, it uses a crate called`sha1\-checked`, a SHA\-1 library with collision detection, running at about 900 MiB/s on that machine\. In contrast, plain`sha1`with the M4's SHA\-1 hardware instructions sits at about 3 GB/s\. ![What collision detection costs on an Apple M4. The sha1 crate hashes at 2979 MiB/s and sha1-checked at 856 MiB/s, 29% of plain SHA-1. A single-threaded pack verify of git/git takes 12.55 seconds, 84% of it in SHA-1 with detection; split by that throughput ratio, about 3.0 seconds is the hash itself and 7.5 seconds, 60% of the run, is the collision detection, beside 1.4 seconds of zlib and 0.6 of everything else.](https://sam.dev/blog/faster-sha1-collision-detection/detection-penalty.svg) Wait, collision detection? Yes: What makes SHA\-1 difficult for untrusted input is that it is known to be cryptographically broken because[chosen\-prefix collisions are practical](https://sha-mbles.github.io/)\. Ideally, we would of course all be using SHA\-256 for our git objects, but migrations\.\.\. Luckily, this security issue can be mitigated by detecting those manufactured collisions within the SHA\-1 state space, which is what git is doing\. Using this approach, when a git server detects a collision, it refuses to accept these objects from the client\. On the not so lucky side, this is obviously quite slow\! And thus I set out to see if I could make things faster and improve the performance of SHA\-1 with collision detection\. ## [Low\-hanging fruit](https://sam.dev/blog/faster-sha1-collision-detection#low-hanging-fruit) To start with, I had a closer look at`sha1\-checked`, and found something to do straight away: it had no hardware acceleration on the detecting path\. Modern`arm64`and`x86\_64`CPUs have SHA\-1 instructions and I initially expected that it would try and use them\. However, the catch with the hardware instructions in this case is that collision detection runs off the internal SHA\-1 message schedule and hash state, which the hardware instructions make difficult to access\. What I changed is to spill the schedule to a buffer as it is expanded, run the happy path through the hardware, and only fall back to a scalar recomputation for the rare block that looks suspicious\. This roughly doubled throughput on both architectures: 928 → 1996 MB/s on Apple Silicon, ~300 → ~640 MB/s on an AMD EPYC with`sha\_ni`\. At the time, I wrapped this up into a[pull request](https://github.com/RustCrypto/hashes/pull/910)to`sha1\-checked`, which is currently open, waiting on the`0\.11`release before review\. But then I became curious to see how much better we could do\. ## [The wall of constants](https://sam.dev/blog/faster-sha1-collision-detection#the-wall-of-constants) With the simple fix of the way, the next bottleneck quickly became visible\. As far as I could deduct from the code at this point \(we'll get to the theory in a minute\), collision detection needs to do two things per block\. First it runs a cheap filter: about 150 tests on individual bits of the expanded message, each of which rules out some of the known attack patterns\. The filter keeps a mask with one bit per pattern and clears bits as tests fail\. Then, only if the mask is still non\-zero, comes an expensive recomputation of the block that settles the question for good\. On ordinary data about 95% of blocks leave the filter with an empty mask, so the recomputation almost never runs\. Without the filter, hashing would crawl along at around 40 MiB/s, and the filter itself is where a collision\-detecting SHA\-1 spends the time it spends beyond plain SHA\-1\. However, there was a problem\. Here is what this code looks like in`sha1\-checked`, which seems to be a more or less direct translation of the original C code, which in turn was generated by a tool from the data files of the research paper behind it: ``` mask &= (((w[44] ^ w[45]) >> 29) & 1).wrapping_sub(1) | !(DV_I_48_0_BIT | DV_I_51_0_BIT | DV_I_52_0_BIT | DV_II_45_0_BIT | DV_II_46_0_BIT | DV_II_50_0_BIT | DV_II_51_0_BIT); mask &= ((w[47] ^ (w[50] >> 25)) & (1 << 4)).wrapping_sub((1) << 4) | !(DV_I_47_0_BIT | DV_I_49_0_BIT | DV_I_51_0_BIT | DV_II_45_0_BIT | DV_II_51_0_BIT | DV_II_56_0_BIT); ``` That goes on for about 475 lines, after 540 lines of hex tables\. It is certainly correct based on the test coverage, but at least to me it is also completely opaque\. I simply could not see how to make it faster without understanding where the numbers came from, and there was nothing in the code to help with that\. ## [Reading the paper](https://sam.dev/blog/faster-sha1-collision-detection#reading-the-paper) So I went back to the source: Stevens and Shumow's[paper](https://eprint.iacr.org/2017/173)on speeding up detection explains what the filter is testing\. The paper itself can be a bit dry, but they also have[slides and a video presentation](https://www.usenix.org/conference/usenixsecurity17/technical-sessions/presentation/stevens)\. It turns out that SHA\-1 collision attacks are built from patterns of message differences called*disturbance vectors*, and the paper selects the 32 cheapest to attack with \(32 because the mask is a 32\-bit integer\)\. What the detector tries to figure out is whether a block could be part of an attack along any of those 32 vectors\. For each vector, the paper derives seven to fifteen so\-called*unavoidable bit conditions*, which are relations between pairs of bits of the expanded message that must hold if an attack along that vector is in progress\. Each one is cheap to check, and if it fails, that vector can be crossed off\. Using these conditions, a small program from the paper's[tools repository](https://github.com/cr-marcstevens/sha1collisiondetection-tools)turns them into checks\. It enumerates every linear combination of a vector's conditions and greedily picks, at each step, the relation that covers the most vectors not yet covered, breaking ties by how cheap the relation is to test: fewest active bits, then fewest distinct bit positions, then the smallest distance between the two words\. ![Toy example of the generator, as given and as checked. Two disturbance vectors over six bits A to F: the first arrives with A=B, B=C and E≠F, the second with A=C and D≠E. Since A=B and B=C imply A=C, the first vector checks A=C instead, the pair the second already checks, so the code needs four checks instead of five and one of them clears both vectors.](https://sam.dev/blog/faster-sha1-collision-detection/generator.svg) Here is why the generator works: A condition says that two particular bits of the expanded message must be equal \(or must differ\)\. Relations like that chain: if bit A must equal bit B, and bit B must equal bit C, then A must equal C\. So for each disturbance vector there is not one list of pairs to check but a whole family of equivalent lists, and the generator gets to choose among them\. The paper's generator chooses pairs that many vectors have in common, so that one statement can serve several of them at once\. That is the choice that minimises statements and it is best for scalar computation\. However, could SIMD units change what a good choice looks like? A few SSE2 or NEON instructions can compare four pairs of bits at once, but only if the four pairs can be processed simultaneously, meaning if they have the same distance between the two words, the same bit positions within them, and so on\. ## [Starting over](https://sam.dev/blog/faster-sha1-collision-detection#starting-over) At that point, a plan started to form: Instead of trying to hand\-tune implementations for a given architecture, I wanted to take these theoretical foundations and introduce them to the world of SIMD\. And so,[`sha1dc`](https://github.com/srijs/sha1dc)was born\. It is a bottom\-up rebuild of SHA\-1 with collision detection in Rust\. It uses the SHA\-1 instructions on`x86\_64`and`arm64`for the hashing itself, and generates`neon`,`sse2`and`avx2`forms of the collision check\. Underneath, it uses a code generator that can be aimed at different vector units to deterministically generate code tuned to their specific characteristics\. ### [How it works](https://sam.dev/blog/faster-sha1-collision-detection#how-it-works) When vectorizing the unavoidable bit conditions, every vector group is equally expensive to execute, but the effectiveness varies: The first few groups buy a lot, because each one rules out a large share of blocks for some disturbance vector\. After that the returns shrink, for two reasons: Most disturbance vectors are already ruled out on most blocks, so another group barely changes anything\. And second, the conditions that fit four to a group begin to run out; the leftovers would mostly fill one lane out of four\. That is why`sha1dc`generates checks in two parts, with the split between them representing the break\-even point: - The**prefix**runs on every block, unconditionally\. Its unit is a*group*: one pair of vector loads covering up to 4 or 8 conditions of one shape at consecutive words\. - The**tail**is a cascading sequence of scalar conditions, very similar to the fully scalar implementation, which only runs when a block hasn't been disqualified previously by the prefix\. ![The shape of the check, one square per condition. The C-derived check runs 47 scalar statements on every block and keeps the other 109 conditions behind mask tests. The NEON form sha1dc generates runs 17 vector groups, four lanes wide and covering 66 conditions in all, on every block, then a mask-is-zero return, then a tail of 93 guarded conditions that only 28% of blocks reach.](https://sam.dev/blog/faster-sha1-collision-detection/check-shape.svg) To accomplish this,`sha1dc`uses a solver\. The solver's job is to decide which conditions go into the vector prefix, and in which groups, so that the prefix rules out as many blocks as it can for the number of groups it is allowed\. Whatever it does not rule out falls to the scalar tail\. The tension is that a group only pays for its full four lanes when four conditions of the same shape line up at consecutive words, and the conditions that line up best are not necessarily the ones that rule out the most blocks\. Think of a random block being hashed\. Each independent condition a vector has in the prefix is a coin flip: the two bits either match or they do not\. So a vector with`r`such conditions survives with probability`2^\(−r\)`, and summing that over the 32 vectors gives the expected number of survivors, which is how the solver knows statistically how often the tail has to run\. That number keeps improving with every group, but throughput does not: measured on the M4, it peaks well before the tail\-entry rate bottoms out\. The way this trade\-off is encoded is via a budget for each instruction set: The solver receives a limit on the number of prefix groups which is set so that it maximizes throughput\. ![Tail-entry rate and measured throughput against the prefix budget on an Apple M4. NEON: as the budget grows from 5 to 35 groups, the share of blocks reaching the tail falls from 98% to 6%, while throughput rises to a plateau between 14 and 21 groups, peaks at 2364 MiB/s at 17, the shipped budget, and then falls to 2144 at 35. Scalar: from 20 to 80 statements the tail share falls from 96% to 14%; throughput is about level from 45 to 60 statements at around 920 MiB/s and lower on either side, and 45 ships.](https://sam.dev/blog/faster-sha1-collision-detection/budget-tradeoff.svg) ### [How it's tested](https://sam.dev/blog/faster-sha1-collision-detection#how-its-tested) When it comes to testing, I am a big fan of fuzzing and property tests to suss out problems that fixed test batteries won't find\. So naturally there are plenty of property tests in`sha1dc`\. However, purely random inputs have a big blind spot in this case: we only progress to the checks in the tail when none of the prefix checks have triggered, and given that we're optimizing specifically for the effectiveness of prefix checks, this becomes extremely difficult to trigger with random inputs\. Luckily, this also can be solved for: because SHA\-1 seeds its internal buffer by linearly expanding the input block, a condition on two of its bits is an equation on the block's bits\. Keeping one vector alive means satisfying at most 15 such equations, with 512 bits to play with\. Computed once, you can fill the hundreds of unconstrained bits with random values, and out comes a fresh block that satisfies every condition, as many times as you like\. The tests in`sha1dc`generate 64 such witnesses per vector, 2,048 in all, from a single seed, so that the checks behind even the rarest vector run\. ## [Final results](https://sam.dev/blog/faster-sha1-collision-detection#final-results) The first version of[`sha1dc`](https://crates.io/crates/sha1dc/0.1.0)is now published on`crates\.io`, and it narrows the gap to plain SHA\-1 to 19–32%, depending on the instruction set\. ### [Microbenchmarking](https://sam.dev/blog/faster-sha1-collision-detection#microbenchmarking) Measuring 16 KiB of pseudo\-random input against two baselines: 1. The`sha1`crate, which does no detection but has hardware\-acceleration\. 2. The`sha1\-checked`crate, which does detection but has no hardware\-acceleration\. Throughput in MiB/s, and as a fraction of the`sha1`row: implementationApple M4Xeon Platinum 8488CGraviton4`sha1`2979 \(100%\)1887 \(100%\)1616 \(100%\)**`sha1dc`****2400 \(81%\)****1285 \(68%\)****1286 \(80%\)**`sha1\-checked`856 \(29%\)523 \(28%\)462 \(29%\)`sha1\-checked`\+[PR \#910](https://github.com/RustCrypto/hashes/pull/910)1712 \(57%\)877 \(46%\)980 \(61%\) ![Throughput of sha1, sha1dc, sha1-checked and sha1-checked with the hardware-acceleration PR on three machines. Apple M4: 2979, 2400 (81%), 856 (29%) and 1712 MiB/s (57%). Xeon Platinum 8488C: 1887, 1285 (68%), 523 (28%) and 877 (46%). Graviton4: 1616, 1286 (80%), 462 (29%) and 980 (61%).](https://sam.dev/blog/faster-sha1-collision-detection/throughput-bars.svg) Given that both`sha1`and`sha1dc`use the machine's SHA\-1 instructions, the gap between them is, all else being equal, the cost of collision detection in`sha1dc`: 19% to 32%, depending on the machine\. The gap to`sha1\-checked`is bigger, 2\.5× to 2\.8×, with most of it due to hardware acceleration\. When adding my PR against`sha1\-checked`, it gives a clearer picture: the hardware SHA\-1 instructions take it to 46% to 61% of plain SHA\-1, and the vector check takes it the rest of the way, another 1\.3× to 1\.5× on top\. ### [Real world performance](https://sam.dev/blog/faster-sha1-collision-detection#real-world-performance) This all started with wanting to make`git`faster, so how are we doing? We're measuring[gitoxide](https://github.com/GitoxideLabs/gitoxide), which normally gets its SHA\-1 from`gix\-hash`, which wraps`sha1\-checked`\. For testing,`sha1dc`was swapped in\. All tests ran on my M4 machine, and I picked the fastest of five runs\. operation`sha1\-checked`**`sha1dc`**speedup`pack verify`, all cores2\.33 s**1\.09 s**2\.13×`pack verify`, one thread12\.55 s**6\.26 s**2\.01×`index\-pack`, all cores4\.23 s**2\.79 s**1\.52×`index\-pack`, one thread14\.72 s**8\.02 s**1\.84× The numbers from the start of this post move the way they should: SHA\-1 with detection goes from 84% of the single\-threaded`pack verify`to 73%, with zlib inflation rising from 11% to 18% as the hashing shrinks around it\. ## [What's next](https://sam.dev/blog/faster-sha1-collision-detection#whats-next) While we're already using this in[Enroute](https://enroute.sh/), I would also love to upstream this into`gitoxide`itself so that the whole Rust\-based`git`ecosystem can benefit\. That said, the single biggest thing in the`pack verify`profile is still SHA\-1, so this journey is far from over\. Stay tuned\!

Similar Articles

Speeding up gearhash on ARM64 (2× faster)

Hacker News Top

The article describes optimizations to the `gearhash` Rust crate, adding a NEON backend that doubles performance on ARM64 architectures, improving content-defined chunking for applications like Hugging Face's Xet client.

Git 3.0's upcoming SHA-256 default will be a costly mistake

Lobsters Hottest

GitButler argues that Git 3.0's upcoming default switch from SHA-1 to SHA-256 hashing will be a costly, global breaking change for developers with little practical benefit, since SHA-1 collisions remain practically unexploitable in Git repositories.

How to speed up the Rust compiler in July 2026

Lobsters Hottest

Nicholas Nethercote reports recent performance improvements to the Rust compiler, including a 5.59% mean wall-time reduction overall, major rustdoc speedups totaling 28%, and significant Clippy optimizations via PRs and PGO training changes.

How to speed up the Rust compiler in September 2026

Lobsters Hottest

The article reports a 4.57% mean reduction in Rust compiler wall-time over two months, highlighting optimizations in rustdoc, Clippy, LLVM upgrade, and the new Polonius borrow checker and Penelope Hammertime trait solver.

How I made Rustdoc 33% faster in one week

Lobsters Hottest

A Rustdoc team member achieved a 33% performance improvement in Rustdoc through a series of optimizations and bug fixes, addressing a recursion limit issue that affected documentation generation.