Skip to content

fix: make Hash duplex sponge portable across pointer widths#171

Merged
mmaker merged 5 commits into
arkworks-rs:mainfrom
shreyas-londhe:fix/portable-hash-squeeze-counter
Jun 10, 2026
Merged

fix: make Hash duplex sponge portable across pointer widths#171
mmaker merged 5 commits into
arkworks-rs:mainfrom
shreyas-londhe:fix/portable-hash-squeeze-counter

Conversation

@shreyas-londhe

@shreyas-londhe shreyas-londhe commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Problem

Hash<D> (the Digest-bridge duplex sponge backing Blake2b512, Blake2s256, SHA256, SHA512) folds two counters into the hash state at native pointer width:

  • squeeze, Mode::Squeeze(i) branch — Digest::update(&mut output_hasher_prefix, i.to_be_bytes()), i: usize
  • squeeze_endDigest::update(&mut squeeze_hasher, byte_count.to_be_bytes()), byte_count: usize

usize::to_be_bytes() is 8 bytes on 64-bit targets and 4 bytes on 32-bit (e.g. wasm32). The first squeeze hits Mode::Squeeze(0)0usize.to_be_bytes() is 8 zero bytes vs 4, so the squeezed output diverges from the first byte.

Impact

Any protocol where a 64-bit prover produces a transcript/NARG that a 32-bit verifier replays (in-browser/on-chain wasm32 verifiers, 32-bit embedded) derives different Fiat-Shamir challenges, so verification fails. Found verifying a 64-bit-produced transcript inside a wasm32 verifier: a 7-byte-absorb-then-squeeze probe diverged between native and wasm; with this patch the probe, all derived challenges, and full proof verification match native exactly.

The permutation-based DuplexSponge<P, …> (Keccak, Ascon) is not affected — its usize fields (absorb_pos/squeeze_pos) are buffer indices and are never absorbed.

Fix

Encode both counters as fixed-width u64:

Digest::update(&mut output_hasher_prefix, (i as u64).to_be_bytes());
// …
Digest::update(&mut squeeze_hasher, (byte_count as u64).to_be_bytes());

On 64-bit this is byte-identical to the current output (i as u64 == i for usize), so existing transcripts and the spec vectors (duplexSpongeVectors.json, test_shosha) are unchanged — confirmed by the existing suite passing. Only 32-bit output changes, now matching 64-bit. Since 32-bit verification is currently broken, there is no compatible 32-bit consumer to break.

On testing

I deliberately did not add a unit test, because a regression here is undetectable on a 64-bit-only CI: the buggy usize and the fixed u64 encodings are byte-identical on 64-bit (same value, same type), so no runtime assertion can distinguish them. The property is only observable on a 32-bit target.

The right guard is a 32-bit CI lane (e.g. i686-unknown-linux-gnu, or wasm32 with a test runner) running the existing suite — the current multi-block-squeeze vectors (test_shosha, duplexSpongeVectors.json) already exercise the Mode::Squeeze(i) counter and would catch any pointer-width divergence for free. The CI currently has no such lane (wasm32-unknown-unknown also needs a getrandom backend for the dev-deps). Happy to add an i686 test job in this PR if you'd like that guard.

`Hash<D>::squeeze` and `squeeze_end` fold the squeeze block-counter and the
read-length into the hash state via `usize::to_be_bytes()`. `usize` is 8 bytes
on 64-bit targets and 4 bytes on 32-bit (e.g. wasm32), so the absorbed bytes —
and therefore the squeezed output — differ by target. A 64-bit prover and a
32-bit verifier then derive different Fiat-Shamir challenges, breaking
verification (this surfaced verifying a 64-bit-produced transcript inside a
wasm32 verifier).

Encode both counters as fixed-width `u64`. On 64-bit this is byte-identical to
the previous output (`i as u64 == i`), so existing transcripts and the spec
vectors are unchanged; only 32-bit output changes, now matching 64-bit. The
permutation `DuplexSponge` is unaffected — its `usize` fields are buffer
indices, never absorbed.
@shreyas-londhe shreyas-londhe force-pushed the fix/portable-hash-squeeze-counter branch from 14c4e66 to 09b9910 Compare June 10, 2026 09:24
…lity

Add a `wasm32-wasip1` job to the PR workflow that builds and runs the test
suite under wasmtime, providing a 32-bit lane. This catches pointer-width
divergence in the `Hash` duplex sponge for free via the existing vector KATs
(`test_shosha`, `duplexSpongeVectors.json`).

- Fix arkworks-rs#171: encode the squeeze index and squeeze_end byte count as fixed-width
  `u64` instead of pointer-width `usize`, so a 64-bit prover and a 32-bit
  verifier (e.g. wasm32) derive identical outputs. Byte-identical on 64-bit.
- Remove the unused Cardano `pallas` dev-dependency: it is referenced nowhere
  (only `ark_pallas`/`ark_vesta` are) and transitively pulls `tokio`/`socket2`,
  which do not compile on wasm. Drops a large transitive tree from Cargo.lock.
- Add a codec KAT locking the `str` length prefix to a fixed-width LE `u32`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@mmaker mmaker left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approving, adding more regression guards

@mmaker mmaker merged commit 4ee5f2b into arkworks-rs:main Jun 10, 2026
17 checks passed
shreyas-londhe added a commit to shreyas-londhe/jolt that referenced this pull request Jun 11, 2026
…lity

0.6.2 carries the upstream fix (arkworks-rs/spongefish#171) making the
`Hash<D>` duplex sponge encode its squeeze counter at fixed `u64` width instead
of native `usize`. Under 0.6.1 a 64-bit prover and a 32-bit (wasm32) verifier
derived different Fiat-Shamir challenges, so the WASM Verifier E2E rejected
valid proofs (UniSkipVerificationError). The change is a no-op on 64-bit, so
native muldiv (host + zk) is unaffected.

Verified locally: muldiv host + host,zk pass; a wasm32 verifier built against
0.6.2 verifies a 64-bit-produced fibonacci proof end-to-end (was failing on
0.6.1).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants