From fae9f407718512ce613f30440fef1be2ace35f74 Mon Sep 17 00:00:00 2001 From: "Richard T. Carback III" Date: Fri, 7 Aug 2026 15:44:24 -0400 Subject: [PATCH] test: add ignored VRF output uniqueness regression test (QUI-925) BABE derives its authoring score and epoch-randomness contribution from SHA256(sr25519_pre_output || ml_dsa_signature). ML-DSA-44 is not unique per (key, message), so a block producer can re-sign the same binding message with randomness of their choosing until the score clears the primary threshold. The existing tests cannot catch this. They exercise only the honest signer, which calls sign_deterministic, so they pass on the grindable construction too. This test mints a second valid binding with signer-chosen randomness, asserts the verifier accepts it, then asserts the derived consensus bytes match. It is marked #[ignore] because it fails against the current derivation, which is the defect itself rather than a broken test. QUI-984 makes it pass by deriving make_bytes from the unique sr25519 pre-output alone. Remove #[ignore] as part of that change. --- Cargo.lock | 1 + quip/primitives/crypto/Cargo.toml | 1 + .../crypto/src/substrate/sr25519_mldsa44.rs | 102 ++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 3c50a4d85850..50bcad3de38f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18457,6 +18457,7 @@ dependencies = [ "hkdf", "parity-scale-codec", "quip-crypto-primitives-core", + "rand_core 0.6.4", "scale-info", "sha2 0.10.9", "sp-application-crypto 45.0.0", diff --git a/quip/primitives/crypto/Cargo.toml b/quip/primitives/crypto/Cargo.toml index 5ca2d3f5cd1a..df6415579c6a 100644 --- a/quip/primitives/crypto/Cargo.toml +++ b/quip/primitives/crypto/Cargo.toml @@ -39,3 +39,4 @@ full_crypto = ["sp-core/full_crypto"] [dev-dependencies] sp-consensus-babe = { workspace = true, default-features = false, features = ["std"] } +rand_core = { version = "0.6", default-features = false } # must match the version fips204 expects. diff --git a/quip/primitives/crypto/src/substrate/sr25519_mldsa44.rs b/quip/primitives/crypto/src/substrate/sr25519_mldsa44.rs index 08c09865ea0f..cc72ef496b29 100644 --- a/quip/primitives/crypto/src/substrate/sr25519_mldsa44.rs +++ b/quip/primitives/crypto/src/substrate/sr25519_mldsa44.rs @@ -610,6 +610,108 @@ mod tests { .is_none()); } + /// Stands in for a block producer's own RNG. Deterministic so the test is + /// reproducible. + struct CountingRng(u64); + + impl rand_core::RngCore for CountingRng { + fn next_u32(&mut self) -> u32 { + self.next_u64() as u32 + } + + fn next_u64(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + for chunk in dest.chunks_mut(8) { + let bytes = self.next_u64().to_le_bytes(); + let len = chunk.len(); + chunk.copy_from_slice(&bytes[..len]); + } + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.fill_bytes(dest); + Ok(()) + } + } + + impl rand_core::CryptoRng for CountingRng {} + + /// Two valid ML-DSA-44 bindings over one VRF input must yield the same + /// consensus-decisive bytes. + /// + /// ML-DSA-44 is not unique per (key, message). The signer picks the signing + /// randomness and the verifier cannot recover it, so a block producer can + /// mint unlimited valid bindings for a fixed input. While the consensus + /// derivation hashes the binding in, each one yields a different BABE + /// authoring score, which makes primary-slot leader election and epoch + /// randomness grindable. + /// + /// `hybrid_vrf_roundtrip_works` and any repeated-call determinism check + /// cannot catch this. They exercise only the honest signer, which calls + /// `sign_deterministic`, so they pass on the grindable construction too. + /// + /// IGNORED ON PURPOSE: this fails against the current derivation, which is + /// the defect itself, not a broken test. QUI-984 makes it pass by deriving + /// `make_bytes` from the unique sr25519 pre-output alone and keeping the + /// ML-DSA binding out of every consensus-decisive value. Remove `#[ignore]` + /// as part of that change. The test must then pass unmodified. Tracked by + /// QUI-925, specified in Section 15 of the Hybrid Post-Quantum Signature + /// Constructions document. + #[test] + #[ignore = "QUI-925: expected to fail until QUI-984 removes the PQ binding from the consensus-decisive derivation"] + fn hybrid_vrf_output_is_independent_of_the_pq_binding() { + let seed = [29u8; MASTER_SEED_LEN]; + let pair = Pair::from_seed(&seed); + let public = pair.public(); + let randomness = [11u8; babe::RANDOMNESS_LENGTH]; + let sign_data = babe::make_vrf_sign_data(&randomness, 5, 13); + + let honest = VrfSecret::vrf_sign(&pair, &sign_data); + + // Re-sign the same binding message with randomness of our choosing. + // This is exactly what a block producer controls. Nothing in the + // protocol forces them onto the deterministic path. + let secret = pair.expanded_secret(); + let message = binding_message(sign_data.input(), &honest.sr25519.pre_output); + let mut rng = CountingRng(0x5EED); + let alternative = VrfSignature { + sr25519: honest.sr25519.clone(), + pq_signature: pq_mldsa44::sign(pq_secret_bytes(&secret), &message, &mut rng), + }; + + assert_ne!( + honest.pq_signature.as_slice(), + alternative.pq_signature.as_slice(), + "the two bindings must differ, otherwise this test proves nothing", + ); + assert!( + VrfPublic::vrf_verify(&public, &sign_data, &alternative), + "the verifier accepts any valid ML-DSA binding, including a chosen one", + ); + + let honest_bytes = + make_bytes::<32>(&public, babe::RANDOMNESS_VRF_CONTEXT, &sign_data, &honest).unwrap(); + let alternative_bytes = make_bytes::<32>( + &public, + babe::RANDOMNESS_VRF_CONTEXT, + &sign_data, + &alternative, + ) + .unwrap(); + + assert_eq!( + honest_bytes, alternative_bytes, + "consensus-decisive bytes must not depend on which valid PQ binding the signer publishes", + ); + } + #[test] fn hybrid_vrf_output_matches_signed_proof_output() { let seed = [19u8; MASTER_SEED_LEN];