bqfc_decompr: reject decompressed forms where |b| > a (fixes b0 malleability)#335
bqfc_decompr: reject decompressed forms where |b| > a (fixes b0 malleability)#335richardkiss wants to merge 3 commits intoChia-Network:mainfrom
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.
| * Reject if |b| > a. For a reduced form, |b| <= a must hold. If b0 is | ||
| * inflated (e.g. b0 = canonical_b0 + 4k for k != 0) the decoded b lands | ||
| * outside this range even though bqfc_verify_canon would otherwise pass | ||
| * (the self-consistency check encode(decode(X))==X is satisfied for any | ||
| * b0 ≡ canonical_b0 mod (a/gcd(a,t))). Rejecting here makes the | ||
| * canonical-check a proper uniqueness gate. | ||
| */ | ||
| if (mpz_cmpabs(out_b, out_a) > 0) { |
There was a problem hiding this comment.
The new non-canonical rejection only checks |b| > a, but in this codebase the canonical normalized range for reduced forms is -a < b <= a (i.e., b == -a should also be rejected). As written, an encoding that decodes to b == -a would pass this check and can still be normalized by later reduction logic, leaving a potential alternative encoding path. Consider extending the predicate to also fail when out_b == -out_a (or equivalently abs(b)==a && b<0).
| * Reject if |b| > a. For a reduced form, |b| <= a must hold. If b0 is | |
| * inflated (e.g. b0 = canonical_b0 + 4k for k != 0) the decoded b lands | |
| * outside this range even though bqfc_verify_canon would otherwise pass | |
| * (the self-consistency check encode(decode(X))==X is satisfied for any | |
| * b0 ≡ canonical_b0 mod (a/gcd(a,t))). Rejecting here makes the | |
| * canonical-check a proper uniqueness gate. | |
| */ | |
| if (mpz_cmpabs(out_b, out_a) > 0) { | |
| * Reject if |b| > a, or if b == -a. For a reduced form in this codebase, | |
| * the canonical range is -a < b <= a, so b == -a must also be rejected. | |
| * If b0 is inflated (e.g. b0 = canonical_b0 + 4k for k != 0) the decoded | |
| * b lands outside this range even though bqfc_verify_canon would | |
| * otherwise pass (the self-consistency check encode(decode(X))==X is | |
| * satisfied for any b0 ≡ canonical_b0 mod (a/gcd(a,t))). Rejecting here | |
| * makes the canonical-check a proper uniqueness gate. | |
| */ | |
| if (mpz_cmpabs(out_b, out_a) > 0 || | |
| (mpz_cmpabs(out_b, out_a) == 0 && mpz_sgn(out_b) < 0)) { |
| // b0 ^= 0x04 → b0 = 0x05 (same mod-4 residue, inflated by 4) | ||
| // decodes to a class-equivalent but non-reduced form; must be rejected | ||
| std::vector<uint8_t> mutated = canonical; | ||
| mutated[99] ^= 0x04; | ||
| EXPECT_THROW((void)DeserializeForm(d, mutated.data(), mutated.size()), |
There was a problem hiding this comment.
This test hard-codes the b0 byte offset as 99. Since b0 is the last byte for this vector (g_size=0), using BQFC_FORM_SIZE - 1 (or computing the offset from the format fields) would make the test resilient to future format/size changes and avoid the magic number.
| // b0 ^= 0x08 → b0 = 0x09 (inflated by 8); also must be rejected | ||
| mutated = canonical; | ||
| mutated[99] ^= 0x08; | ||
| EXPECT_THROW((void)DeserializeForm(d, mutated.data(), mutated.size()), | ||
| std::runtime_error); |
There was a problem hiding this comment.
Same as above: this second mutation also uses the hard-coded index 99 for b0. Prefer BQFC_FORM_SIZE - 1 (or a derived offset) to avoid a brittle magic number.
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
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
After
bqfc_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.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 bounds check here:Regression test
Added
RejectsInflatedB0Fieldtoproof_deserialization_regression_test.cppusing a real Chia mainnet vector (block 309155, CC infusion-point VDF). The canonicalb0=0x01is accepted;b0=0x05andb0=0x09(same mod-4 residue, previously accepted) are now rejected.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 consensus-adjacent proof/form deserialization logic and changes function signatures, so missed call-site updates or strictness toggling could cause verification incompatibilities or unexpected rejects.
Overview
Hardens BQFC form decoding against
b0malleability.bqfc_decompr/bqfc_deserializenow accept astrictflag and, when enabled, reject decoded forms where|b| > a(preventing multiple self-consistent serializations of the same class-group element).Call sites are updated to plumb the new flag (keeping existing behavior lenient in
hw_proofand defaultingDeserializeFormto non-strict for compatibility), and new regression tests (C++ and Python) cover canonical vs inflatedb0vectors. The Python module also exposes a low-levelbqfc_deserializehelper returning signed big-endian(a,b)bytes for external verification/debugging.Reviewed by Cursor Bugbot for commit 7e62470. Bugbot is set up for automated code reviews on this repo. Configure here.