Skip to content

fix(l2): add EIP-2 low-S signature validation to ZisK's recover_signer#6469

Open
avilagaston9 wants to merge 3 commits intomainfrom
fix/zisk-eip2-low-s-validation
Open

fix(l2): add EIP-2 low-S signature validation to ZisK's recover_signer#6469
avilagaston9 wants to merge 3 commits intomainfrom
fix/zisk-eip2-low-s-validation

Conversation

@avilagaston9
Copy link
Copy Markdown
Contributor

Motivation

In #6414, ZisK's crypto was rewritten to use native FFI accelerators. A review concern identified that recover_signer (used for transaction signature verification and EIP-7702 authority recovery) passes signatures directly to the C FFI function without enforcing the EIP-2 low-S check. This means ZisK would accept high-S (malleable) transaction signatures that all other backends (SP1, RISC0, OpenVM) correctly reject.

ZisK's FFI secp256k1_ecdsa_recover validates that s is in [1, N) but does not enforce the stricter EIP-2 requirement of s <= N/2.

The ecrecover precompile is intentionally left without this check, matching Ethereum spec behavior.

Description

Add the same SECP256K1_N_HALF validation to ZisK's recover_signer that already exists in shared.rs:k256_recover_signer for the other backends. Signatures with s > secp256k1n/2 now return CryptoError::InvalidSignature before reaching the FFI call.

Also updates the stale ZiskCrypto docstring that still referenced the old k256/substrate-bn patched crate approach.

Closes #6432

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync. — N/A

ZisK's recover_signer was passing signatures directly to the FFI
function without checking that S is in the lower half of the curve
order. This means high-S (malleable) transaction signatures would be
accepted by ZisK but rejected by all other backends (SP1, RISC0,
OpenVM), which enforce the check via k256_recover_signer in shared.rs.

The ecrecover precompile (secp256k1_ecrecover) is intentionally left
without this check, matching Ethereum spec behavior where the precompile
accepts any valid signature regardless of S value.

Also updates the stale ZiskCrypto docstring that still referenced the
old k256/substrate-bn patched crate approach.
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Review for PR #6469

The changes correctly implement EIP-2 signature malleability protection by rejecting high-s values (s > secp256k1n/2) before FFI invocation. This is a critical consensus check for Ethereum transaction validity.

Code correctness:

  • Line 98-108: The constant SECP256K1_N_HALF is correctly calculated as 0x7fff...5d576e7357a4501ddfe92f46681b20a0 (half of secp256k1 order n).
  • Line 106: The range sig[32..64] correctly extracts the s-component from the 65-byte signature (r: 0-31, s: 32-63, v: 64).
  • Line 107: Lexicographic comparison of big-endian byte arrays correctly implements integer comparison for this specific check.

Minor suggestions:

  1. Readability: Consider using hex_literal or a doc comment to show the hex representation of the constant for easier verification:

    // 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
    const SECP256K1_N_HALF: [u8; 32] = [0x7f, ...];
  2. Lint suppression: The #[allow(clippy::indexing_slicing)] on line 105 is unnecessary since sig is &[u8; 65] (fixed size), making the slicing compile-time safe. However, keeping it is harmless if clippy complains about the pattern in generic contexts.

Security: The check prevents signature malleability attacks where s' = n - s produces a different valid signature. This is essential for transaction hash uniqueness and replay protection.

Verdict: Correct and safe. No blocking issues.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

Findings

No blocking findings.

The change in zisk.rs:95 looks correct: it brings ZiskCrypto::recover_signer in line with the shared/native providers by rejecting high-s signatures before address recovery, which is the expected EIP-2 behavior for transaction sender recovery.

Residual Risk

  • transaction.rs:3603 only tests high-s rejection through NativeCrypto. This PR fixes the ZisK backend specifically, but does not add a ZisK-targeted regression test, so an FFI-specific regression in zisk.rs:95 could still slip through.

I could not run the relevant test locally because the sandbox blocks rustup temp-file creation.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review


Review: Add EIP-2 low-S signature validation to ZisK's recover_signer

Summary

This is a targeted, correct fix for a security gap introduced in PR #6414. ZisK's recover_signer was the only backend that didn't enforce the EIP-2 low-S requirement, meaning it would accept malleable (high-S) transaction signatures that all other backends correctly reject. The fix matches the existing pattern in shared.rs:k256_recover_signer.


Correctness

Constant valueSECP256K1_N_HALF at zisk.rs:97-101 expands to 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0, which is the correct value for secp256k1_n / 2. It is byte-for-byte identical to the constant in shared.rs:48-52.

