# REPRO-2026-00225: Node.js WebCrypto EdDSA verification accepted small-order points, allowing signature verification bypass for Ed25519/Ed448 before the 29890721 fix. ## Summary Status: published Severity: high Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00225 ## Package Name: nodejs Ecosystem: other Affected: All Node.js versions before the fix commit; affects all supported release lines (22.x, 24.x, 26.x) Fixed: Unknown ## Root Cause # Root Cause Analysis: Node.js WebCrypto EdDSA Small-Order Point Signature Bypass ## Summary Node.js WebCrypto's EdDSA (Ed25519/Ed448) signature verification via `globalThis.crypto.subtle.verify` accepted signatures where the public key or the signature's R component is a small-order (low-order) point, before commit `29890721cda51eeb64f4079143d55b69333599c2`. This allowed an attacker to forge signatures that verify as valid without knowing the private key, by exploiting the mathematical properties of small-order points in the Ed25519/Ed448 groups. The most severe variant uses the identity/neutral element (order-1 point), which satisfies the cofactorless verification equation for ANY message with S=0, enabling universal signature forgery. ## Impact - **Package/component affected**: Node.js built-in WebCrypto API (`globalThis.crypto.subtle.verify` for Ed25519 and Ed448), implemented in `src/crypto/crypto_sig.cc` - **Affected versions**: Node.js builds prior to commit `29890721cda51eeb64f4079143d55b69333599c2` (dated 2026-06-20). The vulnerability existed in the v27.0.0-pre development branch and any release built from source before the fix was merged. - **Risk level**: High. Any application using `subtle.verify` for Ed25519/Ed448 signature verification could accept forged signatures, enabling authentication bypass, authorization bypass, and integrity violation. An attacker who controls the public key (e.g., by submitting it during key exchange or registration) can produce valid-looking signatures for arbitrary messages without the private key. - **Consequences**: Signature verification bypass — the core security property of EdDSA is defeated. Any system relying on WebCrypto Ed25519/Ed448 verification for authentication, authorization, or data integrity is vulnerable. ## Impact Parity - **Disclosed/claimed maximum impact**: Signature verification bypass (authz_bypass) — `subtle.verify` returns `true` for mathematically invalid signatures using small-order points. - **Reproduced impact from this run**: Full verification bypass confirmed. The vulnerable Node.js build returns `true` for: - The ticket's specific order-8 point forgery (Test A) - The identity point (order-1) forgery for two different arbitrary messages (Tests B1, B2) - The fixed build returns `false` for all forged signatures, and both builds correctly reject a garbage signature (negative control, Test C). - **Parity**: `full` - **Not demonstrated**: No code execution, privilege escalation, or memory corruption — this is a pure logic/verification bypass vulnerability, consistent with the claimed impact. ## Root Cause ### Technical Explanation Ed25519 signature verification checks the equation `S*B = R + k*A` where: - S is the scalar from the signature (second 32 bytes) - B is the base point (group generator) - R is the point from the signature (first 32 bytes) - A is the public key point - k = SHA-512(R || A || M) mod l (where l is the prime group order ~2^252) The Ed25519 group has cofactor 8, meaning the full group has order 8*l, with a small-order subgroup of order 8 (containing the identity, one order-2 point, two order-4 points, and four order-8 points). When the verification equation is checked without explicitly rejecting small-order points (cofactorless verification), certain special cases arise: 1. **Identity point (order-1)**: When A = identity and R = identity with S = 0: - S*B = 0*B = O (identity) - k*A = k * identity = identity = O (identity has order 1, so any scalar multiple is identity) - R + k*A = identity + identity = identity = O - Therefore S*B = O = R + k*A → **verification passes for ANY message** 2. **Order-8 point**: When A and R are order-8 points with S = 0, and the message is chosen so that k*A = -R: - S*B = O, R + k*A = R + (-R) = O - Therefore S*B = O = R + k*A → **verification passes** Before the fix, Node.js's `crypto_sig.cc` passed the public key and signature directly to OpenSSL's `EVP_DigestVerifyFinal()` for EdDSA verification. Depending on the OpenSSL variant, this cofactorless verification could accept these small-order point signatures. The Node.js code at line 757 (pre-fix) was simply: ```cpp if (context.verify(params.data, params.signature)) { static_cast(buf.get())[0] = 1; } ``` No check was performed on whether the public key or R component was a small-order point. ### Fix The fix (commit `29890721cda51eeb64f4079143d55b69333599c2`, PR #64026) adds a `HasSmallOrderEdDsaPoint()` function that checks both the signature's R component and the public key against hardcoded tables of all known small-order points for Ed25519 (14 points: 8 canonical + 6 non-canonical encodings) and Ed448 (4 points). The verification result is ANDed with this check: ```cpp if (context.verify(params.data, params.signature) && !HasSmallOrderEdDsaPoint(key, params.signature)) { static_cast(buf.get())[0] = 1; } ``` This ensures verification returns `false` whenever a small-order point is detected, regardless of OpenSSL's behavior. - **Fix commit**: `29890721cda51eeb64f4079143d55b69333599c2` - **PR**: https://github.com/nodejs/node/pull/64026 - **Issue**: https://github.com/nodejs/node/issues/54572 ## Reproduction Steps 1. **Script**: `bundle/repro/reproduction_steps.sh` 2. **What the script does**: - Locates the project cache (prepared with Node.js source and build infrastructure) - Uses two pre-built Node.js binaries from the same base commit (`387332fbf3`), differing ONLY in whether `src/crypto/crypto_sig.cc` contains the small-order point check from fix `29890721`: - `node-vuln-true`: crypto_sig.cc reverted to fix parent (`68f14c2ee6`) — VULNERABLE - `node-fixed-true`: crypto_sig.cc with fix applied — FIXED - Runs a JavaScript test (`ed25519_forgery_test.js`) with each binary that: - **Test A**: Imports a small-order (order-8) Ed25519 public key and verifies a crafted signature with S=0 and small-order R, using the ticket's specific data - **Test B**: Imports the identity point (order-1) as both public key and R, with S=0, and verifies two different arbitrary messages - **Test C**: Negative control — verifies a garbage signature with a random public key (must fail on both builds) - Compares results: vulnerable build must accept forged signatures (true), fixed build must reject them (false), negative control must fail on both - Writes runtime manifest and proof logs 3. **Expected evidence**: Vulnerable build returns `true` for Tests A and B; fixed build returns `false` for all; both return `false` for Test C (negative control). Script exits 0. ## Evidence - **Log files**: - `bundle/logs/vuln_test_output.log` — raw JSON output from vulnerable binary: `{"A_ticket":true,"B_identity_msg1":true,"B_identity_msg2":true,"C_negative_control":false}` - `bundle/logs/fixed_test_output.log` — raw JSON output from fixed binary: `{"A_ticket":false,"B_identity_msg1":false,"B_identity_msg2":false,"C_negative_control":false}` - **Runtime manifest**: `bundle/repro/runtime_manifest.json` - **Test script**: `bundle/repro/ed25519_forgery_test.js` - **Key excerpts**: - Vulnerable build: `A_ticket: true` (order-8 point forgery accepted), `B_identity_msg1: true` (identity point forgery accepted for message 1), `B_identity_msg2: true` (identity point forgery accepted for message 2) - Fixed build: All forged signatures rejected (`false`) - Negative control: Both builds correctly reject garbage signature (`C_negative_control: false`) - **Environment**: Node.js v27.0.0-pre, built from source with bundled OpenSSL, x86_64 Linux, GCC. Both binaries built from the same base commit with identical compiler/toolchain, differing only in `crypto_sig.cc` (the fix). ## Recommendations / Next Steps - **Upgrade guidance**: Update to a Node.js version that includes commit `29890721cda51eeb64f4079143d55b69333599c2`. Any application using WebCrypto Ed25519/Ed448 verification should ensure they are running a patched build. - **Suggested fix approach**: The fix already implemented (explicit small-order point rejection in `crypto_sig.cc`) is the correct approach. It provides defense-in-depth by checking at the Node.js layer regardless of OpenSSL behavior. - **Testing recommendations**: Add regression tests that verify all known small-order points for both Ed25519 and Ed448 are rejected during `subtle.verify`. The fix commit already adds these tests in `test/parallel/test-webcrypto-sign-verify-eddsa.js`. - **Additional hardening**: Consider also rejecting small-order points during key import (`subtle.importKey`), though the fix intentionally keeps import behavior unchanged. ## Additional Notes - **Idempotency**: The reproduction script was run twice consecutively, both runs producing identical results and exiting with code 0. - **A/B comparison**: Both binaries were built from the same base commit (`387332fbf3`) with identical compiler, toolchain, and build flags. The only difference is whether `src/crypto/crypto_sig.cc` contains the `HasSmallOrderEdDsaPoint()` function and its call-site check. This provides a clean negative control. - **Mathematical note on order-2/order-4 points**: Tests with order-2 and order-4 points using S=0 do NOT automatically pass verification because `R + k*A ≠ O` unless k is specifically crafted to make `k*A = -R`. The identity point (order-1) is the strongest forgery primitive because `k*identity = O` for any k, making the forgery work for ANY message without any precomputation. The order-8 point from the ticket works because the specific data was chosen so that `k*A = -R`. - **Scope**: This vulnerability affects the `library_api` surface (WebCrypto `subtle.verify`). The reproduction exercises the real Node.js binary executing the real `globalThis.crypto.subtle.verify` function with crafted inputs, not a mock or simulation. ## Reproduction Details Reproduced: 2026-07-04T19:53:02.829Z Duration: 465 seconds Tool calls: 91 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00225 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00225&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00225/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - Source: https://github.com/spaceraccoon/vulnerability-spoiler-alert/issues/295 ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 11359 bytes) - bundle/repro/rca_report.md (analysis, 9911 bytes) - bundle/artifact_promotion_manifest.json (other, 3192 bytes) - bundle/logs/vuln_test_output.log (log, 91 bytes) - bundle/logs/fixed_test_output.log (log, 94 bytes) - bundle/repro/validation_verdict.json (other, 1086 bytes) - bundle/repro/runtime_manifest.json (other, 727 bytes) - bundle/repro/ed25519_forgery_test.js (other, 2143 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00225 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00225/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00225 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev