CWE-780: Use of RSA Algorithm without OAEP - JavaScript/Node.js
Overview
Using RSA encryption without OAEP (Optimal Asymmetric Encryption Padding) enables padding oracle attacks and message malleability. Node's padding default is not the one this CWE usually warns about: crypto.publicEncrypt() uses RSA_PKCS1_OAEP_PADDING when no padding is given, so the bare call already applies OAEP. What the bare call does not choose is the digest - oaepHash defaults to 'sha1'. Measured on Node 24.3, ciphertext from crypto.publicEncrypt(publicKey, buf) decrypts only with oaepHash: 'sha1' and fails with ERR_OSSL_RSA_OAEP_DECODING_ERROR under 'sha256'.
Two shapes therefore reach this CWE in Node's own crypto module: passing crypto.constants.RSA_PKCS1_PADDING explicitly, and leaving the OAEP digest to the SHA-1 default. Third-party libraries are a separate matter and the older warning does apply to them - on node-forge 1.4.0, publicKey.encrypt(data) with no scheme argument produces PKCS#1 v1.5.
Primary Defence: Always specify padding: crypto.constants.RSA_PKCS1_OAEP_PADDING with oaepHash set to 'sha256' or stronger when using crypto.publicEncrypt()/crypto.privateDecrypt(), or use the Web Crypto API's RSA-OAEP algorithm with an explicit hash in browser and modern Node.js environments. Name the hash on both the encrypt and the decrypt call rather than relying on the default, and use hybrid encryption (RSA-OAEP for a key + AES-GCM for data) for payloads larger than the RSA size limit.
Common Vulnerable Patterns
Explicit PKCS#1 v1.5 Padding with crypto.publicEncrypt()
// VULNERABLE - RSA_PKCS1_PADDING selects PKCS#1 v1.5
const crypto = require('node:crypto');
const encrypted = crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING // PKCS#1 v1.5
},
Buffer.from(message)
);
Why this is vulnerable: PKCS#1 v1.5 is deterministic in structure, so a decrypting peer that reveals whether the padding parsed - through an error message, a status code, or a measurable timing difference - gives an attacker the distinguisher Bleichenbacher's adaptive attack needs to recover plaintext without the private key.
The Node-specific detail is where the decrypting peer has to be. On Node 24.3, crypto.privateDecrypt() refuses this padding outright: ERR_INVALID_ARG_VALUE: RSA_PKCS1_PADDING is no longer supported for private decryption, with no revert flag available (--security-revert=CVE-2023-46809 reports Attempt to revert an unknown CVE). So a current Node process can produce this ciphertext but cannot read it back, and whatever does read it - an older Node, a Java or .NET service, an HSM front end - is the component carrying the oracle. Finding this call means asking what decrypts its output.
Relying on Node's Default OAEP Hash
// VULNERABLE - no oaepHash, so OAEP runs on SHA-1
const crypto = require('node:crypto');
const encrypted = crypto.publicEncrypt(publicKey, Buffer.from(message));
// Node 24.3: decrypts only under { oaepHash: 'sha1' }
Why this is vulnerable: the padding here is OAEP, which is why this call passes a review that only looks for RSA_PKCS1_PADDING. The digest is the part nobody chose: oaepHash defaults to 'sha1'. The 2017 collisions are not what makes this a finding: OAEP's proof treats the hash as a random oracle rather than resting on collision resistance, so OAEP-SHA-1 is not broken by them. What makes it a finding is that compliance regimes prohibit SHA-1 for new systems. Writing oaepHash: 'sha1' out loud is the same defect, just visible. Passing a bare key rather than an options object is the shape that hides it, and it also hides the mismatch: the peer that decrypts must also omit oaepHash, so an interoperating service that names SHA-256 gets ERR_OSSL_RSA_OAEP_DECODING_ERROR and no indication which side is wrong.
Using node-forge without OAEP
// VULNERABLE - node-forge with PKCS#1 v1.5 padding
const forge = require('node-forge');
const publicKey = forge.pki.publicKeyFromPem(publicKeyPem);
const encrypted = publicKey.encrypt(
message,
'RSAES-PKCS1-V1_5' // Insecure padding scheme
);
Why this is vulnerable: 'RSAES-PKCS1-V1_5' implements deprecated PKCS#1 v1.5 padding; the same Bleichenbacher-class attacks apply regardless of which library performs the encryption. Omitting the scheme argument does not help here the way it does in Node's crypto: on node-forge 1.4.0, publicKey.encrypt(message) produces PKCS#1 v1.5, verified by decrypting the output with each scheme in turn. Replace it with 'RSA-OAEP' and an explicit { md: forge.md.sha256.create(), mgf1: { md: forge.md.sha256.create() } }, or move the call to Node's built-in crypto.
WebCrypto Key Generated with a SHA-1 OAEP Hash
// VULNERABLE - the OAEP digest is fixed to SHA-1 by the key, not by the encrypt call
const keyPair = await subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 3072,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1' // Weak! Every encrypt with this key uses SHA-1
},
true,
['encrypt', 'decrypt']
);
const ciphertext = await subtle.encrypt({ name: 'RSA-OAEP' }, keyPair.publicKey, encoded);
Why this is vulnerable: in WebCrypto the OAEP digest is a property of the CryptoKey, not an argument to encrypt(). The sink reads as correct - { name: 'RSA-OAEP' } is exactly what the fix looks like - and the weak digest was chosen once, at generateKey() or importKey(), often in a different file from the encryption. Node 24.3 accepts hash: 'SHA-1' here without a warning, and the exported JWK records it as alg: 'RSA-OAEP' rather than the 'RSA-OAEP-256' a SHA-256 key exports, which is the cheapest way to tell which one a stored key is.
There is no PKCS#1 v1.5 encryption to find in WebCrypto, so a scanner rule looking for one is looking for something that does not exist. subtle.encrypt supports only RSA-OAEP for RSA keys; measured on Node 24.3, { name: 'RSA-PKCS1-v1_5' } throws NotSupportedError: Unrecognized algorithm name, and so does { name: 'RSASSA-PKCS1-v1_5' } - that one is a signature algorithm, and generateKey refuses to issue it encrypt usage at all (SyntaxError: Unsupported key usage for a RSA key). Code that appears to encrypt with either name does not run; code that signs with RSASSA-PKCS1-v1_5 is doing something this CWE does not cover.
Secure Patterns
Node.js crypto Module with RSA-OAEP (Preferred)
// SECURE - Node.js crypto with OAEP padding and an explicit SHA-256 digest
const assert = require('node:assert');
const crypto = require('node:crypto');
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 3072, // NIST rates 2048-bit at 112-bit strength and accepts it only through 2030; 3072-bit gives 128-bit and stays valid beyond
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
const message = 'Sensitive data to encrypt';
// One object, used on both sides - the encrypt and decrypt digests must match.
const oaep = {
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256' // Not the default, which is 'sha1'
};
const encrypted = crypto.publicEncrypt(
{ key: publicKey, ...oaep },
Buffer.from(message, 'utf8')
);
const decrypted = crypto.privateDecrypt(
{ key: privateKey, ...oaep },
encrypted
);
assert.strictEqual(decrypted.toString('utf8'), message);
Why this works: RSA_PKCS1_OAEP_PADDING makes RSA probabilistic, blocking ciphertext replay, and oaepHash: 'sha256' names a digest those regimes still allow. Naming the digest is the load-bearing half here, because the padding constant alone leaves it at SHA-1. Sharing one oaep object between the two calls is not tidiness: the digest is not carried in the ciphertext, so a decrypt call that disagrees with the encrypt call fails with ERR_OSSL_RSA_OAEP_DECODING_ERROR and nothing points at the cause. Node follows RFC 8017, so a peer that names SHA-256 for both the OAEP digest and MGF1 interoperates.
WebCrypto API with RSA-OAEP (Browser and Node.js)
// SECURE - WebCrypto API with RSA-OAEP
const assert = require('node:assert');
const { webcrypto } = require('node:crypto');
const { subtle } = webcrypto;
async function generateKeyPairOAEP() {
return await subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 3072,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: 'SHA-256' // Fixes the OAEP digest for the life of the key
},
true,
['encrypt', 'decrypt']
);
}
async function encryptWithOAEP(data, publicKey) {
const encoded = new TextEncoder().encode(data);
return await subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, encoded);
}
async function decryptWithOAEP(ciphertext, privateKey) {
const decrypted = await subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, ciphertext);
return new TextDecoder().decode(decrypted);
}
(async () => {
const keyPair = await generateKeyPairOAEP();
const ciphertext = await encryptWithOAEP('Sensitive data to encrypt', keyPair.publicKey);
assert.strictEqual(await decryptWithOAEP(ciphertext, keyPair.privateKey), 'Sensitive data to encrypt');
})();
Why this works: the digest travels with the CryptoKey, so once generateKey names SHA-256 there is no per-call parameter for encrypt and decrypt to disagree about, and an exported JWK carries it as alg: 'RSA-OAEP-256'. WebCrypto uses native, vetted crypto providers and avoids exposing raw key material to JavaScript. The same property is what makes the vulnerable version above hard to spot, so the review question for a WebCrypto codebase is not what subtle.encrypt was passed but what generateKey or importKey was passed - raw key material can be re-imported under any digest.
Hybrid Encryption for Large Data
// SECURE - Hybrid encryption (RSA-OAEP + AES-GCM)
const assert = require('node:assert');
const crypto = require('node:crypto');
const oaep = {
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
};
function hybridEncrypt(data, rsaPublicKey) {
const aesKey = crypto.randomBytes(32); // 256 bits
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
const ciphertext = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
const encryptedKey = crypto.publicEncrypt({ key: rsaPublicKey, ...oaep }, aesKey);
return { encryptedKey, iv, authTag, ciphertext };
}
function hybridDecrypt({ encryptedKey, iv, authTag, ciphertext }, rsaPrivateKey) {
const aesKey = crypto.privateDecrypt({ key: rsaPrivateKey, ...oaep }, encryptedKey);
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 3072 });
const data = 'x'.repeat(100000);
assert.strictEqual(hybridDecrypt(hybridEncrypt(data, publicKey), privateKey), data);
Why this works: RSA's size limits (318 bytes for a 3072-bit key with SHA-256 OAEP) make it unsuitable for bulk data. AES-GCM encrypts large payloads quickly and provides integrity checks, while RSA-OAEP securely wraps only the short ephemeral AES key - mirroring the hybrid design TLS uses at scale. The envelope has to carry iv and authTag alongside the ciphertext, and setAuthTag has to be called before final(): an encrypt-only example that drops either one still produces ciphertext, and the failure surfaces at decrypt time, after the data is stored.
Testing
- Round trip:
crypto.privateDecrypt()returns a buffer equal to the original plaintext. Assert on the bytes, not on the absence of a thrown error - a decrypt that never runs also never throws. - Digest agreement: ciphertext produced with
oaepHash: 'sha256'fails to decrypt underoaepHash: 'sha1', and vice versa, both withERR_OSSL_RSA_OAEP_DECODING_ERROR. Then assert the reverse for the call you are fixing: if the decrypt path succeeds withoaepHashomitted, it is still on SHA-1. - Payload size: OAEP-SHA-256 leaves 190 bytes on a 2048-bit key and 318 on a 3072-bit key, against 245 and 373 under PKCS#1 v1.5 (measured on Node 24.3). Assert that the largest payload the system actually sends still encrypts: on a 2048-bit key a 200-byte record that worked under PKCS#1 v1.5 throws
ERR_OSSL_RSA_DATA_TOO_LARGE_FOR_KEY_SIZEafter the change, and on a 3072-bit key 319 bytes does the same. - Probabilistic output: two encryptions of the same plaintext produce different ciphertexts. Identical output means the padding is not OAEP.
- Uniform failures: a ciphertext with one flipped byte, a ciphertext for a different key, and a truncated ciphertext all reach the caller as the same generic error. A distinct message or status per case is what rebuilds the oracle.
Common Pitfalls
- Updating the encrypt call to
RSA_PKCS1_OAEP_PADDINGand assuming a legacy decrypt path is still available for "older clients". On Node 24.3 it is not:crypto.privateDecryptrejectsRSA_PKCS1_PADDINGwithERR_INVALID_ARG_VALUE, and--security-revert=CVE-2023-46809no longer exists. The dual-read migration described on the main CWE-780 page therefore cannot be built on Node's own API - the legacy half has to run onnode-forgeor another library, or on a peer service, and that is also where the oracle survives after the Node code looks fixed. - Wrapping
crypto.privateDecryptin a try/catch that returns the caught error's message to an HTTP response or logs it with details that vary by failure type - Node's OAEP implementation throws a single generic decrypt error, but re-adding distinguishing detail in application code (e.g., separate messages for "bad padding" versus "wrong key") can reopen a timing or response-based oracle. - Setting
oaepHashon the encrypt call and leaving the decrypt call without it - the digest is not carried in the ciphertext, so the decrypt side falls back to SHA-1 and every message fails withERR_OSSL_RSA_OAEP_DECODING_ERROR. The error names neither side, and the usual "fix" is to removeoaepHashfrom the encrypt call too, which restores interoperability at SHA-1 and leaves the finding open. - Using
node-forgeor another third-party crypto library's OAEP mode alongside Node's built-incryptomodule in different parts of the same codebase - subtle differences in default hash algorithms or label handling between libraries can cause one code path to remain on a weaker default while the other appears fixed.
Additional Resources
- CWE-780: Use of RSA Algorithm without OAEP
- NIST SP 800-57 Part 1 Rev 5: Recommendation for Key Management - Table 4 is the source for the 2030 date: 112-bit strength is Acceptable for applying protection (generating keys, encrypting, signing) through 2030 and Disallowed from 2031, while processing already-protected data stays permitted as legacy use
- Node.js Crypto Documentation
- OWASP Cryptographic Storage Cheat Sheet
- RFC 8017: PKCS #1 v2.2
- Web Crypto API