Comparison semanticssig[32..64] > SECP256K1_N_HALF[..] at zisk.rs:103 is a lexicographic byte slice comparison. Since both sides are 32 bytes in big-endian representation, this is equivalent to a 256-bit integer comparison. Correct.

Boundary conditions — Signatures with s == N/2 are correctly allowed (only strictly greater values are rejected). s == 0 passes the EIP-2 check but is caught downstream by the FFI's own [1, N) validation.

Ordering — The EIP-2 check runs before the unsafe FFI call, which is the right place.

ecrecover precompile — Correctly left untouched; k256_ecrecover normalizes S rather than rejecting it, matching the Ethereum spec.


Minor Observations

Constant duplicationSECP256K1_N_HALF is now defined identically as a function-local const in both shared.rs:48 and zisk.rs:97. Since ZisK uses a different code path and can't reuse k256_recover_signer, this duplication is unavoidable unless the constant is lifted to module level. Consider promoting it to a pub(crate) const in shared.rs (or a new constants submodule) and importing it in zisk.rs. This would be a one-line change to shared.rs and prevents future drift if the constant ever needs updating. Not a blocker.

#[allow(clippy::indexing_slicing)] — The annotation at zisk.rs:102 suppresses a false positive. Since sig is &[u8; 65], both sig[32..64] and sig[..64] at line 109 are statically in-bounds. The annotation is consistent with the pattern in shared.rs:53.

No test coverage — No unit test for a high-S signature rejection is added. This is understandable given ZisK requires a special build environment, but it's worth noting. If there is a way to mock or inject a test for the guard without the full ZisK toolchain (e.g., by extracting the validation logic to a testable free function), that would harden this going forward.

Docstring update — The updated docstring at zisk.rs:68-70 accurately reflects the post-#6414 FFI accelerator architecture. Good cleanup.


Verdict

The fix is correct, minimal, and consistent with how the other backends handle EIP-2. The only actionable suggestion is extracting the constant to avoid duplication — everything else is a nit. Approve with optional follow-up on constant deduplication.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@avilagaston9 avilagaston9 changed the title Add EIP-2 low-S signature validation to ZisK's recover_signer fix(l2): add EIP-2 low-S signature validation to ZisK's recover_signer Apr 13, 2026
@github-actions github-actions bot added the L2 Rollup client label Apr 13, 2026
@github-actions
Copy link
Copy Markdown

Lines of code report

Total lines added: 9
Total lines removed: 0
Total lines changed: 9

Detailed view
+------------------------------------------------+-------+------+
| File                                           | Lines | Diff |
+------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/crypto/zisk.rs | 390   | +9   |
+------------------------------------------------+-------+------+

@avilagaston9 avilagaston9 marked this pull request as ready for review April 13, 2026 19:20
@avilagaston9 avilagaston9 requested a review from a team as a code owner April 13, 2026 19:20
Copilot AI review requested due to automatic review settings April 13, 2026 19:20
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: The PR correctly implements EIP-2 signature malleability protection and updates documentation. The implementation is sound with correct constant values and proper bounds handling.

Specific Feedback:

crates/guest-program/src/crypto/zisk.rs

  1. EIP-2 Compliance (Lines 96-106): The high-s signature check is correctly implemented. The constant SECP256K1_N_HALF matches the secp256k1 curve order divided by 2 (0x7F...5D576E7357A4501DDFE92F46681B20A0), and the byte-wise lexicographic comparison works correctly for big-endian integers of fixed equal length (32 bytes).

  2. Slice Safety (Line 102): The #[allow(clippy::indexing_slicing)] annotation is acceptable here since sig is typed as &[u8; 65], making the indices 32..64 compile-time safe. However, consider using let s = &sig[32..64]; with an explicit intermediate variable for readability.

  3. Timing: Performing this check before the expensive ziskos_secp256k1_recover FFI call is efficient—fail fast on invalid signatures without wasting cycles on native accelerator calls.

  4. Documentation (Lines 66-72): The updated doc comment accurately reflects the native FFI usage. Ensure that the underlying ZisK implementation doesn't duplicate this check (redundant but harmless), or if it does, document that this is defense-in-depth.

Minor Suggestion:
Consider adding a brief comment explaining why high-s is rejected (EIP-2 malleability protection) for future maintainers:

