Proposal: BLAKE3 strong checksum¶
Status: Draft — comments welcome on
github.com/go-deltasync/zsync2/discussions.
Target version: zsync2 wire format 1.0 (current shipping ships
the classic Phipps wire format 0.6).
Problem¶
The zsync wire format requires two cryptographic hashes:
| Where used | Algorithm | Status |
|---|---|---|
| Per-block strong checksum (after rolling-checksum match) | MD4 | Broken since 2004 (Wang, Lai, et al.); 2^6 work for a collision on a modern CPU |
| Whole-file integrity at end of reconstruction | SHA-1 | Broken since 2017 (Stevens et al., SHAttered) |
For the typical zsync deployment — an Ubuntu mirror serving ISO
diffs — this is "good enough", because the trust anchor is the
TLS-served .zsync control file, not the hashes inside it. But the
choice constrains every threat model that goes beyond accidental
corruption.
Concretely:
- A constrained adversary who can write to the seed file (CI cache poisoning, evil-maid on a laptop, a malicious package pre-installation) can craft MD4 collisions targeted at specific blocks of a known target. The matcher will accept the colliding bytes; the seed-to-target copy then carries the adversary's payload. The file-wide SHA-1 catches it — unless the adversary also crafts a SHA-1 collision for the whole reconstructed file (more expensive but no longer rocket science).
- Anybody who simply wants to package zsync2 in a security-conscious distro has to wave hands at MD4. With BLAKE3 there's no hand-waving.
This proposal upgrades both hashes to BLAKE3 without breaking
existing .zsync consumers.
Why BLAKE3 specifically¶
- Speed: ~6 GB/s single-threaded on modern x86-64 (Phipps' MD4 sits
around 700 MB/s), faster on aarch64-NEON, near memory-bandwidth on
AVX-512. The
gozsyncmakestep is currently MD4-bound; BLAKE3 removes that floor. - Tree structure: lets us parallelise per-block hashing on multi-core makers for free, and lets future extensions key-derive per-block.
- Modern security margin: 256-bit collision resistance vs MD4's effective 0.
- Truncation is well-defined: BLAKE3's output is a 32-byte stream;
truncating to the per-block
Hash-Lengthswidth keeps the same birthday-bound properties as truncating SHA-256. zsync's truncation policy ports verbatim. - Stdlib-adjacent: Go has
lukechampine.com/blake3(pure Go, vendor-free), Rust has the officialblake3crate, C/C++ ship the reference implementation at https://github.com/BLAKE3-team/BLAKE3.
Wire format changes (proposed)¶
The current header block looks like:
zsync: 0.6
Filename: file.bin
MTime: Wed, 01 Jan 2025 00:00:00 +0000
Blocksize: 4096
Length: 5050880
Hash-Lengths: 2 2 4
URL: https://example.com/file.bin
SHA-1: 0e23...
<empty line>
<binary block table>
The new format adds two headers and bumps the version:
zsync2: 1.0 ← new magic; was "zsync: 0.6"
Filename: file.bin
MTime: Wed, 01 Jan 2025 00:00:00 +0000
Blocksize: 4096
Length: 5050880
Hash-Lengths: 2 2 16 ← checksum_bytes widened
Hash-Algorithm: BLAKE3 ← NEW
URL: https://example.com/file.bin
File-Hash: BLAKE3:5b46... ← NEW (replaces SHA-1)
SHA-1: 0e23... ← OPTIONAL legacy fallback
<empty line>
<binary block table: same layout, BLAKE3 bytes instead of MD4>
Two new keys, one new magic:
zsync2:magic line — replaceszsync:. Old Phippszsyncparsers fail with "not a zsync file" and exit cleanly; no risk of a stale parser misinterpreting BLAKE3 bytes as MD4.Hash-Algorithm:— one ofMD4(legacy, the default if absent) orBLAKE3. Future-proofs the field.File-Hash:<algo>:<hex>— replacesSHA-1:. Same role, self-describing. A maker that wants compat can emit bothFile-Hash:andSHA-1:so azsync2: 1.0reader uses BLAKE3 while a hypotheticalzsync: 0.6-aware downgrade reader sees the SHA-1.
The binary block table layout doesn't change. Per block:
Only the meaning of the strong-hash prefix changes (MD4 truncation → BLAKE3 truncation). Same width, same offset.
Compat matrix¶
| Maker emits | Reader sees | Behaviour |
|---|---|---|
zsync: 0.6 (classic) |
Old zsync |
Works (today) |
zsync: 0.6 (classic) |
New gozsync |
Works (we read both) |
zsync2: 1.0 (new) |
Old zsync |
Rejects with "not a zsync file" — safe |
zsync2: 1.0 (new) |
New gozsync |
Works, uses BLAKE3 |
The deliberate non-symmetry: upgrading is a one-way decision. A
maker that emits zsync2: 1.0 is saying "I want BLAKE3-aware clients
only". To target old clients too, the maker emits two control files:
file.bin.zsync and file.bin.zsync2. Clients that know about the
new format prefer .zsync2; old clients pick .zsync.
This matches what GnuPG did with the .asc ↔ .sig split:
filename extension carries the version, content stays clean.
Cross-implementation strategy¶
Three implementations to bring along:
go-deltasync/zsync2(this repo): implements bothzsync: 0.6(today) andzsync2: 1.0(this proposal). Behind agozsyncmake --format zsync2flag initially; new default after one release.AppImageCommunity/zsync2(C++): upstream PR adding a--format=zsync2mode. Existingzsyncmake2produces classic by default,--format=zsync2produces new. The bidirectional read support is the harder ask, but the BLAKE3 reference C++ is well-maintained.Phipps zsync(C): maintenance is sparse; we'd land the spec only and trust the Debian zsync package to follow if there's demand.
The compat test suite in our CI grows a fourth round trip: maker C++
zsync2-cpp &rlharu; reader gozsync, in both directions, with
--format=zsync2. The current four MD4/SHA-1 round trips stay green
— legacy isn't going anywhere.
Implementation cost (in go-deltasync/zsync2)¶
About 100–150 lines added, no removals:
internal/zsync/control.go: parsezsync2:line +Hash-Algorithm:File-Hash:; emit them inWritewhen format iszsync2.internal/zsync/rsum.go: factorstrongHash(blk []byte) []byteout of the MD4-specific code path; add a BLAKE3 implementation. Both paths get a unit test against fixed test vectors.cmd/gozsyncmake/main.go: new--formatflag with valueszsync(default) andzsync2.internal/zsync/compat_test.go: extra round trips againstxxhsum— wait, no, against the C++ reference once it lands.go.mod: addslukechampine.com/blake3 v1.x.
Coverage gate stays at ≥99 % on internal/zsync.
Open questions¶
- Truncation width. MD4 zsync files traditionally truncate to 2-4
bytes per block to keep
.zsyncsmall. With BLAKE3 we could go wider for ~free relative to total file size. The proposal keeps the existingHash-Lengths:formula; concretely a 1 GB target getsHash-Lengths: 2 4 16— 16 bytes of BLAKE3 per block, 128-bit birthday-bound on accidental collision. - Is
File-Hash:worth being polymorphic? Could just say "BLAKE3" and bake it into the format. Argument for<algo>:<hex>: if BLAKE4 ships in 10 years we want the upgrade path. - Should the maker emit both files by default? Probably yes for
the first major release:
gozsyncmake foo.binemits bothfoo.bin.zsyncandfoo.bin.zsync2. Drop the legacy after one year of telemetry.
Reference docs¶
- BLAKE3 paper
- Phipps' original zsync paper
- Wang et al., "Collisions for Hash Functions MD4, MD5, HAVAL-128 and RIPEMD"
If this lands as proposed, the entry in the security section of zsync2's README changes from "use MD4 because that's the protocol" to "use BLAKE3 because that's the protocol, with MD4 as a documented legacy fallback for the year-one transition window". That's the goal.