BQFC: reject non-reduced decompressed forms in strict mode#335
Conversation
There was a problem hiding this comment.
Pull request overview
This PR tightens compressed quadratic-form deserialization to eliminate a known malleability vector in the b0 field, and adds a regression test using a real mainnet-derived vector.
Changes:
- Add an additional validity check during
bqfc_decomprto reject certain non-canonical decodings. - Add a regression test that mutates
b0(inflation by +4/+8) and asserts deserialization rejection.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/proof_deserialization_regression_test.cpp |
Adds a regression test covering b0 inflation malleability using a fixed mainnet vector. |
src/bqfc.c |
Adds a post-decompression bound check intended to reject inflated b0 encodings that previously passed canonical verification. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Add a `bool strict` parameter to bqfc_decompr and bqfc_deserialize. When strict=true, reject decompressed forms where |b| > a (the reduced form invariant). This catches inflated b0 encodings that pass the self-consistency round-trip check but violate uniqueness. When strict=false (the default), preserve the historical behaviour for consensus compatibility with existing chain data. DeserializeForm() defaults to strict=false so all existing callers are unaffected. The flag can be toggled per-call site when a hard fork activates stricter validation. Tests cover both modes using the mainnet block 309155 vector. Made-with: Cursor
027fd06 to
c3f6b05
Compare
Allows differential testing of strict vs lenient BQFC deserialization from Python. Made-with: Cursor
Return signed big-endian bytes from the Python bqfc_deserialize helper to match chia-vdf-verify, and test strict-by-default rejection with lenient opt-out. Made-with: Cursor
|
We're going to get around to a chiavdf release one of these days. Should this get in? Happy to pick it up and finish it if so. |
This is entirely AI generated, with guidance from me. Seems like non-canonical proofs are accepted, as was discovered by differential fuzzing with the newly created rust vdf crate. This lets us tighten this gap when HF2 kicks in. A similar knob exists in the rust vdf api. |
|
Close and re-open to reset baseline CI status |
Update the regression test call site for the strict deserializer argument and keep the Python test within lint limits. Co-authored-by: Cursor <cursoragent@cursor.com>
Fixes #334.
Problem
bqfc_verify_canonperforms a self-consistency check —encode(decode(X)) == X— rather than a uniqueness check —encode(reduce(decode(X))) == X.For forms with
g > 1, theb0field can be inflated by any multiple ofa' = a/gand still round-trip throughbqfc_compr, because thexgcd_partialEuclidean path is unaffected andb0 = floor(|b|/a')picks up the inflated quotient.Concretely (1024-bit discriminant,
g=2,g_size=0): byte 99 (b0) accepts 64 distinct values —b0,b0+4,b0+8, … — instead of 1.DeserializeFormsilently reduces the form viafrom_abd, so Wesolowski verification passes with the correct (reduced) y. The proof is malleable: the same mathematical VDF output has 64 valid serializations.Fix
Add a
strictflag to BQFC form deserialization.When
strict=true, afterbqfc_decomprcomputes(out_a, out_b), assert|out_b| <= out_a. A reduced binary quadratic form requires|b| <= a; if this is violated the encoding cannot be canonical.When
strict=false, preserve the historical behavior and accept these forms for compatibility with existing consensus behavior.The check is a single
mpz_cmpabscall — no need to computecor run the full Pulmark reduction.Why this is the right place
The root cause is that
bqfc_decomprcan produce a non-reduced(out_a, out_b), andbqfc_verify_canoncompares against the encoding of that non-reduced form rather than the reduced representative. Adding the optional strict bounds check here:This PR does not change default consensus behavior unless consensus callers opt into
strict=true.Regression test
Added strict/lenient regression coverage to
proof_deserialization_regression_test.cppusing a real Chia mainnet vector (block 309155, CC infusion-point VDF). The canonicalb0=0x01is accepted; inflatedb0=0x05andb0=0x09are rejected withstrict=trueand accepted withstrict=false.The Python module also exposes
bqfc_deserialize(..., strict=True)for testing and differential fuzzing. Its default is strict, andstrict=Falsepreserves the historical behavior.Testing
Discovered via differential fuzzing of
chia-vdf-verify(pure-Rust reimplementation) against chiavdf, where Rust's stricteris_reduced()check exposed the discrepancy. See the characterization in issue #334.Made with Cursor
Note
Medium Risk
Touches BQFC form decompression/deserialization used in proof verification; the new
strictpath can change acceptance of previously-valid encodings and introduces a new API parameter that callers must handle correctly.Overview
Adds an optional
strictmode to BQFC decompression/deserialization (bqfc_decompr/bqfc_deserialize) that rejects decoded forms where|b| > a, closing ab0-inflation malleability gap while preserving legacy behavior whenstrict=false.Updates call sites to thread the new flag (e.g., hardware init uses lenient mode) and extends regression coverage to assert strict rejection vs lenient acceptance of inflated
b0encodings; also exposes a new Python bindingchiavdf.bqfc_deserialize(..., strict=True)plus pytest coverage, and ignoressrc/build/in.gitignore.Reviewed by Cursor Bugbot for commit bd82b74. Bugbot is set up for automated code reviews on this repo. Configure here.