// EIP-2: reject high-s signatures to prevent signature malleability
if sig[32..64] > SECP256K1_N_HALF[..] {

Security: This change is consensus-critical. The implementation correctly rejects s > n/2, which matches Ethereum's consensus rules exactly. No vulnerabilities detected.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 13, 2026

Greptile Summary

This PR adds the EIP-2 low-S constraint check (s > secp256k1n/2 → InvalidSignature) to ZisK's recover_signer before the FFI call, closing an inconsistency where ZisK accepted high-S (malleable) signatures that SP1, RISC0, and OpenVM all reject. It also updates the now-stale ZiskCrypto doc comment. The fix is minimal, mirrors the existing check in shared.rs:k256_recover_signer exactly (same constant, same comparison), and correctly leaves the secp256k1_ecrecover precompile path unchanged to match Ethereum spec.

Confidence Score: 5/5

Safe to merge — the change is a minimal, correct security fix with no P0/P1 findings.

The constant value and comparison logic are identical to the already-validated check in shared.rs:k256_recover_signer. The only remaining finding is a P2 style suggestion about deduplicating the constant, which does not affect correctness.

No files require special attention.

Important Files Changed

Filename Overview
crates/guest-program/src/crypto/zisk.rs Adds the EIP-2 low-S check (s > SECP256K1_N_HALF → InvalidSignature) to recover_signer before the FFI call, and updates the stale doc comment — logic is correct and consistent with the other backends.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["recover_signer(sig, msg)"] --> B{"s = sig[32..64]\ns > SECP256K1_N_HALF?"}
    B -- "yes (high-S, EIP-2 violation)" --> C["return Err(InvalidSignature)"]
    B -- "no (low-S, EIP-2 compliant)" --> D["Extract sig_bytes[0..64] + recid = sig[64]"]
    D --> E["secp256k1_ecdsa_address_recover_c(FFI)"]
    E -- "ret == 0" --> F["Address::from_slice(output[12..])"] 
    E -- "ret != 0" --> G["return Err(RecoveryFailed)"]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/guest-program/src/crypto/zisk.rs
Line: 97-104

Comment:
**Duplicated `SECP256K1_N_HALF` constant**

`SECP256K1_N_HALF` is now defined identically in both `zisk.rs` and `shared.rs`. Since `shared.rs` already exports crypto helpers used by multiple backends, the constant could be promoted to a `pub(crate) const` there (or in a dedicated constants module) and imported here to keep a single source of truth.

```rust
// In shared.rs — promote to pub(crate)
pub(crate) const SECP256K1_N_HALF: [u8; 32] = [ … ];

// In zisk.rs — import instead of re-declaring
use super::shared::SECP256K1_N_HALF;
```

Not a blocking issue — both copies have the correct value — but a future value change (e.g. different curve) would need to be updated in two places.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Add EIP-2 low-S signature validation to ..." | Re-trigger Greptile

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

No blocking findings.

crates/guest-program/src/crypto/zisk.rs:95 now matches the existing recover_signer behavior in crates/common/crypto/provider.rs:155 and crates/guest-program/src/crypto/shared.rs:44: high-s signatures are rejected for transaction/EIP-7702 sender recovery, which is the correct EIP-2 behavior. It also leaves secp256k1_ecrecover unchanged, which is the right separation because the precompile is intentionally more permissive.

Residual gap: I only found the high-s regression test on the native path in crates/common/types/transaction.rs:3598. Not a blocker for this PR, but a ZisK-feature test covering the same case would help prevent backend drift.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

Comment on lines +97 to +104
const SECP256K1_N_HALF: [u8; 32] = [
0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0x5d, 0x57, 0x6e, 0x73, 0x57, 0xa4, 0x50, 0x1d, 0xdf, 0xe9, 0x2f, 0x46,
0x68, 0x1b, 0x20, 0xa0,
];
#[allow(clippy::indexing_slicing)]
if sig[32..64] > SECP256K1_N_HALF[..] {
return Err(CryptoError::InvalidSignature);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated SECP256K1_N_HALF constant

SECP256K1_N_HALF is now defined identically in both zisk.rs and shared.rs. Since shared.rs already exports crypto helpers used by multiple backends, the constant could be promoted to a pub(crate) const there (or in a dedicated constants module) and imported here to keep a single source of truth.

// In shared.rs — promote to pub(crate)
pub(crate) const SECP256K1_N_HALF: [u8; 32] = [];

// In zisk.rs — import instead of re-declaring
use super::shared::SECP256K1_N_HALF;

Not a blocking issue — both copies have the correct value — but a future value change (e.g. different curve) would need to be updated in two places.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/guest-program/src/crypto/zisk.rs
Line: 97-104

Comment:
**Duplicated `SECP256K1_N_HALF` constant**

`SECP256K1_N_HALF` is now defined identically in both `zisk.rs` and `shared.rs`. Since `shared.rs` already exports crypto helpers used by multiple backends, the constant could be promoted to a `pub(crate) const` there (or in a dedicated constants module) and imported here to keep a single source of truth.

```rust
// In shared.rs — promote to pub(crate)
pub(crate) const SECP256K1_N_HALF: [u8; 32] = [ … ];

// In zisk.rs — import instead of re-declaring
use super::shared::SECP256K1_N_HALF;
```

Not a blocking issue — both copies have the correct value — but a future value change (e.g. different curve) would need to be updated in two places.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review


PR Review: fix(l2): add EIP-2 low-S signature validation to ZisK's recover_signer

Summary: This is a targeted security fix that adds the missing EIP-2 high-S rejection guard to ZisK's FFI-backed recover_signer. The change is correct and consistent with the existing pattern in shared.rs.


Correctness

Constant valueSECP256K1_N_HALF (0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) is the exact floor of N/2 for the secp256k1 curve order. The value is byte-for-byte identical to the constant already established in shared.rs:48, so it's independently verified by the earlier review.

Byte slice comparison (zisk.rs:103) — sig[32..64] > SECP256K1_N_HALF[..] uses Rust's lexicographic slice comparison. For equal-length big-endian byte sequences this is equivalent to integer comparison, making this a correct and idiomatic guard.

Signature layoutsig[32..64] correctly targets the s component of the r || s || v 65-byte format. The subsequent extraction of sig[..64] and sig[64] (recid) below the guard is consistent.

Scope is correct — The guard is intentionally absent from secp256k1_ecrecover (the ECRECOVER precompile path). The EVM spec does not apply EIP-2 to the precompile, only to transaction signatures. That distinction is preserved.


Minor Observations

  • Duplicated constant (zisk.rs:97, shared.rs:48): SECP256K1_N_HALF is now a local const in two separate functions. It won't cause a bug, but a module-level const in shared.rs (or a dedicated crates/guest-program/src/crypto/constants.rs) would eliminate the duplication and make a future change single-source. Not a blocker.

  • No regression test added: A test that feeds a high-S signature through ZiskCrypto::recover_signer and asserts Err(CryptoError::InvalidSignature) would guard against this regressing in future ZisK backend rewrites. Worth filing a follow-up if guest-program testing is feasible in CI.


Docstring Update

The replacement of the stale k256/substrate-bn description with the FFI accelerator list accurately reflects the current implementation. No issues.


Verdict

The fix is correct and complete for its stated goal. The implementation matches the established pattern in shared.rs exactly. The duplication of the constant is the only thing worth a follow-up, but it does not affect correctness or security.


Automated review by Claude (Anthropic) · sonnet · custom prompt

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds EIP-2 low‑S signature enforcement to the ZisK guest crypto backend so it matches the other zkVM backends’ transaction/authority signature validation behavior, while keeping ecrecover behavior unchanged.

Changes:

  • Add an EIP-2 low‑S (s <= secp256k1n/2) check to ZiskCrypto::recover_signer before calling the ZisK FFI recover routine.
  • Update the ZiskCrypto docstring to reflect the current FFI-based implementation (instead of the old patched-crates approach).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 95 to +106
fn recover_signer(&self, sig: &[u8; 65], msg: &[u8; 32]) -> Result<Address, CryptoError> {
// EIP-2: reject high-s signatures (s > secp256k1n/2)
const SECP256K1_N_HALF: [u8; 32] = [
0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0x5d, 0x57, 0x6e, 0x73, 0x57, 0xa4, 0x50, 0x1d, 0xdf, 0xe9, 0x2f, 0x46,
0x68, 0x1b, 0x20, 0xa0,
];
#[allow(clippy::indexing_slicing)]
if sig[32..64] > SECP256K1_N_HALF[..] {
return Err(CryptoError::InvalidSignature);
}

Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crypto already provides a default recover_signer implementation (with the same EIP-2 low-S check) in crates/common/crypto/provider.rs. Keeping a custom implementation here duplicates the SECP256K1_N_HALF constant (now in at least 3 places) and increases the chance of future drift. Consider removing this recover_signer override and relying on the trait default (it will call this type’s secp256k1_ecrecover), or alternatively centralize the constant in one shared location.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L2 Rollup client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Verify that ZisK validates signatures correctly

4 participants