diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 610a52e..5c875d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,6 +60,12 @@ jobs: run: | cargo build --workspace --all-features --verbose + - name: Fuzz + env: + SIMPLEX_TEST_VERBOSE: ${{ runner.debug == '1' && '-v' || '' }} + run: | + simplex test --fuzz --test-threads 4 "${SIMPLEX_TEST_VERBOSE}" -- --release + - name: Test env: SIMPLEX_TEST_VERBOSE: ${{ runner.debug == '1' && '-v' || '' }} diff --git a/.gitignore b/.gitignore index ddafd0a..ab9046a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ # Simplex output src/artifacts +/fuzz-failures # IDE/editors (optional but common minimal) **/.DS_Store diff --git a/Simplex.toml b/Simplex.toml index 555b27b..78db45e 100644 --- a/Simplex.toml +++ b/Simplex.toml @@ -25,8 +25,8 @@ # url = "" # network = "" -# [test.fuzz] -# cases = 10 +[test.fuzz] +cases = 100 # seed = 12345 # network = "" diff --git a/tests/stdlib/common/fuzz.rs b/tests/stdlib/common/fuzz.rs new file mode 100644 index 0000000..799464a --- /dev/null +++ b/tests/stdlib/common/fuzz.rs @@ -0,0 +1,89 @@ +use std::fmt::Debug; + +use simplex::program::ProgramSchema; +use simplex::simplicityhl::Arguments; + +use simplex::fuzz::proptest::strategy::Strategy; +use simplex::fuzz::{FuzzProgramTrait, FuzzProgramValues, Scenario}; +use simplex::{FuzzMode, TestContext}; + +pub use simplex::fuzz::Expect; + +const DEFAULT_SPENT_INPUT: usize = 0; + +/// One call into a dispatch test contract (`function_index` + operands + expected values). +/// +/// Implemented once per test module for its `Case` builder, so fuzz tests only +/// describe how generated inputs become a case. +pub trait DispatchFuzzCase: 'static { + type Program: FuzzProgramTrait; + + /// Contract name used in fuzz scenarios and diagnostics. + const PROGRAM_NAME: &'static str; + + fn arguments() -> ::Arguments; + + fn into_witness(self) -> ::Witness; +} + +/// Fuzz `inputs` through `build`: every generated case must be accepted. +pub fn accepts( + context: &TestContext, + inputs: impl Strategy + 'static, + build: impl Fn(T) -> C + 'static, +) where + C: DispatchFuzzCase, + T: Debug + 'static, +{ + check(context, inputs, build, Expect::Accept); +} + +/// Fuzz `inputs` through `build`: every generated case must be rejected. +pub fn rejects( + context: &TestContext, + inputs: impl Strategy + 'static, + build: impl Fn(T) -> C + 'static, +) where + C: DispatchFuzzCase, + T: Debug + 'static, +{ + check(context, inputs, build, Expect::Reject); +} + +/// Fuzz `inputs` through `build` and expect `expect` for every generated case. +pub fn check( + context: &TestContext, + inputs: impl Strategy + 'static, + build: impl Fn(T) -> C + 'static, + expect: Expect, +) where + C: DispatchFuzzCase, + T: Debug + 'static, +{ + let arguments: Arguments = C::arguments().into(); + let values = inputs.prop_map(move |input| { + FuzzProgramValues::from((arguments.clone(), build(input).into_witness().into())) + }); + + let mut scenario = Scenario::default(); + scenario + .add::(C::PROGRAM_NAME) + .spends(DEFAULT_SPENT_INPUT) + .custom_strategy(values); + + run_scenario(context, scenario, expect); +} + +/// Run a hand-built scenario, for tests that need more than one program or custom bindings. +pub fn run_scenario(context: &TestContext, scenario: Scenario, expect: Expect) { + let engine = context.engine(scenario).spec(move |_| expect); + + // Negative scenarios deliberately generate no accepting witness. + let engine = if expect == Expect::Reject { + engine.allow_unspendable() + } else { + engine + }; + + engine.run(); +} diff --git a/tests/stdlib/common/mod.rs b/tests/stdlib/common/mod.rs index 16f0294..a25bd30 100644 --- a/tests/stdlib/common/mod.rs +++ b/tests/stdlib/common/mod.rs @@ -1,4 +1,5 @@ pub mod core; +pub mod fuzz; pub mod helper; pub mod u256_wrapper; pub mod uint; diff --git a/tests/stdlib/secp256k1/core.rs b/tests/stdlib/secp256k1/core.rs new file mode 100644 index 0000000..dc10f6e --- /dev/null +++ b/tests/stdlib/secp256k1/core.rs @@ -0,0 +1,379 @@ +use num_bigint::BigUint; +use rand::{RngCore, rngs::OsRng}; +use secp256k1_zkp::{PublicKey, SecretKey, rand::rngs::OsRng as SecpOsRng}; + +use crate::common::core::{Expect, run}; +use crate::common::fuzz::DispatchFuzzCase; + +use simplicityhl_std::artifacts::tests::secp256k1::operations::OperationsProgram as Secp256k1OperationsTestProgram; +use simplicityhl_std::artifacts::tests::secp256k1::operations::derived_operations::{ + OperationsArguments as Secp256k1OperationsTestArguments, + OperationsWitness as Secp256k1OperationsTestWitness, +}; + +pub const ZERO: U256 = [0; 32]; +pub const ONE: U256 = u256(1); + +/// A big-endian 256-bit value. +pub type U256 = [u8; 32]; +/// A **f**ield **e**lement, arithmetic modulo **p**. +pub type Fe = U256; +/// A multiplier for curve points, arithmetic modulo the group order **n**. +pub type Scalar = U256; +/// A **G**roup **E**lement, represented as an affine curve point (x, y). +pub type Ge = (Fe, Fe); +/// A **G**roup **E**lement, represented using **J**acobian coordinates ((X, Y), Z). +pub type Gej = (Ge, Fe); +/// A compressed curve point (y_parity, x). +pub type Point = (u8, Fe); + +pub enum FunctionToTest { + GeToPoint, + PointToGej, + FeSub, + ScalarSub, + GejSub, + FeEq, + ScalarEq, + GeEq, + GejPointEq, + SafeGejNormalize, +} + +// FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +pub const SECP_P: U256 = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFC, 0x2F, +]; + +// FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +pub const SECP_N: U256 = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, + 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41, +]; + +pub const fn u256(value: u128) -> U256 { + let mut out = [0; 32]; + let bytes = value.to_be_bytes(); + let mut i = 0; + + while i < 16 { + out[16 + i] = bytes[i]; + i += 1; + } + + out +} + +pub fn big(value: U256) -> BigUint { + BigUint::from_bytes_be(&value) +} + +/// # Panics +/// If `value` does not fit in 256 bits. +pub fn to_u256(value: &BigUint) -> U256 { + let bytes = value.to_bytes_be(); + assert!(bytes.len() <= 32, "{value:#x} does not fit in 256 bits"); + + let mut out = [0; 32]; + out[32 - bytes.len()..].copy_from_slice(&bytes); + out +} + +/// `a + b` as plain integers. +/// +/// # Panics +/// On overflow past 256 bits. +pub fn add_u256(a: U256, b: U256) -> U256 { + to_u256(&(big(a) + big(b))) +} + +/// `a - b (mod modulus)`, canonical. Operands may be non-canonical. +pub fn sub_mod(a: U256, b: U256, modulus: U256) -> U256 { + let m = big(modulus); + + let a = big(a) % &m + &m; + let b = big(b) % &m; + + to_u256(&((a - b) % &m)) +} + +pub fn fe_mul(a: Fe, b: Fe) -> Fe { + to_u256(&(big(a) * big(b) % big(SECP_P))) +} + +pub fn pk_to_ge(pk: &PublicKey) -> Ge { + let serialized = pk.serialize_uncompressed(); // 0x04 || x(32) || y(32) + + ( + serialized[1..33].try_into().unwrap(), + serialized[33..65].try_into().unwrap(), + ) +} + +pub fn ge_to_gej(ge: Ge) -> Gej { + (ge, ONE) +} + +pub fn ge_negate((x, y): Ge) -> Ge { + (x, sub_mod(ZERO, y, SECP_P)) +} + +pub fn compress((x, y): Ge) -> Point { + ( + y[31] & 1, // 0 (even y) or 1 (odd y) + x, + ) +} + +/// `(λ²X, λ³Y, λ)`: the same point as `(X, Y, 1)` for any non-zero λ. +pub fn rescale((x, y): Ge, lambda: Fe) -> Gej { + let lambda_squared = fe_mul(lambda, lambda); + let lambda_cubed = fe_mul(lambda_squared, lambda); + + ((fe_mul(x, lambda_squared), fe_mul(y, lambda_cubed)), lambda) +} + +fn random_mod(modulus: U256) -> U256 { + let mut bytes = [0; 32]; + OsRng.fill_bytes(&mut bytes); + + to_u256(&(big(bytes) % big(modulus))) +} + +pub fn random_fe() -> Fe { + random_mod(SECP_P) +} + +pub fn random_scalar() -> Scalar { + random_mod(SECP_N) +} + +pub fn random_public_key() -> PublicKey { + let secret_key = SecretKey::new(&mut SecpOsRng); + + PublicKey::from_secret_key(secp256k1_zkp::SECP256K1, &secret_key) +} + +pub fn random_ge() -> Ge { + pk_to_ge(&random_public_key()) +} + +/// One dispatch arm of the contract, plus the witness it reads. +#[derive(Clone, Debug)] +pub struct Case { + witness: Secp256k1OperationsTestWitness, +} + +pub fn case(function: FunctionToTest) -> Case { + Case { + witness: Secp256k1OperationsTestWitness { + function_index: function as u8, + first_uint: U256::default(), + second_uint: U256::default(), + first_ge: Ge::default(), + second_ge: Ge::default(), + first_gej: Gej::default(), + second_gej: Gej::default(), + first_point: Point::default(), + expected_uint: U256::default(), + expected_ge: Ge::default(), + expected_gej: Gej::default(), + expected_point: Point::default(), + }, + } +} + +impl Case { + /// The two field or scalar operands, `first_uint` and `second_uint`. + pub fn uints(mut self, first_uint: U256, second_uint: U256) -> Self { + self.witness.first_uint = first_uint; + self.witness.second_uint = second_uint; + self + } + + /// Only `first_ge`. + pub fn ge(mut self, first_ge: Ge) -> Self { + self.witness.first_ge = first_ge; + self + } + + /// The two affine points, `first_ge` and `second_ge`. + pub fn ges(mut self, first_ge: Ge, second_ge: Ge) -> Self { + self.witness.first_ge = first_ge; + self.witness.second_ge = second_ge; + self + } + + /// Only `first_gej`. + pub fn gej(mut self, first_gej: Gej) -> Self { + self.witness.first_gej = first_gej; + self + } + + /// The two Jacobian points, `first_gej` and `second_gej`. + pub fn gejs(mut self, first_gej: Gej, second_gej: Gej) -> Self { + self.witness.first_gej = first_gej; + self.witness.second_gej = second_gej; + self + } + + /// `first_point`: a compressed point, parity byte and x coordinate. + pub fn point(mut self, first_point: Point) -> Self { + self.witness.first_point = first_point; + self + } + + /// `expected_uint`: the field or scalar the arm should produce. + pub fn expect_uint(mut self, expected_uint: U256) -> Self { + self.witness.expected_uint = expected_uint; + self + } + + /// `expected_ge`: the affine point the arm should produce. + pub fn expect_ge(mut self, expected_ge: Ge) -> Self { + self.witness.expected_ge = expected_ge; + self + } + + /// `expected_gej`: the Jacobian point the arm should produce. + pub fn expect_gej(mut self, expected_gej: Gej) -> Self { + self.witness.expected_gej = expected_gej; + self + } + + /// `expected_point`: the compressed point the arm should produce. + pub fn expect_point(mut self, expected_point: Point) -> Self { + self.witness.expected_point = expected_point; + self + } + + /// Fund, spend, and expect the spend to succeed. + pub fn run(self, context: &simplex::TestContext) -> anyhow::Result<()> { + self.expecting(context, Expect::Ok) + } + + /// Fund, spend, and expect `expect`. + pub fn expecting(self, context: &simplex::TestContext, expect: Expect) -> anyhow::Result<()> { + let program = Secp256k1OperationsTestProgram::new(Case::arguments()); + + run(context, program, self.witness, expect) + } +} + +impl DispatchFuzzCase for Case { + type Program = Secp256k1OperationsTestProgram; + + const PROGRAM_NAME: &'static str = "secp256k1::operations"; + + fn arguments() -> Secp256k1OperationsTestArguments { + Secp256k1OperationsTestArguments {} + } + + fn into_witness(self) -> Secp256k1OperationsTestWitness { + self.witness + } +} + +pub mod cases { + use super::*; + + use FunctionToTest::*; + + /// `ge_to_point` keeps x and takes the parity of y. + pub fn ge_to_point(ge: Ge) -> Case { + case(GeToPoint).ge(ge).expect_point(compress(ge)) + } + + /// `point_to_gej` decompresses to the same point. + pub fn point_to_gej(ge: Ge) -> Case { + let point = compress(ge); + + case(PointToGej).point(point).expect_point(point) + } + + /// `fe_sub` matches the reference, for any (also non-canonical) operands. + pub fn fe_sub(a: Fe, b: Fe) -> Case { + case(FeSub).uints(a, b).expect_uint(sub_mod(a, b, SECP_P)) + } + + /// `scalar_sub` matches the reference, for any (also non-canonical) operands. + pub fn scalar_sub(a: Scalar, b: Scalar) -> Case { + case(ScalarSub) + .uints(a, b) + .expect_uint(sub_mod(a, b, SECP_N)) + } + + /// `gej_sub` matches the reference, the point at infinity included (`p == q`). + pub fn gej_sub(p: PublicKey, q: PublicKey) -> Case { + let expected = if p == q { + // The expected witness for infinity uses all-zero coordinates. + Gej::default() + } else { + let difference = p + .combine(&q.negate(secp256k1_zkp::SECP256K1)) + .expect("p - q non-infinity"); + + ge_to_gej(pk_to_ge(&difference)) + }; + + case(GejSub) + .gejs(ge_to_gej(pk_to_ge(&p)), ge_to_gej(pk_to_ge(&q))) + .expect_gej(expected) + } + + /// `fe_eq(a, b)`, accepted iff `a ≡ b (mod p)`. + pub fn fe_eq(a: Fe, b: Fe) -> Case { + case(FeEq).uints(a, b) + } + + /// `scalar_eq(a, b)`, accepted iff `a ≡ b (mod n)`. + pub fn scalar_eq(a: Scalar, b: Scalar) -> Case { + case(ScalarEq).uints(a, b) + } + + /// `ge_eq(P, -P)`: must be rejected, y differs. + pub fn ge_eq_negation(ge: Ge) -> Case { + case(GeEq).ges(ge, ge_negate(ge)) + } + + /// `gej_point_eq` holds for any Jacobian representation `(λ²X, λ³Y, λ)` of P. + pub fn gej_point_eq_rescaled(ge: Ge, lambda: Fe) -> Case { + case(GejPointEq) + .gej(rescale(ge, lambda)) + .point(compress(ge)) + } + + /// `gej_point_eq(P, compress(-P))` must be false. + /// Flipping the parity bit keeps `x` and selects `(x, -y)`, the negation of `P`. + /// The contract asserts equality, so this witness must be rejected. + pub fn gej_point_eq_negation(ge: Ge) -> Case { + let (parity, x) = compress(ge); + + case(GejPointEq).gej(ge_to_gej(ge)).point((parity ^ 1, x)) + } + + /// `gej_point_eq(P, (0, x))` with an off-curve `x`: must be rejected. + pub fn gej_point_eq_off_curve_point(ge: Ge, x: Fe) -> Case { + case(GejPointEq).gej(ge_to_gej(ge)).point((0, x)) + } + + /// Compare an off-curve `(x, y, 1)` against a compressed point with the same x and parity. + /// + /// The caller supplies an x from a curve point and a y off the curve at that x. + /// Matching x and parity isolates the on-curve check. + pub fn gej_point_eq_off_curve_gej(x: Fe, y: Fe) -> Case { + let off_curve = (x, y); + + case(GejPointEq) + .gej(ge_to_gej(off_curve)) + .point(compress(off_curve)) + } + + /// `safe_gej_normalize` maps any Jacobian representation of P back to P. + pub fn safe_gej_normalize(ge: Ge, lambda: Fe) -> Case { + case(SafeGejNormalize) + .gej(rescale(ge, lambda)) + .expect_ge(ge) + } +} diff --git a/tests/stdlib/secp256k1/mod.rs b/tests/stdlib/secp256k1/mod.rs index 9045df1..418f35e 100644 --- a/tests/stdlib/secp256k1/mod.rs +++ b/tests/stdlib/secp256k1/mod.rs @@ -1 +1,3 @@ -mod operations; +pub(crate) mod core; +pub(crate) mod operations; +mod operations_fuzz; diff --git a/tests/stdlib/secp256k1/operations.rs b/tests/stdlib/secp256k1/operations.rs index 3bc6798..24cd709 100644 --- a/tests/stdlib/secp256k1/operations.rs +++ b/tests/stdlib/secp256k1/operations.rs @@ -1,380 +1,93 @@ -use num_bigint::BigUint; -use num_traits::Zero; -use rand::{RngCore, rngs::OsRng}; -use secp256k1_zkp::{PublicKey, Secp256k1, SecretKey, rand::rngs::OsRng as SecpOsRng}; +use crate::common::core::Expect; -use crate::common::core::{Expect, run}; - -use simplicityhl_std::artifacts::tests::secp256k1::operations::OperationsProgram as Secp256k1OperationsTestProgram; -use simplicityhl_std::artifacts::tests::secp256k1::operations::derived_operations::{ - OperationsArguments as Secp256k1OperationsTestArguments, - OperationsWitness as Secp256k1OperationsTestWitness, -}; - -use FunctionToTest::*; - -enum FunctionToTest { - GeToPoint, - PointToGej, - FeSub, - ScalarSub, - GejSub, - FeEq, - ScalarEq, - GeEq, - GejPointEq, - SafeGejNormalize, -} - -// FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F -const SECP_P: [u8; 32] = [ - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFC, 0x2F, -]; - -// FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 -const SECP_N: [u8; 32] = [ - 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, - 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41, -]; - -fn program() -> Secp256k1OperationsTestProgram { - Secp256k1OperationsTestProgram::new(&Secp256k1OperationsTestArguments {}) -} - -/// One dispatch arm of the contract, plus the witness it reads. -struct Case { - witness: Secp256k1OperationsTestWitness, -} - -fn case(function: FunctionToTest) -> Case { - Case { - witness: Secp256k1OperationsTestWitness { - function_index: function as u8, - first_uint: [0; 32], - second_uint: [0; 32], - first_ge: ([0; 32], [0; 32]), - second_ge: ([0; 32], [0; 32]), - first_gej: (([0; 32], [0; 32]), [0; 32]), - second_gej: (([0; 32], [0; 32]), [0; 32]), - first_point: (0, [0; 32]), - expected_uint: [0; 32], - expected_ge: ([0; 32], [0; 32]), - expected_gej: (([0; 32], [0; 32]), [0; 32]), - expected_point: (0, [0; 32]), - }, - } -} - -impl Case { - /// The two field or scalar operands, `first_uint` and `second_uint`. - fn uints(mut self, first_uint: [u8; 32], second_uint: [u8; 32]) -> Self { - self.witness.first_uint = first_uint; - self.witness.second_uint = second_uint; - self - } - - /// Only `first_ge`. - fn ge(mut self, first_ge: ([u8; 32], [u8; 32])) -> Self { - self.witness.first_ge = first_ge; - self - } - - /// The two affine points, `first_ge` and `second_ge`. - fn ges(mut self, first_ge: ([u8; 32], [u8; 32]), second_ge: ([u8; 32], [u8; 32])) -> Self { - self.witness.first_ge = first_ge; - self.witness.second_ge = second_ge; - self - } - - /// Only `first_gej`. - fn gej(mut self, first_gej: (([u8; 32], [u8; 32]), [u8; 32])) -> Self { - self.witness.first_gej = first_gej; - self - } - - /// The two Jacobian points, `first_gej` and `second_gej`. - fn gejs( - mut self, - first_gej: (([u8; 32], [u8; 32]), [u8; 32]), - second_gej: (([u8; 32], [u8; 32]), [u8; 32]), - ) -> Self { - self.witness.first_gej = first_gej; - self.witness.second_gej = second_gej; - self - } - - /// `first_point`: a compressed point, parity byte and x coordinate. - fn point(mut self, first_point: (u8, [u8; 32])) -> Self { - self.witness.first_point = first_point; - self - } - - /// `expected_uint`: the field or scalar the arm should produce. - fn expect_uint(mut self, expected_uint: [u8; 32]) -> Self { - self.witness.expected_uint = expected_uint; - self - } - - /// `expected_ge`: the affine point the arm should produce. - fn expect_ge(mut self, expected_ge: ([u8; 32], [u8; 32])) -> Self { - self.witness.expected_ge = expected_ge; - self - } - - /// `expected_gej`: the Jacobian point the arm should produce. - fn expect_gej(mut self, expected_gej: (([u8; 32], [u8; 32]), [u8; 32])) -> Self { - self.witness.expected_gej = expected_gej; - self - } - - /// `expected_point`: the compressed point the arm should produce. - fn expect_point(mut self, expected_point: (u8, [u8; 32])) -> Self { - self.witness.expected_point = expected_point; - self - } - - /// Fund, spend, and expect the spend to succeed. - fn run(self, context: &simplex::TestContext) -> anyhow::Result<()> { - self.expecting(context, Expect::Ok) - } - - /// Fund, spend, and expect `expect`. - fn expecting(self, context: &simplex::TestContext, expect: Expect) -> anyhow::Result<()> { - run(context, program(), self.witness, expect) - } -} - -fn secp_p() -> BigUint { - BigUint::from_bytes_be(&SECP_P) -} -fn secp_n() -> BigUint { - BigUint::from_bytes_be(&SECP_N) -} - -fn to_32_be(x: &BigUint) -> [u8; 32] { - let b = x.to_bytes_be(); - - let mut out = [0u8; 32]; - out[32 - b.len()..].copy_from_slice(&b); - - out -} - -fn mod_p(x: &BigUint) -> BigUint { - x % secp_p() -} -fn mod_n(x: &BigUint) -> BigUint { - x % secp_n() -} - -fn fe_sub_ref(a: [u8; 32], b: [u8; 32]) -> [u8; 32] { - let p = secp_p(); - let a = BigUint::from_bytes_be(&a) % &p; - let b = BigUint::from_bytes_be(&b) % &p; - - to_32_be(&((a + &p - b) % &p)) -} - -fn scalar_sub_ref(a: [u8; 32], b: [u8; 32]) -> [u8; 32] { - let n = secp_n(); - let a = BigUint::from_bytes_be(&a) % &n; - let b = BigUint::from_bytes_be(&b) % &n; - - to_32_be(&((a + &n - b) % &n)) -} - -fn fe_negate_ref(a: [u8; 32]) -> [u8; 32] { - let p = secp_p(); - let a = BigUint::from_bytes_be(&a) % &p; - - if a.is_zero() { - [0u8; 32] - } else { - to_32_be(&(&p - &a)) - } -} - -fn fe_mul_ref(a: [u8; 32], b: [u8; 32]) -> [u8; 32] { - let p = secp_p(); - let a = BigUint::from_bytes_be(&a); - let b = BigUint::from_bytes_be(&b); - - to_32_be(&((a * b) % &p)) -} - -// Randomness helpers -fn random_uint_bytes() -> [u8; 32] { - let mut b = [0u8; 32]; - OsRng.fill_bytes(&mut b); - - b -} - -fn random_fe_bytes() -> [u8; 32] { - to_32_be(&mod_p(&BigUint::from_bytes_be(&random_uint_bytes()))) -} - -fn random_scalar_bytes() -> [u8; 32] { - to_32_be(&mod_n(&BigUint::from_bytes_be(&random_uint_bytes()))) -} - -fn random_ge_bytes() -> ([u8; 32], [u8; 32]) { - let secp = Secp256k1::new(); - let sk = SecretKey::new(&mut SecpOsRng); - let pk = PublicKey::from_secret_key(&secp, &sk); - let ser = pk.serialize_uncompressed(); // 0x04 || x(32) || y(32) - - let mut x = [0u8; 32]; - x.copy_from_slice(&ser[1..33]); - - let mut y = [0u8; 32]; - y.copy_from_slice(&ser[33..65]); - - (x, y) -} - -fn ge_to_gej(ge: ([u8; 32], [u8; 32])) -> (([u8; 32], [u8; 32]), [u8; 32]) { - let mut one = [0u8; 32]; - one[31] = 1; - - (ge, one) -} - -fn compress(ge: ([u8; 32], [u8; 32])) -> (u8, [u8; 32]) { - (ge.1[31] & 1, ge.0) -} - -fn pk_to_gej(pk: &PublicKey) -> (([u8; 32], [u8; 32]), [u8; 32]) { - let ser = pk.serialize_uncompressed(); - - let mut x = [0u8; 32]; - x.copy_from_slice(&ser[1..33]); - - let mut y = [0u8; 32]; - y.copy_from_slice(&ser[33..65]); - - ge_to_gej((x, y)) -} +use super::core::*; // 0. ge_to_point #[simplex::test] fn ge_to_point_matches_parity(context: simplex::TestContext) -> anyhow::Result<()> { // Sample one on-curve point; whatever parity it has, that's what we expect // ge_to_point to produce. Covers both parities across repeated runs. - let ge = random_ge_bytes(); - let expected_parity = ge.1[31] & 1; // 0 (even y) or 1 (odd y) - case(GeToPoint) - .ge(ge) - .expect_point((expected_parity, ge.0)) - .run(&context) + cases::ge_to_point(random_ge()).run(&context) } // 1. point_to_gej #[simplex::test] fn point_to_gej_roundtrip(context: simplex::TestContext) -> anyhow::Result<()> { - let ge = random_ge_bytes(); - let point = compress(ge); - - case(PointToGej) - .point(point) - .expect_point(point) - .run(&context) + cases::point_to_gej(random_ge()).run(&context) } // 2. fe_sub #[simplex::test] fn fe_sub_self_is_zero(context: simplex::TestContext) -> anyhow::Result<()> { - let a = random_fe_bytes(); + let a = random_fe(); - case(FeSub).uints(a, a).expect_uint([0u8; 32]).run(&context) + cases::fe_sub(a, a).expect_uint(ZERO).run(&context) } #[simplex::test] fn fe_sub_matches_reference(context: simplex::TestContext) -> anyhow::Result<()> { - let a = random_fe_bytes(); - let b = random_fe_bytes(); - let exp = fe_sub_ref(a, b); - - case(FeSub).uints(a, b).expect_uint(exp).run(&context) + cases::fe_sub(random_fe(), random_fe()).run(&context) } // 3. scalar_sub #[simplex::test] fn scalar_sub_matches_reference(context: simplex::TestContext) -> anyhow::Result<()> { - let a = random_scalar_bytes(); - let b = random_scalar_bytes(); - let exp = scalar_sub_ref(a, b); - - case(ScalarSub).uints(a, b).expect_uint(exp).run(&context) + cases::scalar_sub(random_scalar(), random_scalar()).run(&context) } // 4. gej_sub #[simplex::test] fn gej_sub_matches_reference(context: simplex::TestContext) -> anyhow::Result<()> { - let secp = Secp256k1::new(); - let sk_p = SecretKey::new(&mut SecpOsRng); - let sk_q = SecretKey::new(&mut SecpOsRng); + let p = random_public_key(); + let mut q = random_public_key(); - let p = PublicKey::from_secret_key(&secp, &sk_p); - let q = PublicKey::from_secret_key(&secp, &sk_q); - let diff = p.combine(&q.negate(&secp)).expect("p - q non-infinity"); + while q == p { + q = random_public_key(); + } - case(GejSub) - .gejs(pk_to_gej(&p), pk_to_gej(&q)) - .expect_gej(pk_to_gej(&diff)) - .run(&context) + cases::gej_sub(p, q).run(&context) } // 5. fe_eq #[simplex::test] fn fe_eq_reflexive(context: simplex::TestContext) -> anyhow::Result<()> { - let a = random_fe_bytes(); + let a = random_fe(); - case(FeEq).uints(a, a).run(&context) + cases::fe_eq(a, a).run(&context) } #[simplex::test] fn fe_eq_zero_and_p_are_equal(context: simplex::TestContext) -> anyhow::Result<()> { - case(FeEq).uints([0u8; 32], SECP_P).run(&context) + cases::fe_eq(ZERO, SECP_P).run(&context) } // 6. scalar_eq #[simplex::test] fn scalar_eq_s_and_s_plus_n_are_equal(context: simplex::TestContext) -> anyhow::Result<()> { // s = 5. s + n has no carry past the low byte. - let mut s = [0u8; 32]; - s[31] = 5; - - let mut s_plus_n = SECP_N; - s_plus_n[31] = s_plus_n[31].wrapping_add(5); + let s = u256(5); - case(ScalarEq).uints(s, s_plus_n).run(&context) + cases::scalar_eq(s, add_u256(s, SECP_N)).run(&context) } // 7. ge_eq #[simplex::test] fn ge_eq_rejects_negation(context: simplex::TestContext) -> anyhow::Result<()> { - let ge = random_ge_bytes(); - let neg_y = fe_negate_ref(ge.1); - - case(GeEq) - .ges(ge, (ge.0, neg_y)) - .expecting(&context, Expect::AssertFailed) + cases::ge_eq_negation(random_ge()).expecting(&context, Expect::AssertFailed) } // 8. gej_point_eq #[simplex::test] fn gej_point_eq_rescaled(context: simplex::TestContext) -> anyhow::Result<()> { // (λ²X, λ³Y, λZ) is the same affine point as (X, Y, 1). - let ge = random_ge_bytes(); - let lambda = random_fe_bytes(); - let l2 = fe_mul_ref(lambda, lambda); - let l3 = fe_mul_ref(l2, lambda); - let g = ((fe_mul_ref(ge.0, l2), fe_mul_ref(ge.1, l3)), lambda); + let mut lambda = random_fe(); + + while lambda == ZERO { + lambda = random_fe(); + } - case(GejPointEq).gej(g).point(compress(ge)).run(&context) + cases::gej_point_eq_rescaled(random_ge(), lambda).run(&context) } #[simplex::test] @@ -390,45 +103,31 @@ fn gej_point_eq_rejects_negation(context: simplex::TestContext) -> anyhow::Resul // decompress((parity ^ 1, x)) inside gej_point_eq recovers (x, -y) = -P. // Since P ≠ -P on secp256k1, the equivalence must return false. // expected_bool = false locks that in. - let ge = random_ge_bytes(); - let (parity, x) = compress(ge); - case(GejPointEq) - .gej(ge_to_gej(ge)) - .point((parity ^ 1, x)) - .expecting(&context, Expect::AssertFailed) + cases::gej_point_eq_negation(random_ge()).expecting(&context, Expect::AssertFailed) } #[simplex::test] fn gej_point_eq_off_curve_is_false(context: simplex::TestContext) -> anyhow::Result<()> { // x = 0 is off-curve. - case(GejPointEq) - .gej(ge_to_gej(random_ge_bytes())) - .point((0, [0u8; 32])) - .expecting(&context, Expect::AssertFailed) + cases::gej_point_eq_off_curve_point(random_ge(), ZERO).expecting(&context, Expect::AssertFailed) } #[simplex::test] fn gej_point_eq_rejects_off_curve_gej(context: simplex::TestContext) -> anyhow::Result<()> { - // (x, 1, 1) shares x with P but is off-curve. The equivalence jet only - // rejects y = -P.y, so without an on-curve check this would pass. - let ge = random_ge_bytes(); - let mut one = [0u8; 32]; - one[31] = 1; + // (x, 1, 1) shares x with P but is off-curve. + // The compressed point shares x and odd parity, isolating the on-curve check. + let neg_one = sub_mod(ZERO, ONE, SECP_P); + let mut ge = random_ge(); + while ge.1 == ONE || ge.1 == neg_one { + ge = random_ge(); + } - case(GejPointEq) - .gej(((ge.0, one), one)) - .point(compress(ge)) - .expecting(&context, Expect::AssertFailed) + cases::gej_point_eq_off_curve_gej(ge.0, ONE).expecting(&context, Expect::AssertFailed) } // 9. safe_gej_normalize #[simplex::test] fn safe_gej_normalize_roundtrip(context: simplex::TestContext) -> anyhow::Result<()> { - let ge = random_ge_bytes(); - - case(SafeGejNormalize) - .gej(ge_to_gej(ge)) - .expect_ge(ge) - .run(&context) + cases::safe_gej_normalize(random_ge(), ONE).run(&context) } diff --git a/tests/stdlib/secp256k1/operations_fuzz.rs b/tests/stdlib/secp256k1/operations_fuzz.rs new file mode 100644 index 0000000..4996745 --- /dev/null +++ b/tests/stdlib/secp256k1/operations_fuzz.rs @@ -0,0 +1,215 @@ +use secp256k1_zkp::{PublicKey, SecretKey}; + +use simplex::fuzz::proptest::prelude::{Just, any}; +use simplex::fuzz::proptest::prop_oneof; +use simplex::fuzz::proptest::sample::select; +use simplex::fuzz::proptest::strategy::Strategy; +use simplex::{FuzzMode, TestContext}; + +use crate::common::fuzz::{accepts, rejects}; + +use super::core::{ + Fe, Ge, ONE, SECP_N, SECP_P, U256, ZERO, add_u256, big, cases, pk_to_ge, sub_mod, to_u256, u256, +}; + +/// Mix uniform 256-bit values with exact boundaries for `modulus`. +/// +/// Simplex's guided generator supports whole-word `u256` boundaries. This custom +/// strategy includes the secp256k1 modulus and its neighbours to exercise reduction +/// of non-canonical operands. +fn arb_u256_mod(modulus: U256) -> impl Strategy { + let edges = vec![ + ZERO, + ONE, + to_u256(&(big(modulus) - 1u32)), + modulus, + add_u256(modulus, ONE), + [u8::MAX; 32], + ]; + + prop_oneof![select(edges), any::()] +} + +/// A canonical field element, `< SECP_P`. +fn arb_fe() -> impl Strategy { + arb_u256_mod(SECP_P).prop_filter("canonical field element", |value| *value < SECP_P) +} + +fn arb_non_zero_fe() -> impl Strategy { + arb_fe().prop_filter("non-zero field element", |value| *value != ZERO) +} + +fn arb_public_key() -> impl Strategy { + any::<[u8; 32]>() + .prop_filter_map("valid secret key", |bytes| { + SecretKey::from_slice(&bytes).ok() + }) + .prop_map(|secret_key| PublicKey::from_secret_key(secp256k1_zkp::SECP256K1, &secret_key)) +} + +fn arb_ge() -> impl Strategy { + arb_public_key().prop_map(|pk| pk_to_ge(&pk)) +} + +/// A canonical x with no curve point: `x³ + 7` is not a square mod p. +fn arb_off_curve_x() -> impl Strategy { + prop_oneof![Just(ZERO), arb_fe()].prop_filter("off-curve x", |x| { + let mut compressed = [0x02; 33]; + compressed[1..].copy_from_slice(x); + + // Filtering only off curve points. + PublicKey::from_slice(&compressed).is_err() + }) +} + +/// `x` and `x + modulus`: equal modulo `modulus`, the second one non-canonical. +fn arb_shifted_pair( + x: impl Strategy, + modulus: U256, +) -> impl Strategy { + x.prop_map(move |x| (x, add_u256(x, modulus))) +} + +/// Two values that differ modulo `modulus`. +fn arb_distinct_mod(modulus: U256) -> impl Strategy { + (arb_u256_mod(modulus), arb_u256_mod(modulus)).prop_filter("distinct modulo", move |(a, b)| { + sub_mod(*a, *b, modulus) != ZERO + }) +} + +// 0. ge_to_point +#[simplex::fuzz] +fn ge_to_point_matches_parity(context: TestContext) { + accepts(&context, arb_ge(), cases::ge_to_point); +} + +// 1. point_to_gej +#[simplex::fuzz] +fn point_to_gej_roundtrip(context: TestContext) { + accepts(&context, arb_ge(), cases::point_to_gej); +} + +// 2. fe_sub +#[simplex::fuzz] +fn fe_sub_self_is_zero(context: TestContext) { + accepts(&context, arb_u256_mod(SECP_P), |a| { + cases::fe_sub(a, a).expect_uint(ZERO) + }); +} + +#[simplex::fuzz] +fn fe_sub_matches_reference(context: TestContext) { + accepts( + &context, + (arb_u256_mod(SECP_P), arb_u256_mod(SECP_P)), + |(a, b)| cases::fe_sub(a, b), + ); +} + +// 3. scalar_sub +#[simplex::fuzz] +fn scalar_sub_matches_reference(context: TestContext) { + accepts( + &context, + (arb_u256_mod(SECP_N), arb_u256_mod(SECP_N)), + |(a, b)| cases::scalar_sub(a, b), + ); +} + +// 4. gej_sub +#[simplex::fuzz] +fn gej_sub_matches_reference(context: TestContext) { + let distinct_points = (arb_public_key(), arb_public_key()) + .prop_filter("distinct secp256k1 points", |(p, q)| p != q); + + accepts(&context, distinct_points, |(p, q)| cases::gej_sub(p, q)); +} + +#[simplex::fuzz] +fn gej_sub_self_is_infinity(context: TestContext) { + accepts(&context, arb_public_key(), |p| cases::gej_sub(p, p)); +} + +// 5. fe_eq +#[simplex::fuzz] +fn fe_eq_reflexive(context: TestContext) { + accepts(&context, arb_u256_mod(SECP_P), |a| cases::fe_eq(a, a)); +} + +// Generalized case of fe_eq_zero_and_p_are_equal. +#[simplex::fuzz] +fn fe_eq_x_and_x_plus_p_are_equal(context: TestContext) { + // x + p fits in 256 bits only for x < 2^256 - p = 0x1000003D1. + let x = (0_u128..0x1_0000_03D1).prop_map(u256); + + accepts(&context, arb_shifted_pair(x, SECP_P), |(a, b)| { + cases::fe_eq(a, b) + }); +} + +#[simplex::fuzz] +fn fe_eq_rejects_distinct(context: TestContext) { + rejects(&context, arb_distinct_mod(SECP_P), |(a, b)| { + cases::fe_eq(a, b) + }); +} + +// 6. scalar_eq +#[simplex::fuzz] +fn scalar_eq_s_and_s_plus_n_are_equal(context: TestContext) { + // s + n fits in 256 bits for any s < 2^128 (2^256 - n > 2^128). + let s = any::().prop_map(u256); + + accepts(&context, arb_shifted_pair(s, SECP_N), |(a, b)| { + cases::scalar_eq(a, b) + }); +} + +#[simplex::fuzz] +fn scalar_eq_rejects_distinct(context: TestContext) { + rejects(&context, arb_distinct_mod(SECP_N), |(a, b)| { + cases::scalar_eq(a, b) + }); +} + +// 7. ge_eq +#[simplex::fuzz] +fn ge_eq_rejects_negation(context: TestContext) { + rejects(&context, arb_ge(), cases::ge_eq_negation); +} + +// 8. gej_point_eq +#[simplex::fuzz] +fn gej_point_eq_rescaled(context: TestContext) { + accepts(&context, (arb_ge(), arb_non_zero_fe()), |(ge, lambda)| { + cases::gej_point_eq_rescaled(ge, lambda) + }); +} + +#[simplex::fuzz] +fn gej_point_eq_rejects_negation(context: TestContext) { + rejects(&context, arb_ge(), cases::gej_point_eq_negation); +} + +#[simplex::fuzz] +fn gej_point_eq_off_curve_is_false(context: TestContext) { + rejects(&context, (arb_ge(), arb_off_curve_x()), |(ge, x)| { + cases::gej_point_eq_off_curve_point(ge, x) + }); +} + +#[simplex::fuzz] +fn gej_point_eq_rejects_off_curve_gej(context: TestContext) { + // A point with y = 0 would have order two. The group has odd order. + rejects(&context, arb_ge(), |(x, _)| { + cases::gej_point_eq_off_curve_gej(x, ZERO) + }); +} + +// 9. safe_gej_normalize +#[simplex::fuzz] +fn safe_gej_normalize_roundtrip(context: TestContext) { + accepts(&context, (arb_ge(), arb_non_zero_fe()), |(ge, lambda)| { + cases::safe_gej_normalize(ge, lambda) + }); +}