diff --git a/crypto/ethrex-crypto/Cargo.lock b/crypto/ethrex-crypto/Cargo.lock index be8a46c1e..ec809fff9 100644 --- a/crypto/ethrex-crypto/Cargo.lock +++ b/crypto/ethrex-crypto/Cargo.lock @@ -540,12 +540,22 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lambda-vm-ethrex-crypto" version = "0.1.0" dependencies = [ "ethrex-crypto", "k256", + "keccak", "lambda-vm-syscalls", ] diff --git a/crypto/ethrex-crypto/Cargo.toml b/crypto/ethrex-crypto/Cargo.toml index d62dc8491..ea6c91074 100644 --- a/crypto/ethrex-crypto/Cargo.toml +++ b/crypto/ethrex-crypto/Cargo.toml @@ -12,7 +12,7 @@ license = "MIT OR Apache-2.0" # LambdaVM-side crypto accelerators for ethrex's EVM, injected into the guest as # a `Crypto` impl. Keeping the logic here (not in the ethrex repo) means crypto # changes don't require an ethrex PR — the guest just constructs and injects -# `LambdaVmEcsmCrypto`. See `crypto/ethrex-crypto` in the plan. +# `LambdaVmEcsmCrypto`. [dependencies] # Defines the `Crypto` trait, `CryptoError`, and `keccak::keccak_hash`. Same rev @@ -21,7 +21,7 @@ license = "MIT OR Apache-2.0" ethrex-crypto = { git = "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/lambdaclass/ethrex.git", rev = "156cb8d6a3974f411d71622eecd1b249ee37ff1c", package = "ethrex-crypto", default-features = false } # Pinned to the exact 0.13.4 ethrex uses so the guest resolves a single k256 # (a version split would make `FieldElement`/`Scalar` incompatible types). -# `expose-field` is required by the x-only reconstruction (Phase 1). +# `expose-field` is required by the x-only reconstruction. k256 = { version = "=0.13.4", default-features = false, features = ["arithmetic", "expose-field"] } # The ECSM / keccak ecalls only exist on the riscv64 guest target; on host the @@ -29,3 +29,7 @@ k256 = { version = "=0.13.4", default-features = false, features = ["arithmetic" # (which pulls riscv-only allocator crates that don't link on host) is gated out. [target.'cfg(target_arch = "riscv64")'.dependencies] lambda-vm-syscalls = { path = "../../syscalls" } + +[dev-dependencies] +# Trusted software Keccak-f[1600] used to cross-check keccak256_with_permute in tests. +keccak = "0.1" diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index be34e91b9..76d24f045 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -72,8 +72,8 @@ impl Crypto for LambdaVmEcsmCrypto { /// `recover_from_prehash`, which internally runs a *second* lincomb to /// re-verify the key — doubling the ECSM ecalls for no gain here. fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 32], CryptoError> { - let r_bytes = FieldBytes::from_slice(&sig[..32]); - let s_bytes = FieldBytes::from_slice(&sig[32..]); + let r_bytes = <&FieldBytes>::from(&sig[..32]); + let s_bytes = <&FieldBytes>::from(&sig[32..]); // Parse r and s as scalars, rejecting values >= the curve order. let r: Option = Scalar::from_repr(*r_bytes).into(); @@ -97,7 +97,7 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 32], }; let r_proj = ProjectivePoint::from(r_point); - let z = >::reduce_bytes(FieldBytes::from_slice(msg)); + let z = >::reduce_bytes(&FieldBytes::from(*msg)); let r_inv: Option = r.invert_vartime().into(); let Some(r_inv) = r_inv else { return Err(CryptoError::RecoveryFailed); @@ -124,8 +124,8 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 32], /// /// On riscv64 this reconstructs the full affine result from four x-only ECSM /// queries (see [`lincomb2_with_oracle`]); on other targets, and whenever a -/// degenerate-configuration guard trips, it returns `None` so the caller uses -/// the pure-Rust `ProjectivePoint::lincomb`. +/// guard trips (degenerate input or oracle inconsistency), it returns `None` +/// so the caller uses the pure-Rust `ProjectivePoint::lincomb`. #[cfg(target_arch = "riscv64")] fn ecsm_lincomb2( p1: &ProjectivePoint, @@ -146,11 +146,13 @@ fn ecsm_lincomb2( None } -/// x-only scalar-mul oracle backed by the ECSM precompile. `x` must be the -/// x-coordinate of a curve point and `k` in `(0, n)` — guaranteed by the guards -/// in [`lincomb2_with_oracle`]. Values cross the ABI as 32-byte little-endian; -/// `xg` and `k` are distinct stack arrays so the executor's -/// `|addr_xG − addr_k| ≥ 32` assumption holds by construction. +/// x-only scalar-mul oracle backed by the ECSM precompile: computes `x(k·P)` +/// for the curve point P whose x-coordinate is passed in. `x` must be the +/// x-coordinate of a curve point and `k` in `(0, N)` (N = curve order) — +/// guaranteed by the guards in [`lincomb2_with_oracle`]. Values cross the ABI +/// as 32-byte little-endian; `x_le` and `k_le` are distinct stack arrays so +/// the executor's `|addr_x_le − addr_k_le| ≥ 32` assumption holds by +/// construction. #[cfg(target_arch = "riscv64")] fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { let x_be = x.to_bytes(); @@ -232,14 +234,16 @@ where let xq = (lq.square() - xa - xb).normalize(); let yq = (lq * (xa - xq) - ya).normalize(); - // `point_from_xy` re-validates the result is on the curve (rejecting → - // software fallback), so no separate curve-equation assertion is needed. + // `point_from_xy` checks the result is on the curve as a cheap backstop: + // it rejects gross off-curve garbage and falls back to software, but + // correctness rests on the algebra above — an on-curve-but-wrong point + // would still pass this check. point_from_xy(&xq, &yq) } -/// Recovers `y(k·P)` for `P = (xp, yp)` from `xa = x(k·P)` and -/// `xc = x((k+1)·P)`, given `dx = xa − xp` and `inv_den = (2·yp·dx)⁻¹`. -/// `None` if the λ² consistency check fails (degenerate configuration). +/// Recovers `y(k·P)` from `xa = x(k·P)` and `xc = x((k+1)·P)`. +/// Returns `None` if `xc` is inconsistent with the computed `lambda` +/// (oracle misbehavior); degeneracy guards are in [`lincomb2_with_oracle`]. #[cfg(any(target_arch = "riscv64", test))] fn solve_y( xp: &FieldElement, @@ -259,8 +263,8 @@ fn solve_y( Some((*yp + lambda * dx).normalize()) } -/// `k ∈ {0, 1, n−1}`: cases where `k` or `k+1` is an invalid ecall scalar or -/// the chord algebra degenerates (`A = ±P`). +/// `k ∈ {0, 1, n−1}`: fast early-exit before oracle calls. +/// k=0: invalid ecall scalar. k=1: dx=0. k=n-1: k+1 wraps to 0 mod n. #[cfg(any(target_arch = "riscv64", test))] fn scalar_near_edge(k: &Scalar) -> bool { use k256::elliptic_curve::subtle::ConstantTimeEq; @@ -290,56 +294,53 @@ fn point_from_xy(x: &FieldElement, y: &FieldElement) -> Option // ── Keccak-256 over the keccak_permute precompile (riscv64 guest) ─────────── -/// Keccak-256 as a sponge over LambdaVM's `keccak_permute` syscall. +/// Keccak-256 sponge with an injected permutation function. /// /// Keccak-f[1600], rate 1088 bits (136 bytes), capacity 512 bits. /// Padding: `0x01 ... 0x80` (multi-rate, last bit set). The state is a /// 25-element u64 array; bytes are absorbed into the state via little-endian /// XOR (matching the standard Keccak byte-to-lane mapping). -#[cfg(target_arch = "riscv64")] -fn keccak256_via_lambdavm(input: &[u8]) -> [u8; 32] { +/// +/// Gated to `riscv64 | test` so the generic function is available to the host +/// unit tests without being dead code in the non-test host build. +#[cfg(any(target_arch = "riscv64", test))] +fn keccak256_with_permute(input: &[u8], mut permute: F) -> [u8; 32] { const RATE: usize = 136; let mut state = [0u64; 25]; let mut offset = 0; - while input.len().saturating_sub(offset) >= RATE { + while input.len() - offset >= RATE { absorb_block(&mut state, &input[offset..offset + RATE]); - lambda_vm_syscalls::syscalls::keccak_permute(&mut state); - offset = offset.saturating_add(RATE); + permute(&mut state); + offset += RATE; } // Final block with multi-rate padding. let mut last = [0u8; RATE]; - let remaining = input.len().saturating_sub(offset); - if let Some(tail) = last.get_mut(..remaining) { - if let Some(src) = input.get(offset..) { - tail.copy_from_slice(src); - } - } - if let Some(b) = last.get_mut(remaining) { - *b ^= 0x01; - } - if let Some(b) = last.get_mut(RATE - 1) { - *b ^= 0x80; - } + let remaining = input.len() - offset; + last[..remaining].copy_from_slice(&input[offset..]); + last[remaining] ^= 0x01; + last[RATE - 1] ^= 0x80; absorb_block(&mut state, &last); - lambda_vm_syscalls::syscalls::keccak_permute(&mut state); + permute(&mut state); // Squeeze the first 32 bytes (four lanes) as little-endian. let mut output = [0u8; 32]; for (i, lane) in state.iter().take(4).enumerate() { - let bytes = lane.to_le_bytes(); - let start = i.saturating_mul(8); - if let Some(dst) = output.get_mut(start..start.saturating_add(8)) { - dst.copy_from_slice(&bytes); - } + output[i * 8..i * 8 + 8].copy_from_slice(&lane.to_le_bytes()); } output } -/// XOR one rate-sized block of bytes into the state lanes (little-endian). +/// Keccak-256 via LambdaVM's `keccak_permute` syscall (riscv64 guest only). #[cfg(target_arch = "riscv64")] +fn keccak256_via_lambdavm(input: &[u8]) -> [u8; 32] { + keccak256_with_permute(input, |s| lambda_vm_syscalls::syscalls::keccak_permute(s)) +} + +/// XOR one rate-sized block of bytes into the state lanes (little-endian). +#[cfg(any(target_arch = "riscv64", test))] fn absorb_block(state: &mut [u64; 25], block: &[u8]) { for (lane, chunk) in state.iter_mut().zip(block.chunks_exact(8)) { let mut buf = [0u8; 8]; @@ -349,86 +350,4 @@ fn absorb_block(state: &mut [u64; 25], block: &[u8]) { } #[cfg(test)] -mod tests { - use super::*; - - /// secp256k1 curve constant `b = 7`. - fn curve_b() -> FieldElement { - let mut bytes = [0u8; 32]; - bytes[31] = 7; - FieldElement::from_bytes(&bytes.into()).unwrap() - } - - /// Software stand-in for the ECSM precompile: lift `x` to a curve point and - /// return `x(k·P)` (parity-invariant, like the real ecall). - fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option { - let xn = x.normalize(); - let y2 = (xn.square() * xn + curve_b()).normalize(); - let y = Option::::from(y2.sqrt())?; - let p = point_from_xy(&xn, &y.normalize())?; - let prod = (p * k).to_affine(); - Some(affine_xy(&prod)?.0) - } - - fn g_times(n: u64) -> ProjectivePoint { - ProjectivePoint::GENERATOR * Scalar::from(n) - } - - #[test] - fn matches_software_lincomb_on_fixed_inputs() { - let cases = [ - (g_times(3), 123_456_789u64, g_times(7), 987_654_321u64), - (g_times(11), 2u64.pow(20) + 5, g_times(2), 42u64), - (ProjectivePoint::GENERATOR, 7u64, g_times(5), 9u64), - ]; - for (p1, k1, p2, k2) in cases { - let (k1, k2) = (Scalar::from(k1), Scalar::from(k2)); - let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2); - let got = lincomb2_with_oracle(&p1, &k1, &p2, &k2, soft_oracle) - .expect("non-degenerate inputs must reconstruct"); - assert_eq!(got.to_affine(), expected.to_affine()); - } - } - - #[test] - fn matches_software_lincomb_on_recovery_shape() { - // u1·G + u2·R, generator first, like ECDSA recovery. - let g = ProjectivePoint::GENERATOR; - let r = g_times(0x1234); - let u1 = Scalar::from(0xdead_beefu64); - let u2 = Scalar::from(0x0bad_f00du64); - let expected = ProjectivePoint::lincomb(&g, &u1, &r, &u2); - let got = lincomb2_with_oracle(&g, &u1, &r, &u2, soft_oracle) - .expect("non-degenerate inputs must reconstruct"); - assert_eq!(got.to_affine(), expected.to_affine()); - } - - #[test] - fn edge_scalars_fall_back() { - let p1 = g_times(3); - let p2 = g_times(5); - let ok = Scalar::from(12345u64); - for bad in [Scalar::ZERO, Scalar::ONE, -Scalar::ONE] { - assert!(lincomb2_with_oracle(&p1, &bad, &p2, &ok, soft_oracle).is_none()); - assert!(lincomb2_with_oracle(&p1, &ok, &p2, &bad, soft_oracle).is_none()); - } - } - - #[test] - fn identity_points_fall_back() { - let p = g_times(3); - let k = Scalar::from(7u64); - let id = ProjectivePoint::IDENTITY; - assert!(lincomb2_with_oracle(&id, &k, &p, &k, soft_oracle).is_none()); - assert!(lincomb2_with_oracle(&p, &k, &id, &k, soft_oracle).is_none()); - } - - #[test] - fn cancelling_and_doubling_terms_fall_back() { - let p = g_times(3); - let k = Scalar::from(7u64); - // A = B (doubling chord) and A = −B (Q = O): both share x(A) = x(B). - assert!(lincomb2_with_oracle(&p, &k, &p, &k, soft_oracle).is_none()); - assert!(lincomb2_with_oracle(&p, &k, &(-p), &k, soft_oracle).is_none()); - } -} +mod tests; diff --git a/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs new file mode 100644 index 000000000..2cd69d95f --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/ecrecover_tests.rs @@ -0,0 +1,135 @@ +//! Known-answer tests for the full `ecsm_ecrecover` path (r/s parse, +//! decompress + parity, z-reduction, u1/u2, final keccak(X‖Y) address). +//! +//! On host, `ecsm_lincomb2` returns `None`, so these exercise the recovery +//! wiring through the pure-Rust `ProjectivePoint::lincomb` fallback. + +use crate::*; + +/// Build a valid ECDSA/secp256k1 signature from (d, kk, msg) using only the +/// k256 primitives already imported and return `(sig, recid, expected_addr)`. +/// +/// `expected_addr` = keccak(X‖Y) of the uncompressed public key, exactly as +/// `ecsm_ecrecover` computes it. +fn make_ecdsa_fixture(d: Scalar, kk: Scalar, msg: [u8; 32]) -> ([u8; 64], u8, [u8; 32]) { + assert!(!bool::from(d.is_zero()), "private key must be nonzero"); + assert!(!bool::from(kk.is_zero()), "nonce must be nonzero"); + + // Public key Q = d·G. + let q = (ProjectivePoint::GENERATOR * d).to_affine(); + let q_uncompressed = q.to_encoded_point(false); + let expected = keccak_hash(&q_uncompressed.as_bytes()[1..65]); + + // R = kk·G; r = reduce(Rx); assert r ≠ 0. + let r_point = (ProjectivePoint::GENERATOR * kk).to_affine(); + let (rx, ry) = affine_xy(&r_point).expect("R is not identity"); + let r = >::reduce_bytes(&rx.to_bytes()); + assert!(!bool::from(r.is_zero()), "r must be nonzero"); + // rx is in Fp; since n < p, rx >= n with probability ~2^{-128}. When that + // happens r = rx-n and the signature requires the high-x recovery bit + // (recid >= 2, meaning R.x = r+n) which ecsm_ecrecover does not handle. + // Assert no reduction occurred so the low-x path is valid. + assert_eq!( + r.to_bytes(), + rx.to_bytes(), + "rx >= n: this kk needs high-x recovery (recid >= 2) — pick a different nonce" + ); + + // recid parity: low bit of Ry (big-endian, byte 31). + let recid = ry.normalize().to_bytes()[31] & 1; + + // z = reduce(msg). + let z = >::reduce_bytes(&FieldBytes::from(msg)); + + // s = kk⁻¹ · (z + r·d). + let s = kk.invert_vartime().expect("kk is nonzero") * (z + r * d); + assert!(!bool::from(s.is_zero()), "s must be nonzero"); + + // sig = r (BE, 32 bytes) ‖ s (BE, 32 bytes). + let mut sig = [0u8; 64]; + sig[..32].copy_from_slice(&r.to_bytes()); + sig[32..].copy_from_slice(&s.to_bytes()); + + (sig, recid, expected) +} + +#[test] +fn ecrecover_known_answer_three_tuples() { + // Three distinct (d, kk, msg) tuples — deterministic, no RNG. + let tuples: &[(u64, u64, [u8; 32])] = &[ + ( + 0x0000_0000_0000_0001u64, + 0x0000_0000_dead_beefu64, + { + let mut m = [0u8; 32]; + m[31] = 0x42; + m + }, + ), + ( + 0x00c0_ffee_dead_beef_u64, + 0x0123_4567_89ab_cdef_u64, + { + let mut m = [0u8; 32]; + m[0] = 0xff; + m[31] = 0x01; + m + }, + ), + ( + 0x0bad_f00d_1337_cafe, + 0xfeed_face_0000_0001, + { + let mut m = [0u8; 32]; + for (i, b) in m.iter_mut().enumerate() { + *b = i as u8; + } + m + }, + ), + ]; + + for &(d_u64, kk_u64, msg) in tuples { + let d = Scalar::from(d_u64); + let kk = Scalar::from(kk_u64); + let (sig, recid, expected) = make_ecdsa_fixture(d, kk, msg); + match ecsm_ecrecover(&sig, recid, &msg) { + Ok(got) => assert_eq!( + got, expected, + "ecrecover returned wrong address for d={d_u64:#x} kk={kk_u64:#x}" + ), + Err(e) => panic!("ecrecover failed for d={d_u64:#x} kk={kk_u64:#x}: {e:?}"), + } + } +} + +#[test] +fn ecrecover_rejects_zero_s() { + // sig = valid r ‖ 0x00..00 (s = 0) must return InvalidSignature. + let mut sig = [0u8; 64]; + // r = 1 (nonzero, but s = 0 in the second half). + sig[31] = 0x01; + let msg = [0u8; 32]; + assert!( + matches!( + ecsm_ecrecover(&sig, 0, &msg), + Err(CryptoError::InvalidSignature) + ), + "expected InvalidSignature for zero s" + ); +} + +#[test] +fn ecrecover_rejects_zero_r() { + // sig = 0x00..00 ‖ valid s must return InvalidSignature. + let mut sig = [0u8; 64]; + sig[63] = 0x01; // s = 1, r = 0 + let msg = [0u8; 32]; + assert!( + matches!( + ecsm_ecrecover(&sig, 0, &msg), + Err(CryptoError::InvalidSignature) + ), + "expected InvalidSignature for zero r" + ); +} diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs new file mode 100644 index 000000000..ace1dc63a --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -0,0 +1,177 @@ +//! Tests for the x-only ECSM linear-combination reconstruction +//! (`lincomb2_with_oracle`) against the software `ProjectivePoint::lincomb`, +//! plus the degenerate-configuration fallback guards. + +use crate::*; + +/// secp256k1 curve constant `b = 7`. +fn curve_b() -> FieldElement { + let mut bytes = [0u8; 32]; + bytes[31] = 7; + FieldElement::from_bytes(&bytes.into()).unwrap() +} + +/// Software stand-in for the ECSM precompile: lift `x` to a curve point and +/// return `x(k·P)` (parity-invariant, like the real ecall). +fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option { + let xn = x.normalize(); + let y2 = (xn.square() * xn + curve_b()).normalize(); + let y = Option::::from(y2.sqrt())?; + let p = point_from_xy(&xn, &y.normalize())?; + let prod = (p * k).to_affine(); + Some(affine_xy(&prod)?.0) +} + +fn g_times(n: u64) -> ProjectivePoint { + ProjectivePoint::GENERATOR * Scalar::from(n) +} + +#[test] +fn matches_software_lincomb_on_fixed_inputs() { + let cases = [ + (g_times(3), 123_456_789u64, g_times(7), 987_654_321u64), + (g_times(11), 2u64.pow(20) + 5, g_times(2), 42u64), + (ProjectivePoint::GENERATOR, 7u64, g_times(5), 9u64), + ]; + for (p1, k1, p2, k2) in cases { + let (k1, k2) = (Scalar::from(k1), Scalar::from(k2)); + let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2); + let got = lincomb2_with_oracle(&p1, &k1, &p2, &k2, soft_oracle) + .expect("non-degenerate inputs must reconstruct"); + assert_eq!(got.to_affine(), expected.to_affine()); + } +} + +#[test] +fn matches_software_lincomb_on_recovery_shape() { + // u1·G + u2·R, generator first, like ECDSA recovery. + let g = ProjectivePoint::GENERATOR; + let r = g_times(0x1234); + let u1 = Scalar::from(0xdead_beefu64); + let u2 = Scalar::from(0x0bad_f00du64); + let expected = ProjectivePoint::lincomb(&g, &u1, &r, &u2); + let got = lincomb2_with_oracle(&g, &u1, &r, &u2, soft_oracle) + .expect("non-degenerate inputs must reconstruct"); + assert_eq!(got.to_affine(), expected.to_affine()); +} + +#[test] +fn edge_scalars_fall_back() { + let p1 = g_times(3); + let p2 = g_times(5); + let ok = Scalar::from(12345u64); + for bad in [Scalar::ZERO, Scalar::ONE, -Scalar::ONE] { + assert!(lincomb2_with_oracle(&p1, &bad, &p2, &ok, soft_oracle).is_none()); + assert!(lincomb2_with_oracle(&p1, &ok, &p2, &bad, soft_oracle).is_none()); + } +} + +#[test] +fn identity_points_fall_back() { + let p = g_times(3); + let k = Scalar::from(7u64); + let id = ProjectivePoint::IDENTITY; + assert!(lincomb2_with_oracle(&id, &k, &p, &k, soft_oracle).is_none()); + assert!(lincomb2_with_oracle(&p, &k, &id, &k, soft_oracle).is_none()); +} + +#[test] +fn cancelling_and_doubling_terms_fall_back() { + let p = g_times(3); + let k = Scalar::from(7u64); + // A = B (doubling chord) and A = −B (Q = O): both share x(A) = x(B). + assert!(lincomb2_with_oracle(&p, &k, &p, &k, soft_oracle).is_none()); + assert!(lincomb2_with_oracle(&p, &k, &(-p), &k, soft_oracle).is_none()); +} + +#[test] +fn k_half_n_minus_1_reconstructs_correctly() { + // k = (n-1)/2 satisfies k·P = -(k+1)·P for any P, so the oracle returns + // the same x-coordinate for both the k and k+1 calls (xa = xc). The + // solve_y algebra still holds: lambda² = 2·xa + xp = t, so the check + // passes and the correct ya is recovered. + let two_inv = Scalar::from(2u64) + .invert_vartime() + .expect("2 is invertible mod n"); + let k_half = -Scalar::ONE * two_inv; // (n-1)/2 + + let p1 = g_times(5); + let p2 = g_times(11); + let k2 = Scalar::from(99999u64); + + let expected = ProjectivePoint::lincomb(&p1, &k_half, &p2, &k2); + let got = lincomb2_with_oracle(&p1, &k_half, &p2, &k2, soft_oracle) + .expect("k=(n-1)/2 is not near-edge and must reconstruct correctly"); + assert_eq!(got.to_affine(), expected.to_affine()); +} + +#[test] +fn cross_point_cancellation_falls_back() { + // Construct k1, k2, P1 ≠ ±P2 such that k1·P1 = -(k2·P2), so + // k1·P1 + k2·P2 = O. The shared x-coordinate makes dxq = 0 → None. + // P1 = 3G, P2 = 7G: k1·3G = -k2·7G → k1 = -k2·7·3^{-1} mod n. + let p1 = g_times(3); + let p2 = g_times(7); + let k2 = Scalar::from(12345u64); + let three_inv = Scalar::from(3u64) + .invert_vartime() + .expect("3 is invertible mod n"); + let k1 = -(k2 * Scalar::from(7u64) * three_inv); + assert!( + lincomb2_with_oracle(&p1, &k1, &p2, &k2, soft_oracle).is_none(), + "cross-point cancellation (P1 ≠ ±P2, result = O) must fall back" + ); +} + +#[test] +fn solve_y_rejects_inconsistent_oracle_xc() { + // Directly test that solve_y's lambda² == t check fires when xc is wrong. + // This is the oracle-misbehavior guard: it cannot easily be reached via + // lincomb2_with_oracle because the oracle is Fn (no mutable state to + // return xa correct and xc wrong in separate calls). + let (xp, yp) = affine_xy(&g_times(3).to_affine()).unwrap(); + let k = Scalar::from(12345u64); + + let xa = soft_oracle(&xp, &k).unwrap(); + let xc_correct = soft_oracle(&xp, &(k + Scalar::ONE)).unwrap(); + // xc from k+100 is inconsistent with xa from k — lambda²=t must reject it. + let xc_wrong = soft_oracle(&xp, &(k + Scalar::from(100u64))).unwrap(); + + let dx = (xa - xp).normalize(); + let inv_den = Option::::from((yp.double() * dx).invert()) + .expect("dx is nonzero for k=12345"); + + assert!( + solve_y(&xp, &yp, &xa, &xc_correct, &dx, &inv_den).is_some(), + "correct xc must pass the lambda² check" + ); + assert!( + solve_y(&xp, &yp, &xa, &xc_wrong, &dx, &inv_den).is_none(), + "inconsistent xc (oracle misbehavior) must be rejected by the lambda² check" + ); +} + +#[test] +fn odd_y_base_point_reconstructs_correctly() { + // Validates the solve_y sign-selection argument: when P1 has odd y the + // reconstruction must still match ProjectivePoint::lincomb. + let (p1, _k_gen) = (2u64..200) + .find_map(|n| { + let p = g_times(n); + let (_, y) = affine_xy(&p.to_affine())?; + if y.normalize().to_bytes()[31] & 1 == 1 { + Some((p, n)) + } else { + None + } + }) + .expect("at least one of the first 200 multiples of G has odd y"); + + let p2 = g_times(13); + let k1 = Scalar::from(54321u64); + let k2 = Scalar::from(11111u64); + let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2); + let got = lincomb2_with_oracle(&p1, &k1, &p2, &k2, soft_oracle) + .expect("odd-y base point is non-degenerate and must reconstruct correctly"); + assert_eq!(got.to_affine(), expected.to_affine()); +} diff --git a/crypto/ethrex-crypto/src/tests/keccak_tests.rs b/crypto/ethrex-crypto/src/tests/keccak_tests.rs new file mode 100644 index 000000000..cde649fcb --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/keccak_tests.rs @@ -0,0 +1,73 @@ +//! Host-side tests for the Keccak-256 sponge (`keccak256_with_permute`), +//! driving it with the trusted `keccak` crate's f1600 permutation and +//! cross-checking against ethrex's reference `keccak_hash`. + +use crate::*; + +/// Cross-check our sponge body against the trusted `keccak` crate's f1600. +fn check_keccak(input: &[u8]) { + let got = keccak256_with_permute(input, keccak::f1600); + let want = keccak_hash(input); + assert_eq!(got, want, "keccak256 mismatch for {}-byte input", input.len()); +} + +/// Cross-check our sponge against a hardcoded vector from the Ethereum spec. +fn check_keccak_kat(input: &[u8], expected_hex: &str) { + let expected: Vec = (0..expected_hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&expected_hex[i..i + 2], 16).unwrap()) + .collect(); + let got = keccak256_with_permute(input, keccak::f1600); + assert_eq!( + got.as_ref(), + expected.as_slice(), + "KAT mismatch for {}-byte input", + input.len() + ); +} + +#[test] +fn keccak_sponge_matches_trusted_permutation() { + // Empty input. + check_keccak(&[]); + // One byte. + check_keccak(&[0xab]); + // 135 bytes — RATE-1: padding lands on byte 135 (0x01) and byte 135 is + // also the last byte (0x80), so both bits land on the same byte: 0x81. + check_keccak(&[0x5a; 135]); + // Exactly RATE (136): fills one full block, final block is all-padding. + check_keccak(&[0x3c; 136]); + // RATE+1: one full block + one-byte remainder. + check_keccak(&[0x7e; 137]); + // Multi-block: ~1.5 × RATE (200 bytes), deterministic pattern. + let long: Vec = (0u8..200).collect(); + check_keccak(&long); + // 2 × RATE (272 bytes): two full absorb blocks + all-padding final block. + check_keccak(&[0xaa; 272]); + // 2 × RATE - 1 (271 bytes): two full absorbs + one-byte remainder. + check_keccak(&[0xbb; 271]); +} + +#[test] +fn keccak_sponge_known_answer_vectors() { + // Vectors from the Ethereum Yellow Paper / EIP-155. These use Keccak-256 + // (0x01 padding), NOT SHA3-256 (0x06 padding). Any sponge framing bug + // (wrong rate, wrong padding byte, wrong lane endianness) breaks these + // even if the differential test above passes. + + // keccak256("") = c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 + check_keccak_kat( + b"", + "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + ); + // keccak256("abc") + check_keccak_kat( + b"abc", + "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45", + ); + // keccak256("The quick brown fox jumps over the lazy dog") + check_keccak_kat( + b"The quick brown fox jumps over the lazy dog", + "4d741b6f1eb29cb2a9b9911c82f56fa8d73b04959d3d9d222895df6c0b28aa15", + ); +} diff --git a/crypto/ethrex-crypto/src/tests/mod.rs b/crypto/ethrex-crypto/src/tests/mod.rs new file mode 100644 index 000000000..f050a8e48 --- /dev/null +++ b/crypto/ethrex-crypto/src/tests/mod.rs @@ -0,0 +1,6 @@ +#[cfg(test)] +pub mod ecrecover_tests; +#[cfg(test)] +pub mod ecsm_tests; +#[cfg(test)] +pub mod keccak_tests;