V12 again - #79
Conversation
bytes_to_digest, bytes_to_felts_compact, and rehash_to_bytes now return Result and reject limbs >= P, which previously aliased with canonical field elements and allowed byte-distinct inputs to hash identically. Co-authored-by: Cursor <cursoragent@cursor.com>
…ed_felt Oversized amounts now return a recoverable error instead of panicking, matching the reverse conversion try_felt_to_quantized_u128. Co-authored-by: Cursor <cursoragent@cursor.com>
Goldilocks add/sub/reduce contain rare value-dependent correction branches, so dudect results are empirical evidence of no measurable leakage, not a branch-free guarantee. Co-authored-by: Cursor <cursoragent@cursor.com>
Discarded results let the optimizer elide the hashing work being timed, so the dudect suite could pass without measuring anything. Co-authored-by: Cursor <cursoragent@cursor.com>
Narrowing casts silently discarded high 32 bits, letting distinct limb arrays alias to the same serialized digest. Co-authored-by: Cursor <cursoragent@cursor.com>
n13
left a comment
There was a problem hiding this comment.
Reviewed each finding fix against the audit claims, with independent local verification. The security changes are all correct and well-executed — the one thing that needs fixing before merge is that the new alloc_bounds test is flaky and will intermittently fail CI.
What I verified
- Streaming encoder is byte-identical to the old one. I wrote a differential test pinning the pre-PR
bytes_to_u64salgorithm and compared it against bothbytes_to_u64sandbytes_to_u64s_iterfor every input length 0..=1024 with pseudorandom contents — identical output everywhere, including the empty input and 4-byte-aligned edge cases. The 18 existing KAT vectors also pass, so digests are unchanged. - Canonicality rejection is sound and can't reject library output.
digest_to_bytesserializesas_canonical_u64(), so every library-produced digest has limbs< Pandrehash_to_bytes/bytes_to_digestround-trips are infallible for well-behaved callers, exactly as documented. The rejected all-0xFFKAT vector was correctly converted into a rejection test. u64s_to_digest/try_u128_to_quantized_feltmatch the adjacent conventions (as_32_bit_limb_u64reuse, mirror oftry_felt_to_quantized_u128). Panic path is gone.- Goldilocks branch claims in the docs are accurate —
add/sub/reduce128do contain rare value-dependent correction branches (branch_hint), so narrowing the README claim to empirical dudect evidence is the honest fix; a branchless rewrite would have been riskier. - No perf regression:
hash_only/hash_bytes/8192measured 266.9µs on this branch vs 273.2µs on main (same machine, within noise). - Hygiene: full suite passes single-threaded (72 tests),
cargo clippy --workspace --all-targets --all-features --release -- -D warningsclean, no hidden/bidi unicode in any changed file.
Blocking: alloc_bounds heap test is flaky
hash_squeeze_twice_uses_constant_heap failed on my first local cargo test run:
peak heap growth was 9757 bytes (budget: 4096)
Reproduction rates on an M-series mac: 4/50 runs (debug, default test threads), 4/100 (release, default threads), 0/100 with --test-threads=1. CI runs cargo test --locked --release with default threads on two OSes, so at ~4% per binary run this will produce regular spurious failures.
Root cause: MEASURE_LOCK serializes the two test bodies, but libtest still spawns both test threads concurrently — harness bookkeeping and the blocked thread's setup allocate while the first test is measuring, and the global PEAK_ALLOCATED counter attributes that to the measured closure. The comment on MEASURE_LOCK anticipates exactly this failure mode; the lock just can't fully prevent it.
Two options, either is fine:
- Min over N runs (small diff, verified locally — 0 failures in 200 release runs with default threads):
/// Runs `f` and returns its result plus the peak heap growth (in bytes) observed while it ran.
/// Caller must hold `MEASURE_LOCK`. Takes the minimum over several runs: `f` is deterministic,
/// so the minimum converges to its true peak while filtering out concurrent allocations from
/// libtest harness threads.
fn measure_peak_delta<T>(f: impl Fn() -> T) -> (T, usize) {
let mut best_peak = usize::MAX;
let mut result = None;
for _ in 0..5 {
let baseline = CURRENT_ALLOCATED.load(SeqCst);
PEAK_ALLOCATED.store(baseline, SeqCst);
result = Some(f());
let peak = PEAK_ALLOCATED.load(SeqCst);
best_peak = best_peak.min(peak.saturating_sub(baseline));
}
(result.expect("ran at least once"), best_peak)
}- Thread-scoped tracking — a thread-local flag set only inside
measure_peak_deltaso the allocator ignores other threads entirely. Deterministic, slightly more code.
Non-blocking notes
BytesToU64sIterhas nosize_hint, sobytes_to_u64s/bytes_to_feltslost the exactwith_capacitypre-allocation the old implementation had and now grow theVecgeometrically. The output length is exactly(input.len() + 1).div_ceil(4)(wait —+1for the terminator, padded), so anExactSizeIteratorimpl is trivial and restores the old allocation behavior for the collecting APIs.- Downstream coordination:
Quantus-Network/chainprimitives/wormholecallsrehash_to_bytesinderive_wormhole_address, whichextract_author_from_digestfeeds with the miner-supplied 32-byte PreRuntime preimage. On upgrade, that call site must decide explicitly what a rejected non-canonical preimage means (skip attribution? invalid block?) — it's a consensus-visible behavior change from the current silent aliasing, which is precisely the audit issue. Probability of rejecting a random preimage is ~2^-30, so honest-miner impact is negligible, but the error path needs to exist and be deliberate. - Version is still 2.1.0 with an Unreleased changelog section — consistent with the release-proposal workflow, just remember this needs the major bump option when cutting the release.
Verdict: request changes for the test flake only; with alloc_bounds de-flaked this is ready to merge.
n13
left a comment
There was a problem hiding this comment.
Re-reviewed the three commits since my last review (20721be, e4ac1e9, 84f9d90). All points addressed — approving.
Blocking item resolved: alloc_bounds flake
measure_peak_delta now takes the minimum peak over 5 runs. Re-ran the same stress setup that reproduced the flake before:
| Configuration | Before | After |
|---|---|---|
| release, default test threads | 4/100 failures | 0/200 |
| debug, default test threads | 4/50 failures | 0/100 |
Consider it de-flaked.
size_hint / ExactSizeIterator
Verified correct: re-ran my differential test against the pinned pre-PR bytes_to_u64s algorithm for every input length 0..=1024 — output still byte-identical and iter.len() matches the old vector length everywhere. The exactness invariants ((len+1).div_ceil(4), stays exact while consuming, terminator-word accounting with pos never advancing past the last full chunk) all check out, and the new unit test covers the edge lengths. Collecting APIs get their exact pre-allocation back.
New bytes_to_digest_lossy
Reasonable escape hatch: behavior is exactly the pre-PR bytes_to_digest, the non-injectivity is loudly documented with the correct aliasing probability (~2^-32 per limb), the aliasing is pinned by a test, and the strict decoder remains the default — rehash_to_bytes still goes through the strict path, so the wormhole derivation boundary keeps its canonicality check.
One caution for the downstream migration (chain, zk-circuits): the lossy variant should be reserved for its stated purpose — binding genuinely non-field data (e.g. Blake2 roots) into a Poseidon preimage. A blanket bytes_to_digest(x) → bytes_to_digest_lossy(x) substitution to avoid Result handling would silently reintroduce finding #96398 at that call site. Worth watching for in the dependent-repo PRs.
Re-verified on the new head
- 75 tests pass (62 unit + 11 integration + 2 doc), KAT vectors unchanged
cargo clippy --workspace --all-targets --all-features --release -- -D warningsclean- CI green on both OSes
- Still pending at release time: this needs the major bump option in the release workflow.
|
v12 pls audit |
|
Warning V12 could not find a connected workspace for this repository. Make sure the repository owner has signed in to V12 and connected their GitHub account (or that the V12 GitHub App is installed for the owning organization), then comment again. |
Address V12 audit findings (v12 report)
Fixes all six findings from the V12 audit. Each fix landed as its own commit with a regression test confirming the issue before the fix was applied.
Security fixes
Unbounded byte-hash allocation (#96388, Medium) —
hash_bytesandhash_squeeze_twicematerialized the entire serialized preimage (~3x input size across three heap buffers) before absorbing into the sponge, allowing memory-amplification DoS on attacker-sized inputs. Byte absorption now streams 4-byte chunks directly into the sponge via a new sharedbytes_to_felts_iter/bytes_to_u64s_iterencoding iterator, keeping heap usage O(1) while producing byte-identical digests (verified against existing known-answer vectors).Non-canonical 8-byte limb decoding (#96398, Medium) —
bytes_to_digestandbytes_to_felts_compactaccepted raw u64 limbs without checkingvalue < P. SincePand0are the same Goldilocks element, byte-distinct inputs (e.g. a limb ofPvs0) decoded to identical felts and collided underrehash_to_bytesand compact hashing. These decoders now reject non-canonical limbs.Panicking amount conversion (#96399, Medium) —
u128_to_quantized_feltpanicked viaassert!on amounts whose quantized value exceeds 32 bits, turning input validation into a DoS vector (especially underpanic=abort). Replaced bytry_u128_to_quantized_felt, which returns a recoverable error, mirroring the existing reverse conversion.Digest limb truncation (#96397, Low) —
u64s_to_digestnarrowed each u64 limb withas u32, silently discarding high bits so distinct limb arrays aliased to the same wire digest. It now rejects limbs exceeding 32 bits.Benchmark discards timed outputs (#96386, Low) — every timed closure in the dudect constant-time harness (
examples/ct_bench.rs) discarded its result, permitting the optimizer to elide the very work being measured. All inputs and outputs are now routed throughblack_box.Constant-time documentation (#96400, Medium, audit PoC marked invalid) — the README claimed "no input-dependent branches," but Goldilocks add/sub/reduce contain rare carry/borrow correction branches (standard optimized Goldilocks arithmetic, as in plonky2/plonky3). The audit's own measurements found no exploitable timing distinguisher, so rather than a risky branchless rewrite, the claim is narrowed: dudect results are empirical evidence of no measurable leakage, not a branch-free guarantee.
Breaking API changes
Decoding/parsing functions at trust boundaries are now fallible, consistent with the existing
try_*/Resultconventions in the crate:bytes_to_digestResult— rejects limbs>= Pbytes_to_felts_compactResult— rejects limbs>= Prehash_to_bytesResult— propagates digest decode errorsu128_to_quantized_felttry_u128_to_quantized_felt, returnsResultu64s_to_digestResult— rejects limbs> u32::MAXDigests produced by this library are always canonical, so well-behaved callers can
?/expectthese results. A semver-major release is warranted.Tests
Four new integration test files (
alloc_bounds,non_canonical_limbs,quantized_amount,digest_limb_truncation) covering each finding, including a tracking-allocator harness asserting O(1) heap for byte hashing. Full suite: 72 tests passing, clippy clean, dudect harness verified running in release mode.Note
High Risk
Breaking public APIs plus cryptographic parsing and hash-input handling changes; callers must handle
Resultand a major release is expected, though library-produced digests remain canonical.Overview
Addresses the V12 audit with breaking security and API hardening across hashing, serialization, and docs/tests.
Hashing / DoS:
hash_bytesandhash_squeeze_twiceno longer build a full serialized preimage on the heap; they stream the injective encoding via newbytes_to_felts_iter/bytes_to_u64s_iterinto the sponge (O(1) extra heap, same digests). Integration tests use a tracking allocator to enforce that.Collision / validation at decode boundaries:
bytes_to_digest,bytes_to_felts_compact, andrehash_to_bytesnow returnResultand reject 8-byte limbs with value>= P.u64s_to_digestreturnsResultand rejects limbs wider than 32 bits instead of truncating.u128_to_quantized_feltis replaced bytry_u128_to_quantized_felt(no panic on oversized amounts).Timing / docs:
ct_benchwraps measured work inblack_box; README andCONSTANT_TIME_TESTING.mdnarrow constant-time claims to empirical dudect evidence (Goldilocks arithmetic is not strictly branch-free).New regression tests cover allocation bounds, non-canonical limbs, digest truncation, and quantized amounts; changelog documents unreleased breaking changes.
Reviewed by Cursor Bugbot for commit 062e827. Configure here.