CWE-780: Use of RSA Without OAEP - JavaScript/Node.js
Overview
Using RSA encryption without OAEP (Optimal Asymmetric Encryption Padding) enables padding oracle attacks, chosen ciphertext attacks, and message malleability. In Node.js, this commonly occurs when using the crypto module without explicitly specifying OAEP padding, which defaults to the insecure PKCS#1 v1.5 padding.
Primary Defence: Always specify padding: crypto.constants.RSA_PKCS1_OAEP_PADDING with oaepHash set to 'sha256' or stronger when using crypto.publicEncrypt() and crypto.privateDecrypt(), use Web Crypto API's RSA-OAEP algorithm with explicit hash functions in browser environments, never rely on default padding schemes, and consider using hybrid encryption (RSA for key exchange + AES-GCM for data) to prevent padding oracle attacks and ensure cryptographic security.
Common Vulnerable Patterns
Default Padding with crypto.publicEncrypt()
// VULNERABLE - Defaults to RSA_PKCS1_PADDING (insecure!)
const crypto = require('crypto');
const encrypted = crypto.publicEncrypt(
publicKey, // No padding specified
Buffer.from(message)
);
// Uses PKCS#1 v1.5 by default - vulnerable to Bleichenbacher's attack!
Why this is vulnerable:
- Omitting padding in
crypto.publicEncrypt()defaults toRSA_PKCS1_PADDING(PKCS#1 v1.5). - PKCS#1 v1.5 is deterministic, enabling Bleichenbacher-style padding oracles.
- Attackers can adaptively probe decryption success/failure to recover plaintext.
- Identical plaintexts yield identical ciphertexts, enabling pattern leakage.
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.- Distinct error messages or timing differences create a padding oracle.
- Attackers can brute-force with crafted ciphertexts and recover plaintext.
- Real-world exploits exist against TLS and messaging systems.
crypto-browserify with Default Padding
// VULNERABLE - crypto-browserify defaults to PKCS#1 v1.5
const crypto = require('crypto-browserify');
const encrypted = crypto.publicEncrypt(publicKeyPem, Buffer.from(message));
// No padding specified - uses insecure default
Why this is vulnerable:
- crypto-browserify defaults to PKCS#1 v1.5 when padding is omitted.
- Browser decryption can leak timing differences, enabling padding oracles.
- Deterministic PKCS#1 v1.5 exposes repeated tokens or keys.
- Client-side environments lack server-grade timing mitigations.
WebCrypto API with PKCS#1 v1.5
// VULNERABLE - Using RSA-PKCS1-v1_5 algorithm
async function encryptInsecure(data, publicKey) {
const encoded = new TextEncoder().encode(data);
const ciphertext = await crypto.subtle.encrypt(
{
name: 'RSA-PKCS1-v1_5' // Insecure!
},
publicKey,
encoded
);
return ciphertext;
}
Why this is vulnerable:
'RSA-PKCS1-v1_5'uses obsolete PKCS#1 v1.5 padding.- Promise timing differences can reveal padding validity.
- Browser APIs (e.g.,
performance.now()) enable high-precision timing oracles. - XSS or third-party scripts can probe decryptions at scale.
Using Weak Hash with OAEP
// VULNERABLE - SHA-1 is deprecated
const crypto = require('crypto');
const encrypted = crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha1' // Weak! Use sha256 or better
},
Buffer.from(message)
);
Why this is vulnerable:
- OAEP is fine, but
SHA-1is broken (practical collisions since 2017). - OAEP depends on a strong hash; SHA-1 undermines that security proof.
- 160-bit SHA-1 yields only ~80-bit collision security (too weak).
- Standards and compliance regimes prohibit SHA-1 in new systems.
Secure Patterns
Node.js crypto Module with RSA-OAEP (PREFERRED)
Always specify RSA_PKCS1_OAEP_PADDING when encrypting with RSA.
// SECURE - Node.js crypto with OAEP padding
const crypto = require('crypto');
// Generate RSA key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048, // Minimum 2048 bits
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
const message = 'Sensitive data to encrypt';
// SECURE - Encrypt with OAEP padding
const encrypted = crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256' // Use SHA-256 or better
},
Buffer.from(message, 'utf8')
);
// SECURE - Decrypt with OAEP padding
const decrypted = crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encrypted
);
console.log('Original:', message);
console.log('Decrypted:', decrypted.toString('utf8'));
Why this works:
RSA_PKCS1_OAEP_PADDINGmakes RSA probabilistic, blocking ciphertext replay.oaepHash: 'sha256'provides strong collision resistance for OAEP.- Node.js follows RFC 8017, ensuring cross-platform compatibility.
- OAEP eliminates PKCS#1 v1.5 padding oracles.
WebCrypto API with RSA-OAEP (Browser & Node.js 15.6+)
// SECURE - WebCrypto API with RSA-OAEP
const { webcrypto } = require('crypto');
const { subtle } = webcrypto;
async function generateKeyPairOAEP() {
return await subtle.generateKey(
{
name: 'RSA-OAEP', // OAEP algorithm
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: 'SHA-256' // Strong hash
},
true, // extractable
['encrypt', 'decrypt']
);
}
async function encryptWithOAEP(data, publicKey) {
const encoded = new TextEncoder().encode(data);
const ciphertext = await subtle.encrypt(
{
name: 'RSA-OAEP'
},
publicKey,
encoded
);
return Buffer.from(ciphertext);
}
async function decryptWithOAEP(ciphertext, privateKey) {
const plaintext = await subtle.decrypt(
{
name: 'RSA-OAEP'
},
privateKey,
ciphertext
);
return new TextDecoder().decode(plaintext);
}
// Example usage
(async () => {
const keyPair = await generateKeyPairOAEP();
const message = 'Secret message';
const encrypted = await encryptWithOAEP(message, keyPair.publicKey);
const decrypted = await decryptWithOAEP(encrypted, keyPair.privateKey);
console.log('Original:', message);
console.log('Decrypted:', decrypted);
})();
Why this works:
- Keys are generated for
RSA-OAEPand cannot be used with PKCS#1 v1.5. - SHA-256 is bound to the key, keeping encrypt/decrypt consistent.
- WebCrypto uses native crypto providers and avoids key extraction.
- Async APIs integrate cleanly while keeping crypto in vetted implementations.
node-forge Library with OAEP
// SECURE - node-forge with RSA-OAEP
const forge = require('node-forge');
// Generate key pair
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const publicKey = keypair.publicKey;
const privateKey = keypair.privateKey;
const message = 'Confidential data';
// SECURE - Encrypt with OAEP
const encrypted = publicKey.encrypt(
message,
'RSA-OAEP',
{
md: forge.md.sha256.create(), // Use SHA-256
mgf1: {
md: forge.md.sha256.create() // MGF1 with SHA-256
}
}
);
// SECURE - Decrypt with OAEP
const decrypted = privateKey.decrypt(
encrypted,
'RSA-OAEP',
{
md: forge.md.sha256.create(),
mgf1: {
md: forge.md.sha256.create()
}
}
);
console.log('Original:', message);
console.log('Decrypted:', decrypted);
Why this works:
'RSA-OAEP'forces OAEP padding instead of PKCS#1 v1.5.- Explicit
mdandmgf1.mdkeep hash selection under your control. - Prevents silent fallback to insecure defaults.
- Works in environments without native crypto (with a performance trade-off).
crypto-browserify with OAEP
// SECURE - crypto-browserify with explicit OAEP padding
const crypto = require('crypto-browserify');
const encrypted = crypto.publicEncrypt(
{
key: publicKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(message)
);
const decrypted = crypto.privateDecrypt(
{
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encrypted
);
Why this works:
- Same API as Node.js (
RSA_PKCS1_OAEP_PADDING,oaepHash) for drop-in use. - Uses WebCrypto when available, falls back to JS otherwise.
- Explicit padding/hash avoids environment-specific defaults.
- Keeps security consistent across Node and browser builds.
Hybrid Encryption for Large Data
// SECURE - Hybrid encryption (RSA-OAEP + AES-GCM)
const crypto = require('crypto');
class HybridEncryption {
static encrypt(data, rsaPublicKey) {
// Step 1: Generate random AES-256 key
const aesKey = crypto.randomBytes(32); // 256 bits
// Step 2: Encrypt data with AES-GCM
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
let ciphertext = cipher.update(data, 'utf8');
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
const authTag = cipher.getAuthTag();
// Step 3: Encrypt AES key with RSA-OAEP
const encryptedKey = crypto.publicEncrypt(
{
key: rsaPublicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
aesKey
);
return {
encryptedKey: encryptedKey,
iv: iv,
authTag: authTag,
ciphertext: ciphertext
};
}
static decrypt(encryptedData, rsaPrivateKey) {
// Step 1: Decrypt AES key with RSA-OAEP
const aesKey = crypto.privateDecrypt(
{
key: rsaPrivateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encryptedData.encryptedKey
);
// Step 2: Decrypt data with AES-GCM
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
aesKey,
encryptedData.iv
);
decipher.setAuthTag(encryptedData.authTag);
let plaintext = decipher.update(encryptedData.ciphertext);
plaintext = Buffer.concat([plaintext, decipher.final()]);
return plaintext.toString('utf8');
}
}
// Example: Encrypt large data (megabytes)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
const largeData = 'A'.repeat(1000000);
const encrypted = HybridEncryption.encrypt(largeData, publicKey);
const decrypted = HybridEncryption.decrypt(encrypted, privateKey);
console.log('Data size:', largeData.length);
console.log('Match:', largeData === decrypted);
Why this works:
- RSA size limits make it unsuitable for bulk data.
- AES-GCM encrypts large payloads quickly and provides integrity checks.
- RSA-OAEP securely wraps the short AES key.
- Mirrors TLS design for performance and security at scale.
Remediation Strategy
Use Node.js crypto with OAEP Padding (PRIMARY FIX)
Always specify RSA_PKCS1_OAEP_PADDING when encrypting with RSA.
Basic RSA-OAEP Encryption/Decryption
const crypto = require('crypto');
// Generate RSA key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048, // Minimum 2048 bits
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
const message = 'Sensitive data to encrypt';
// SECURE - Encrypt with OAEP padding
const encrypted = crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256' // Use SHA-256 or better
},
Buffer.from(message, 'utf8')
);
// SECURE - Decrypt with OAEP padding
const decrypted = crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encrypted
);
console.log('Original:', message);
console.log('Decrypted:', decrypted.toString('utf8'));
Using async/await (Node.js 15.6+)
const { webcrypto } = require('crypto');
const { subtle } = webcrypto;
async function generateKeyPairAsync() {
return await subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]), // 65537
hash: 'SHA-256'
},
true, // extractable
['encrypt', 'decrypt']
);
}
async function encryptWithOAEP(data, publicKey) {
const encoded = new TextEncoder().encode(data);
const ciphertext = await subtle.encrypt(
{
name: 'RSA-OAEP'
},
publicKey,
encoded
);
return Buffer.from(ciphertext);
}
async function decryptWithOAEP(ciphertext, privateKey) {
const plaintext = await subtle.decrypt(
{
name: 'RSA-OAEP'
},
privateKey,
ciphertext
);
return new TextDecoder().decode(plaintext);
}
// Example usage
(async () => {
const keyPair = await generateKeyPairAsync();
const message = 'Secret message';
const encrypted = await encryptWithOAEP(message, keyPair.publicKey);
const decrypted = await decryptWithOAEP(encrypted, keyPair.privateKey);
console.log('Original:', message);
console.log('Decrypted:', decrypted);
})();
Hybrid Encryption for Large Data
RSA is limited to small data (~190 bytes for 2048-bit RSA with SHA-256). Use hybrid encryption for larger data.
const crypto = require('crypto');
class HybridEncryption {
static encrypt(data, rsaPublicKey) {
// Step 1: Generate random AES-256 key
const aesKey = crypto.randomBytes(32); // 256 bits
// Step 2: Encrypt data with AES-GCM
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
let ciphertext = cipher.update(data, 'utf8');
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
const authTag = cipher.getAuthTag();
// Step 3: Encrypt AES key with RSA-OAEP
const encryptedKey = crypto.publicEncrypt(
{
key: rsaPublicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
aesKey
);
return {
encryptedKey: encryptedKey,
iv: iv,
authTag: authTag,
ciphertext: ciphertext
};
}
static decrypt(encryptedData, rsaPrivateKey) {
// Step 1: Decrypt AES key with RSA-OAEP
const aesKey = crypto.privateDecrypt(
{
key: rsaPrivateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
encryptedData.encryptedKey
);
// Step 2: Decrypt data with AES-GCM
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
aesKey,
encryptedData.iv
);
decipher.setAuthTag(encryptedData.authTag);
let plaintext = decipher.update(encryptedData.ciphertext);
plaintext = Buffer.concat([plaintext, decipher.final()]);
return plaintext.toString('utf8');
}
}
// Example usage
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
// Can encrypt large data (megabytes)
const largeData = 'A'.repeat(1000000);
const encrypted = HybridEncryption.encrypt(largeData, publicKey);
const decrypted = HybridEncryption.decrypt(encrypted, privateKey);
console.log('Data size:', largeData.length);
console.log('Match:', largeData === decrypted);
RSA Signatures with PSS (Not OAEP)
For signatures (not encryption), use PSS padding instead of OAEP.
const crypto = require('crypto');
function signWithPSS(message, privateKey) {
const sign = crypto.createSign('RSA-SHA256');
sign.update(message);
sign.end();
// Sign with PSS padding
return sign.sign({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_MAX_SIGN
});
}
function verifyWithPSS(message, signature, publicKey) {
const verify = crypto.createVerify('RSA-SHA256');
verify.update(message);
verify.end();
return verify.verify({
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_AUTO
}, signature);
}
// Example
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
const message = 'Document to sign';
const signature = signWithPSS(message, privateKey);
const isValid = verifyWithPSS(message, signature, publicKey);
console.log('Signature valid:', isValid);
Complete Working Example with Key Storage
const crypto = require('crypto');
const fs = require('fs');
class RSAKeyManager {
// Generate and save key pair
static generateAndSaveKeys(publicKeyFile, privateKeyFile) {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
fs.writeFileSync(publicKeyFile, publicKey);
fs.writeFileSync(privateKeyFile, privateKey);
return { publicKey, privateKey };
}
// Load keys from files
static loadKeys(publicKeyFile, privateKeyFile) {
const publicKey = fs.readFileSync(publicKeyFile, 'utf8');
const privateKey = fs.readFileSync(privateKeyFile, 'utf8');
return { publicKey, privateKey };
}
// Encrypt with OAEP
static encrypt(message, publicKey) {
return crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(message, 'utf8')
);
}
// Decrypt with OAEP
static decrypt(ciphertext, privateKey) {
return crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
ciphertext
).toString('utf8');
}
}
// Usage
const publicKeyFile = 'public.pem';
const privateKeyFile = 'private.pem';
// Generate keys
RSAKeyManager.generateAndSaveKeys(publicKeyFile, privateKeyFile);
// Load keys
const { publicKey, privateKey } = RSAKeyManager.loadKeys(
publicKeyFile,
privateKeyFile
);
// Encrypt
const message = 'Confidential data';
const encrypted = RSAKeyManager.encrypt(message, publicKey);
console.log('Encrypted:', encrypted.toString('base64'));
// Decrypt
const decrypted = RSAKeyManager.decrypt(encrypted, privateKey);
console.log('Decrypted:', decrypted);
TypeScript Version
import * as crypto from 'crypto';
interface EncryptedData {
encryptedKey: Buffer;
iv: Buffer;
authTag: Buffer;
ciphertext: Buffer;
}
interface KeyPair {
publicKey: string;
privateKey: string;
}
class SecureRSA {
static generateKeyPair(): KeyPair {
return crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
}
static encryptOAEP(message: string, publicKey: string): Buffer {
return crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(message, 'utf8')
);
}
static decryptOAEP(ciphertext: Buffer, privateKey: string): string {
return crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
ciphertext
).toString('utf8');
}
}
// Usage
const keys = SecureRSA.generateKeyPair();
const encrypted = SecureRSA.encryptOAEP('Secret', keys.publicKey);
const decrypted = SecureRSA.decryptOAEP(encrypted, keys.privateKey);
Migration from PKCS#1 v1.5 to OAEP
const crypto = require('crypto');
class MigrationHelper {
// Dual-padding decryption for migration period
static decryptWithFallback(ciphertext, privateKey) {
// Try OAEP first (secure)
try {
return crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
ciphertext
).toString('utf8');
} catch (error) {
console.warn('OAEP decryption failed, trying legacy PKCS1 padding');
}
// Fall back to legacy PKCS#1 v1.5
try {
return crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PADDING
},
ciphertext
).toString('utf8');
} catch (error) {
throw new Error(`Decryption failed with both OAEP and PKCS1: ${error.message}`);
}
}
// Always encrypt with OAEP (never use PKCS1 for new data)
static encryptSecure(message, publicKey) {
return crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(message, 'utf8')
);
}
}
Common Finding Examples
Example 1: Node.js crypto with default padding
// security finding: CWE-780
// Source: request.body.data
// Sink: crypto.publicEncrypt()
// VULNERABLE CODE:
const data = req.body.data;
const encrypted = crypto.publicEncrypt(publicKey, Buffer.from(data));
// FIX:
const data = req.body.data;
const encrypted = crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(data)
);
Example 2: node-forge with PKCS#1 v1.5
// security finding: CWE-780
// Source: config.secretData
// Sink: publicKey.encrypt()
// VULNERABLE CODE:
const forge = require('node-forge');
const encrypted = publicKey.encrypt(secretData, 'RSAES-PKCS1-V1_5');
// FIX:
const forge = require('node-forge');
const encrypted = publicKey.encrypt(
secretData,
'RSA-OAEP',
{
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() }
}
);
Example 3: WebCrypto API with wrong algorithm
// security finding: CWE-780
// Source: userInput
// Sink: subtle.encrypt()
// VULNERABLE CODE:
async function encryptData(data, publicKey) {
const encoded = new TextEncoder().encode(data);
return await crypto.subtle.encrypt(
{ name: 'RSA-PKCS1-v1_5' }, // Insecure!
publicKey,
encoded
);
}
// FIX:
async function encryptData(data, publicKey) {
const encoded = new TextEncoder().encode(data);
return await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' }, // Secure!
publicKey,
encoded
);
}