From c3f6b0597867b10fdd64b63621f814747c324c29 Mon Sep 17 00:00:00 2001 From: Richard Kiss Date: Thu, 12 Mar 2026 16:20:27 -0700 Subject: [PATCH 1/4] bqfc: add strict flag for b0 reduced-form check 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 --- .gitignore | 1 + src/bqfc.c | 24 +++++++- src/bqfc.h | 6 +- src/hw/hw_proof.cpp | 2 +- src/proof_common.h | 5 +- src/proof_deserialization_regression_test.cpp | 59 +++++++++++++++++++ 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 1e1a2618..61deee65 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ src/cmake-build-debug/ # Rust /target .vscode +src/build/ diff --git a/src/bqfc.c b/src/bqfc.c index 9286ceab..1e164fec 100644 --- a/src/bqfc.c +++ b/src/bqfc.c @@ -53,7 +53,8 @@ int bqfc_compr(struct qfb_c *out_c, mpz_t a, mpz_t b) return 0; } -int bqfc_decompr(mpz_t out_a, mpz_t out_b, const mpz_t D, const struct qfb_c *c) +int bqfc_decompr(mpz_t out_a, mpz_t out_b, const mpz_t D, const struct qfb_c *c, + bool strict) { int ret = 0; mpz_t tmp, t, t_inv, d; @@ -114,6 +115,22 @@ int bqfc_decompr(mpz_t out_a, mpz_t out_b, const mpz_t D, const struct qfb_c *c) mpz_neg(out_b, out_b); } + /* + * 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))). + * + * When strict == true this makes the canonical-check a proper uniqueness + * gate. When false we preserve the historical (pre-2026) behaviour for + * consensus compatibility. + */ + if (strict && mpz_cmpabs(out_b, out_a) > 0) { + ret = -1; + goto out; + } + out: mpz_clears(tmp, t, t_inv, d, NULL); return ret; @@ -290,7 +307,8 @@ static int bqfc_verify_canon(mpz_t a, mpz_t b, const uint8_t *str, size_t d_bits return memcmp(canon_str, str, BQFC_FORM_SIZE); } -int bqfc_deserialize(mpz_t out_a, mpz_t out_b, const mpz_t D, const uint8_t *str, size_t size, size_t d_bits) +int bqfc_deserialize(mpz_t out_a, mpz_t out_b, const mpz_t D, const uint8_t *str, + size_t size, size_t d_bits, bool strict) { struct qfb_c f_c; int ret; @@ -313,7 +331,7 @@ int bqfc_deserialize(mpz_t out_a, mpz_t out_b, const mpz_t D, const uint8_t *str if (ret) goto out; - ret = bqfc_decompr(out_a, out_b, D, &f_c); + ret = bqfc_decompr(out_a, out_b, D, &f_c, strict); if (ret) goto out; diff --git a/src/bqfc.h b/src/bqfc.h index bccdaa98..dd352678 100644 --- a/src/bqfc.h +++ b/src/bqfc.h @@ -21,12 +21,14 @@ struct qfb_c { int bqfc_compr(struct qfb_c *out_c, mpz_t a, mpz_t b); -int bqfc_decompr(mpz_t out_a, mpz_t out_b, const mpz_t D, const struct qfb_c *c); +int bqfc_decompr(mpz_t out_a, mpz_t out_b, const mpz_t D, const struct qfb_c *c, + bool strict); int bqfc_serialize_only(uint8_t *out_str, const struct qfb_c *c, size_t d_bits); int bqfc_deserialize_only(struct qfb_c *out_c, const uint8_t *str, size_t d_bits); int bqfc_serialize(uint8_t *out_str, mpz_t a, mpz_t b, size_t d_bits); -int bqfc_deserialize(mpz_t out_a, mpz_t out_b, const mpz_t D, const uint8_t *str, size_t size, size_t d_bits); +int bqfc_deserialize(mpz_t out_a, mpz_t out_b, const mpz_t D, const uint8_t *str, + size_t size, size_t d_bits, bool strict); #endif // BQFC_H diff --git a/src/hw/hw_proof.cpp b/src/hw/hw_proof.cpp index 4b510f49..c6e1bec1 100644 --- a/src/hw/hw_proof.cpp +++ b/src/hw/hw_proof.cpp @@ -821,7 +821,7 @@ void init_vdf_state(struct vdf_state *vdf, struct vdf_proof_opts *opts, const ch init_vdf_value(&vdf->last_val); // TODO: verify validity of initial form bqfc_deserialize(vdf->last_val.a, vdf->last_val.b, vdf->D.impl, init_form, - BQFC_FORM_SIZE, BQFC_MAX_D_BITS); + BQFC_FORM_SIZE, BQFC_MAX_D_BITS, false); hw_proof_get_form(hw_proof_value_at(vdf, 0), vdf, &vdf->last_val); vdf->valid_values[0] = 1 << 0; //vdf->raw_values.push_back(initial); diff --git a/src/proof_common.h b/src/proof_common.h index c2410584..5cf32c8b 100644 --- a/src/proof_common.h +++ b/src/proof_common.h @@ -51,10 +51,11 @@ std::vector SerializeForm(form &y, int d_bits) return res; } -form DeserializeForm(const integer &D, const uint8_t *bytes, size_t size) +form DeserializeForm(const integer &D, const uint8_t *bytes, size_t size, + bool strict = false) { integer a, b; - if (bqfc_deserialize(a.impl, b.impl, D.impl, bytes, size, D.num_bits())) { + if (bqfc_deserialize(a.impl, b.impl, D.impl, bytes, size, D.num_bits(), strict)) { throw std::runtime_error("Deserializing compressed form failed"); } form f = form::from_abd(a, b, D); diff --git a/src/proof_deserialization_regression_test.cpp b/src/proof_deserialization_regression_test.cpp index afd261f6..21f349c5 100644 --- a/src/proof_deserialization_regression_test.cpp +++ b/src/proof_deserialization_regression_test.cpp @@ -54,3 +54,62 @@ TEST(ProofDeserializationRegressionTest, RejectsNonCanonicalEncoding) { canonical[BQFC_FORM_SIZE - 1] ^= 0x01; EXPECT_THROW((void)DeserializeForm(d, canonical.data(), canonical.size()), std::runtime_error); } + +// Regression test for b0 malleability: for a form with g=2 (g_size=0), the +// b0 field at byte 99 admits multiple self-consistent encodings b0, b0+4, +// b0+8, … that decode to class-equivalent (but not equal) forms. All values +// other than the canonical b0 must be rejected. +// +// Vector: Chia mainnet block height 309155, CC infusion-point VDF, +// 1024-bit discriminant. Original b0 = 0x01; XOR 0x04 gives b0 = 0x05 +// (same mod-4 residue → would previously pass bqfc_verify_canon). +// Mainnet block height 309155, CC infusion-point VDF, 1024-bit discriminant. +// g=2, g_size=0, b0=0x01 at byte 99. Inflating b0 by +4/+8 produces +// self-consistent encodings that pass the old bqfc_verify_canon round-trip +// but violate |b| <= a. strict=true rejects them; strict=false accepts. + +static integer get_b0_discriminant() { + return integer("-146212091130374364448271598629912687111631974722846603227183769906935970876483871782840562162445571052154480975719448767769767557905129461524079902394315542354994269060181795718055043487735056120915916768273200138311940357886024014124174476991145983171370265799623472241486347111977874193600694306566545523111"); +} + +static std::vector get_b0_canonical_form() { + return hex_to_bytes( + "0300d8262c430e78e7c06cf60c9b2049968f604f3b506a85bfe4fff319f8176760" + "e06cab8ab45524458bf558101f9b4ce8c23cc1e053263272b808b76c6f26493a11" + "3b62ded5707b28d9eedc0503ac2efcd32be670726725be0fa7ea01f0ef3f602502" + "01"); +} + +TEST(ProofDeserializationRegressionTest, StrictRejectsInflatedB0Field) { + integer d = get_b0_discriminant(); + auto canonical = get_b0_canonical_form(); + ASSERT_EQ(canonical.size(), static_cast(BQFC_FORM_SIZE)); + + EXPECT_NO_THROW((void)DeserializeForm(d, canonical.data(), canonical.size(), true)); + + std::vector mutated = canonical; + mutated[99] ^= 0x04; + EXPECT_THROW((void)DeserializeForm(d, mutated.data(), mutated.size(), true), + std::runtime_error); + + mutated = canonical; + mutated[99] ^= 0x08; + EXPECT_THROW((void)DeserializeForm(d, mutated.data(), mutated.size(), true), + std::runtime_error); +} + +TEST(ProofDeserializationRegressionTest, LenientAcceptsInflatedB0Field) { + integer d = get_b0_discriminant(); + auto canonical = get_b0_canonical_form(); + ASSERT_EQ(canonical.size(), static_cast(BQFC_FORM_SIZE)); + + EXPECT_NO_THROW((void)DeserializeForm(d, canonical.data(), canonical.size(), false)); + + std::vector mutated = canonical; + mutated[99] ^= 0x04; + EXPECT_NO_THROW((void)DeserializeForm(d, mutated.data(), mutated.size(), false)); + + mutated = canonical; + mutated[99] ^= 0x08; + EXPECT_NO_THROW((void)DeserializeForm(d, mutated.data(), mutated.size(), false)); +} From 1c85bbdd428a2cb0a2c2952b6b1b337ffe811316 Mon Sep 17 00:00:00 2001 From: Richard Kiss Date: Tue, 28 Apr 2026 18:59:08 -0700 Subject: [PATCH 2/4] Expose bqfc_deserialize to Python with strict flag Allows differential testing of strict vs lenient BQFC deserialization from Python. Made-with: Cursor --- src/python_bindings/fastvdf.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/python_bindings/fastvdf.cpp b/src/python_bindings/fastvdf.cpp index dc11a621..d3d6173d 100644 --- a/src/python_bindings/fastvdf.cpp +++ b/src/python_bindings/fastvdf.cpp @@ -118,6 +118,19 @@ PYBIND11_MODULE(chiavdf, m) { return py::tuple(py::make_tuple(result.first, res_bytes)); }); + // Low-level BQFC form deserialization with strict flag. + // Returns (a_str, b_str) as decimal strings, or raises on error. + m.def("bqfc_deserialize", [] (const string& discriminant, + const string& data, + bool strict) -> py::tuple { + integer D(discriminant); + if (data.size() != BQFC_FORM_SIZE) { + throw std::runtime_error("expected 100-byte form"); + } + form f = DeserializeForm(D, (const uint8_t *)data.data(), data.size(), strict); + return py::tuple(py::make_tuple(f.a.to_string(), f.b.to_string())); + }, py::arg("discriminant"), py::arg("data"), py::arg("strict") = true); + m.def("get_b_from_n_wesolowski", [] (const string& discriminant, const string& x_s, const string& proof_blob, From 7e624704420d7d4ddca352f399828e13b37b1104 Mon Sep 17 00:00:00 2001 From: Richard Kiss Date: Thu, 30 Apr 2026 00:19:18 -0700 Subject: [PATCH 3/4] Expose strict BQFC deserialization in Python 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 --- src/python_bindings/fastvdf.cpp | 25 +++++++++++++++++++++-- tests/test_bqfc_deserialize.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/test_bqfc_deserialize.py diff --git a/src/python_bindings/fastvdf.cpp b/src/python_bindings/fastvdf.cpp index d3d6173d..ab7831c8 100644 --- a/src/python_bindings/fastvdf.cpp +++ b/src/python_bindings/fastvdf.cpp @@ -5,6 +5,26 @@ namespace py = pybind11; +static py::bytes to_signed_bytes_be(const integer& value) { + std::string out; + out.push_back(mpz_sgn(value.impl) < 0 ? '\x01' : '\x00'); + + mpz_t magnitude; + mpz_init(magnitude); + mpz_abs(magnitude, value.impl); + + if (mpz_sgn(magnitude) != 0) { + size_t count = 0; + std::string mag_bytes((mpz_sizeinbase(magnitude, 2) + 7) / 8, '\0'); + mpz_export(mag_bytes.data(), &count, 1, 1, 1, 0, magnitude); + mag_bytes.resize(count); + out += mag_bytes; + } + + mpz_clear(magnitude); + return py::bytes(out); +} + PYBIND11_MODULE(chiavdf, m) { m.doc() = "Chia proof of time"; @@ -119,7 +139,8 @@ PYBIND11_MODULE(chiavdf, m) { }); // Low-level BQFC form deserialization with strict flag. - // Returns (a_str, b_str) as decimal strings, or raises on error. + // Returns (a_bytes, b_bytes) using chia-vdf-verify's signed big-endian + // format: sign byte (0 = non-negative, 1 = negative), then magnitude. m.def("bqfc_deserialize", [] (const string& discriminant, const string& data, bool strict) -> py::tuple { @@ -128,7 +149,7 @@ PYBIND11_MODULE(chiavdf, m) { throw std::runtime_error("expected 100-byte form"); } form f = DeserializeForm(D, (const uint8_t *)data.data(), data.size(), strict); - return py::tuple(py::make_tuple(f.a.to_string(), f.b.to_string())); + return py::tuple(py::make_tuple(to_signed_bytes_be(f.a), to_signed_bytes_be(f.b))); }, py::arg("discriminant"), py::arg("data"), py::arg("strict") = true); m.def("get_b_from_n_wesolowski", [] (const string& discriminant, diff --git a/tests/test_bqfc_deserialize.py b/tests/test_bqfc_deserialize.py new file mode 100644 index 00000000..ada24753 --- /dev/null +++ b/tests/test_bqfc_deserialize.py @@ -0,0 +1,36 @@ +import pytest + +from chiavdf import bqfc_deserialize + + +B0_DISCRIMINANT = ( + "-146212091130374364448271598629912687111631974722846603227183769906935970876483871782840562162445571052154480975719448767769767557905129461524079902394315542354994269060181795718055043487735056120915916768273200138311940357886024014124174476991145983171370265799623472241486347111977874193600694306566545523111" +) + +B0_CANONICAL_FORM = bytes.fromhex( + "0300d8262c430e78e7c06cf60c9b2049968f604f3b506a85bfe4fff319f8176760" + "e06cab8ab45524458bf558101f9b4ce8c23cc1e053263272b808b76c6f26493a11" + "3b62ded5707b28d9eedc0503ac2efcd32be670726725be0fa7ea01f0ef3f602502" + "01" +) + + +def test_bqfc_deserialize_returns_signed_big_endian_bytes(): + a, b = bqfc_deserialize(B0_DISCRIMINANT, B0_CANONICAL_FORM) + + assert isinstance(a, bytes) + assert isinstance(b, bytes) + assert a[0] == 0 + assert b[0] in (0, 1) + + +def test_bqfc_deserialize_strict_rejects_inflated_b0_by_default(): + mutated = bytearray(B0_CANONICAL_FORM) + mutated[99] ^= 0x04 + + with pytest.raises(RuntimeError): + bqfc_deserialize(B0_DISCRIMINANT, bytes(mutated)) + + a, b = bqfc_deserialize(B0_DISCRIMINANT, bytes(mutated), strict=False) + assert isinstance(a, bytes) + assert isinstance(b, bytes) From bd82b74c359248fc45c27411a14dd7a12db1ba83 Mon Sep 17 00:00:00 2001 From: Richard Kiss Date: Tue, 12 May 2026 18:35:29 -0700 Subject: [PATCH 4/4] Fix BQFC strict-mode CI failures Update the regression test call site for the strict deserializer argument and keep the Python test within lint limits. Co-authored-by: Cursor --- src/discriminant_bounds_regression_test.cpp | 2 +- tests/test_bqfc_deserialize.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/discriminant_bounds_regression_test.cpp b/src/discriminant_bounds_regression_test.cpp index 83fa3563..7bc3bf2e 100644 --- a/src/discriminant_bounds_regression_test.cpp +++ b/src/discriminant_bounds_regression_test.cpp @@ -96,7 +96,7 @@ TEST(DiscriminantBoundsRegressionTest, BqfcDeserializationRejectsOversizedDiscri mpz_init(out_b); EXPECT_EQ( - bqfc_deserialize(out_a, out_b, D, serialized, BQFC_FORM_SIZE, static_cast(BQFC_MAX_D_BITS) + 1), + bqfc_deserialize(out_a, out_b, D, serialized, BQFC_FORM_SIZE, static_cast(BQFC_MAX_D_BITS) + 1, true), -1); mpz_clear(D); diff --git a/tests/test_bqfc_deserialize.py b/tests/test_bqfc_deserialize.py index ada24753..f60ee802 100644 --- a/tests/test_bqfc_deserialize.py +++ b/tests/test_bqfc_deserialize.py @@ -4,7 +4,9 @@ B0_DISCRIMINANT = ( - "-146212091130374364448271598629912687111631974722846603227183769906935970876483871782840562162445571052154480975719448767769767557905129461524079902394315542354994269060181795718055043487735056120915916768273200138311940357886024014124174476991145983171370265799623472241486347111977874193600694306566545523111" + "-146212091130374364448271598629912687111631974722846603227183769906935970876483871782840562162445571052154480" + "975719448767769767557905129461524079902394315542354994269060181795718055043487735056120915916768273200138311" + "940357886024014124174476991145983171370265799623472241486347111977874193600694306566545523111" ) B0_CANONICAL_FORM = bytes.fromhex(