Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ src/cmake-build-debug/
# Rust
/target
.vscode
src/build/
24 changes: 21 additions & 3 deletions src/bqfc.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down
6 changes: 4 additions & 2 deletions src/bqfc.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/discriminant_bounds_regression_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<size_t>(BQFC_MAX_D_BITS) + 1),
bqfc_deserialize(out_a, out_b, D, serialized, BQFC_FORM_SIZE, static_cast<size_t>(BQFC_MAX_D_BITS) + 1, true),
-1);

mpz_clear(D);
Expand Down
2 changes: 1 addition & 1 deletion src/hw/hw_proof.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions src/proof_common.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ std::vector<unsigned char> 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);
Expand Down
59 changes: 59 additions & 0 deletions src/proof_deserialization_regression_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint8_t> 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<size_t>(BQFC_FORM_SIZE));

EXPECT_NO_THROW((void)DeserializeForm(d, canonical.data(), canonical.size(), true));

std::vector<uint8_t> 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<size_t>(BQFC_FORM_SIZE));

EXPECT_NO_THROW((void)DeserializeForm(d, canonical.data(), canonical.size(), false));

std::vector<uint8_t> 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));
}
34 changes: 34 additions & 0 deletions src/python_bindings/fastvdf.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -118,6 +138,20 @@ PYBIND11_MODULE(chiavdf, m) {
return py::tuple(py::make_tuple(result.first, res_bytes));
});

// Low-level BQFC form deserialization with strict flag.
// 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 {
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(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,
const string& x_s,
const string& proof_blob,
Expand Down
38 changes: 38 additions & 0 deletions tests/test_bqfc_deserialize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest

from chiavdf import bqfc_deserialize


B0_DISCRIMINANT = (
"-146212091130374364448271598629912687111631974722846603227183769906935970876483871782840562162445571052154480"
"975719448767769767557905129461524079902394315542354994269060181795718055043487735056120915916768273200138311"
"940357886024014124174476991145983171370265799623472241486347111977874193600694306566545523111"
)

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)
Loading