From 1b9c3cdcd67e4a324141302a3aa6b59ed8754ae5 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 08:41:57 -0400 Subject: [PATCH 01/14] feat: add OAEP encode/decode with independent MGF1 digest Node's crypto cannot set the MGF1 digest separately from the OAEP digest, so implement EME-OAEP over the raw RSA primitive. Verified against OpenSSL-generated ciphertext in both directions. Co-Authored-By: Claude Opus 5 --- lib/oaep.js | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ test/oaep.js | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 lib/oaep.js create mode 100644 test/oaep.js diff --git a/lib/oaep.js b/lib/oaep.js new file mode 100644 index 0000000..5063954 --- /dev/null +++ b/lib/oaep.js @@ -0,0 +1,115 @@ +var crypto = require('crypto'); + +// MGF1 mask generation function (RFC 8017 B.2.1). +function mgf1(seed, length, hash) { + var hLen = crypto.createHash(hash).digest().length; + var out = Buffer.alloc(Math.ceil(length / hLen) * hLen); + var counter = Buffer.alloc(4); + for (var i = 0; i * hLen < length; i++) { + counter.writeUInt32BE(i, 0); + crypto.createHash(hash).update(seed).update(counter).digest().copy(out, i * hLen); + } + return out.subarray(0, length); +} + +function xor(a, b) { + var out = Buffer.allocUnsafe(a.length); + for (var i = 0; i < a.length; i++) { + out[i] = a[i] ^ b[i]; + } + return out; +} + +function decodingError() { + var err = new Error('oaep decoding error'); + err.code = 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'; + return err; +} + +// EME-OAEP-DECODE (RFC 8017 7.1.2) with the message digest and the MGF1 digest +// chosen independently. Node's privateDecrypt cannot express that combination: +// it only sets the OAEP digest, and OpenSSL then defaults MGF1 to match it. +function privateDecryptOaep(privateKey, ciphertext, options) { + var opts = options || {}; + var oaepHash = opts.oaepHash || 'sha1'; + var mgf1Hash = opts.mgf1Hash || oaepHash; + var label = opts.oaepLabel || Buffer.alloc(0); + + var em; + try { + em = crypto.privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_NO_PADDING }, + ciphertext + ); + } catch (e) { + // Random bytes or malformed ciphertext can exceed the modulus and fail + // before OAEP decode even starts. Don't reveal which check failed. + throw decodingError(); + } + + var hLen = crypto.createHash(oaepHash).digest().length; + if (em.length < 2 * hLen + 2) throw decodingError(); + + var maskedSeed = em.subarray(1, 1 + hLen); + var maskedDB = em.subarray(1 + hLen); + var seed = xor(maskedSeed, mgf1(maskedDB, hLen, mgf1Hash)); + var db = xor(maskedDB, mgf1(seed, maskedDB.length, mgf1Hash)); + var lHash = crypto.createHash(oaepHash).update(label).digest(); + + // Accumulate every failure condition, then throw one generic error. Do not + // branch out early and do not report which check failed: the RFC 8017 + // 7.1.2 checks must be indistinguishable from outside. + var bad = em[0] | (crypto.timingSafeEqual(db.subarray(0, hLen), lHash) ? 0 : 1); + var found = 0; + var messageStart = 0; + for (var i = hLen; i < db.length; i++) { + var isOne = (db[i] ^ 0x01) === 0 ? 1 : 0; + var isZero = db[i] === 0 ? 1 : 0; + var first = isOne & (found ^ 1); + messageStart |= first * (i + 1); + found |= isOne; + bad |= (found ^ 1) & (isZero ^ 1); + } + if (bad || !found) throw decodingError(); + + return Buffer.from(db.subarray(messageStart)); +} + +// EME-OAEP-ENCODE (RFC 8017 7.1.1) followed by the raw RSA public operation. +function publicEncryptOaep(publicKey, message, options) { + var opts = options || {}; + var oaepHash = opts.oaepHash || 'sha1'; + var mgf1Hash = opts.mgf1Hash || oaepHash; + var label = opts.oaepLabel || Buffer.alloc(0); + + var key = crypto.createPublicKey(publicKey); + var k = Math.ceil(key.asymmetricKeyDetails.modulusLength / 8); + var hLen = crypto.createHash(oaepHash).digest().length; + var msg = Buffer.isBuffer(message) ? message : Buffer.from(message); + if (msg.length > k - 2 * hLen - 2) { + throw new Error('message too long for the given key size'); + } + + var lHash = crypto.createHash(oaepHash).update(label).digest(); + var db = Buffer.concat([ + lHash, + Buffer.alloc(k - msg.length - 2 * hLen - 2), + Buffer.from([0x01]), + msg + ]); + var seed = crypto.randomBytes(hLen); + var maskedDB = xor(db, mgf1(seed, db.length, mgf1Hash)); + var maskedSeed = xor(seed, mgf1(maskedDB, hLen, mgf1Hash)); + var em = Buffer.concat([Buffer.alloc(1), maskedSeed, maskedDB]); + + return crypto.publicEncrypt( + { key: key, padding: crypto.constants.RSA_NO_PADDING }, + em + ); +} + +module.exports = { + mgf1: mgf1, + publicEncryptOaep: publicEncryptOaep, + privateDecryptOaep: privateDecryptOaep +}; diff --git a/test/oaep.js b/test/oaep.js new file mode 100644 index 0000000..259e585 --- /dev/null +++ b/test/oaep.js @@ -0,0 +1,113 @@ +var assert = require('assert'); +var crypto = require('crypto'); +var oaep = require('../lib/oaep'); + +// Throwaway 2048-bit key + ciphertext from the ESD-63620 repro. Produced by: +// openssl pkeyutl -encrypt -pubin -inkey pub.pem -in key.bin \ +// -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha1 +var VECTOR_PLAINTEXT = 'AES-128-key-1234'; +var VECTOR_KEY = Buffer.from( + 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQzA5QmY1NTRXR0VxRXYKMmZyUWxOUG9ycWRMbld5RVAyTGlybGwvekZuUHFnK0c5RkdxYnNNb3o3UG1CUDRpZlRhVFRtSkViaUx1ajkyWQpQM3FieU9JUmN6MkFZQXJkZlE3M0RiSXhYaUZsazNObjlvVnRISVJUcHVvZkwzc2FkVlozMGg5c3JVeTZ4N0Z1ClZvL0ErNDJlSEVRNEdhaGJOSjJsMlh2QU5ydGQwUG5jNlc2MS9pVTdQK3Z4WDJyM0Fqb2VLMjNTWVRxbTkxRTkKMlN0WmVKQjJuSm4rSGxaamV6WTVUblhCZy9HRmFCZGNvR1JMb1diYzFsV2Q0SHNYa1BuVExyTW5UL0xiV1pQZQo4QVpyU253R1Fpa3dud245ZjF3K01ZQ1h6QU0yNWE4STkrZXRacEl0cFN5VUVtWE9yMnEzRkVkMG5RUUVzMHNFCmkrbENlaW5oQWdNQkFBRUNnZ0VBRUpDdDV6YytIblZ6SXczSjY3Rk1LdWRlTWtwaGhrUEZPaW9xMEV1MVJ4RHkKNWZCVXo0emZPY3UxMU05Tk1ud1M5RzQvQ2JPcFoveHNsVVR1WlBlQlZvYWRzVFJabWtnYUNCek5YTDZZd1JNOApBOTdwL1FDWXpvMmZyaVlyRjFONWpIT0VZKzhEY0svYU90Y2F4dGhnY1FKMmJrcFBBclp3M2g5b09FTHFhUjZTClJyNDgxSUZtS0JNdmhyVUQxVFU0MG5jWG43MTdvazlxalR4bFNuOElONElxSmVMTDFPTkFTMDlNSkhISTZPdG8KbHRZUjNWc1RFdE9YTGNsQ2ZubU5ZT2xpeVgrL1VoMTZBak0rSlJmOFRNL2lDYjNkUGFxMyt4UUFCL1oxRnZPZAo0UEFpa01LNVFTc09jdHhxYThwbm10MUlLK1N4MEZ0aUIweThzbWdYM3dLQmdRRHIyNmxLMlIwbmRpWVZDK3hLCjB2SmxYZ0FZeG9TU1IxOS9EdEFQbDdNMkFVVFRzblVFNmlpSCtDbU1ZK1k5Qm0wblkrNGsveThNUG9sdHZ5OUsKQ1ZVT21Ka0hFY3IvZmo3WHl2OEdkTTJSeXVQOHFhRVZxUlh5WU9PMzM5OUt0NzBFb3FFWVJsS0MxQllXcTVWcQovRExURXphSEowSHFXSk85QjB5eW00cDJId0tCZ1FERWFCdEZGN0NqbG1lMVVjUXRDZ0pqZnZPVWF6UTQ3WGdxClpkNTZ6emcyWm5vejZjYzkvTE40WnV5OC8rcFRYcS9GL3E5RjZtb1pyYktHVDBlNFg4dlVtQVZxd3NmQ01TOGcKTWk4Ui8zeGRZdG54Ly9IbW5DUmpYYTJTOUoraWU1Wks3RHpZWGl0Vi9yamEwRHhvWG9RMm82TG9URXQ1WVNTdQpFY2dEclNkZi93S0JnQXZ5dk1qRjV1d3cyQTBJNVplRXlETEthRWJaQjY1QlgxMFlhd0hmTlh6dTQ0VzE3S2VyCkZSS09SOHlNNHdVRVpsTXdoTWZyQlg4aFMrVDdZbkhsdHlGZUthSnFERmFWRnFubjVyTjFCMVR6YWtsS2JwYWkKVWpKTkpqd1NZMFZ0dVcyYXIzNkRVWHEvTTc5Q1FmZUJmekdpTDRqNVBDV2JCeUQwVmJaV210VVJBb0dBY3ZncAo5bUR1c21QSm8zY1FxZml3KzBNR0hMeEFYbzZMaCs0SHRNWDJOc24wQU1sNUt3endsYXRTS3pSM0c0UlN5a2pTCm1zK2tlaEdXYms2Y1FnNDVoK0hSVWZSZzhJalArRDNJRmZZQys3dHdydHRPNDlwRTVyR2dlR1NmeVlJa3NRam0KZVJWdXNyRWZ6bDZVN2RkZDk0b0VRNHpkcFZpN0d2WW5xaGRDOUVzQ2dZRUFwSkJ1SEpFZXYwd1lxUStnNERzVwpxZTVQYWJOY1dVaDJrNUswMGwza0lmMlpweVdWRWs5bnhRVzJIOFVmNHNVc0d0VTBvMmUycEtUanJ1WDRWcVR5CitoeWxrOXZEam1MU29CenFEREU4bFdOK1llU3hBYzJ0WnE2TnpmT0FPZGdHOVN1ODI0MDlEcUtuR1RlQzlKKzAKWHJ6Z21WOGp5K013cU9kQXJ2QXJ0MXM9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K', + 'base64' +).toString('utf8'); +var VECTOR_CT = Buffer.from( + 'fcAWGHe0HIxC3LcLBwwrkts3005XTSznQZTZZU6EiLOSh/fAfPoe0vF60RcK0IYGW1oDUfuwCl3W+C3HOPTRvFGHiI6AfKCKkj8pTna6WuAZP5x4lBdSKxkIoECgBp+GYko2TMlRn6aW0mOhMCw60P1lT5x93blbbYf4nh0reOtODA8VQBCHnS0wu+qFqIzG/x2UgIbrasnlHo45UlbxdfpOYR08ckKZZrltMZrLcoQnTgrwevwafOg9OvfpY9Kw5Aml+aBhdsabr2aQC5quE6nho0ar/QobPmG5+WzEB5eHn59fTQExDdV2KDcyi7E8xACOjkFFWr+VZmf6t1l59Q==', + 'base64' +); + +describe('oaep', function () { + describe('privateDecryptOaep', function () { + it('decrypts an OpenSSL OAEP(sha256)/MGF1(sha1) ciphertext', function () { + var pt = oaep.privateDecryptOaep(VECTOR_KEY, VECTOR_CT, { + oaepHash: 'sha256', + mgf1Hash: 'sha1' + }); + assert.equal(pt.toString('utf8'), VECTOR_PLAINTEXT); + }); + + it('rejects the same ciphertext when MGF1 is wrong', function () { + assert.throws(function () { + oaep.privateDecryptOaep(VECTOR_KEY, VECTOR_CT, { + oaepHash: 'sha256', + mgf1Hash: 'sha256' + }); + }, /oaep decoding error/); + }); + + it('rejects random bytes', function () { + assert.throws(function () { + oaep.privateDecryptOaep(VECTOR_KEY, crypto.randomBytes(256), { + oaepHash: 'sha256', + mgf1Hash: 'sha1' + }); + }, /oaep decoding error/); + }); + + it('sets code ERR_OSSL_RSA_OAEP_DECODING_ERROR on failure', function () { + try { + oaep.privateDecryptOaep(VECTOR_KEY, crypto.randomBytes(256), { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + assert.fail('should have thrown'); + } catch (e) { + assert.equal(e.code, 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'); + } + }); + }); + + describe('mgf1', function () { + it('matches the RFC 8017 counter construction for one block', function () { + var seed = Buffer.from('abc'); + var ctr = Buffer.alloc(4); // i = 0 + var expected = crypto.createHash('sha1').update(seed).update(ctr).digest(); + assert.equal(oaep.mgf1(seed, 20, 'sha1').toString('hex'), expected.toString('hex')); + }); + + it('spans multiple blocks and truncates to the requested length', function () { + var out = oaep.mgf1(Buffer.from('seed'), 50, 'sha1'); + assert.equal(out.length, 50); + }); + }); + + describe('round trips', function () { + var fs = require('fs'); + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var key = fs.readFileSync(__dirname + '/test-auth0.key'); + var combos = [ + ['sha256', 'sha1'], + ['sha512', 'sha1'], + ['sha1', 'sha256'], + ['sha384', 'sha256'], + ['sha256', 'sha256'] + ]; + + combos.forEach(function (combo) { + var oaepHash = combo[0]; + var mgf1Hash = combo[1]; + // 2048-bit key => k = 256 bytes; longest legal message is k - 2*hLen - 2. + var hLen = crypto.createHash(oaepHash).digest().length; + [0, 1, 17, 256 - 2 * hLen - 2].forEach(function (len) { + it('round trips oaep=' + oaepHash + ' mgf1=' + mgf1Hash + ' len=' + len, function () { + var msg = crypto.randomBytes(len); + var ct = oaep.publicEncryptOaep(pub, msg, { oaepHash: oaepHash, mgf1Hash: mgf1Hash }); + var pt = oaep.privateDecryptOaep(key, ct, { oaepHash: oaepHash, mgf1Hash: mgf1Hash }); + assert.equal(Buffer.compare(pt, msg), 0); + }); + }); + }); + + it('rejects a message longer than the key allows', function () { + assert.throws(function () { + oaep.publicEncryptOaep(pub, crypto.randomBytes(256), { oaepHash: 'sha256' }); + }, /message too long/); + }); + + it('round trips a non-empty oaepLabel and rejects the wrong label', function () { + var label = Buffer.from('MYLABEL'); + var ct = oaep.publicEncryptOaep(pub, Buffer.from('labelled'), { oaepHash: 'sha256', mgf1Hash: 'sha1', oaepLabel: label }); + var pt = oaep.privateDecryptOaep(key, ct, { oaepHash: 'sha256', mgf1Hash: 'sha1', oaepLabel: label }); + assert.equal(pt.toString(), 'labelled'); + assert.throws(function () { + oaep.privateDecryptOaep(key, ct, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + }, /oaep decoding error/); + }); + }); +}); From aaa11cf39dba1fdb608b869f521a2e3c4c6b0208 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 09:02:37 -0400 Subject: [PATCH 02/14] fix(oaep): hoist key parsing, pin rejection guards, use branch-free isZero/isOne MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review findings for Task 1: Finding 1 (Important): Hoist key parsing and ciphertext-length validation above the try-catch so operational failures (bad PEM, wrong key type) surface immediately rather than masquerading as OAEP decode errors. Only the modular exponentiation remains masked so that the RFC 8017 7.1.2 checks stay indistinguishable from outside. Finding 2 (Important): Add four tests that pin each RFC 8017 §7.1.2 rejection guard (leading byte, lHash mismatch, no separator, non-zero PS). Mutation testing confirms each guard is now covered. Minor: Replace isZero/isOne ternaries with branch-free arithmetic form to eliminate data-dependent branching in the separator scan. Minor: Derive k from the fixture key instead of hardcoding 256, so max- length test cases remain correct if the key changes. All 77 tests passing. OpenSSL interop verified. Co-Authored-By: Claude Opus 5 --- lib/oaep.js | 17 ++++++--- test/oaep.js | 102 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/lib/oaep.js b/lib/oaep.js index 5063954..78d3659 100644 --- a/lib/oaep.js +++ b/lib/oaep.js @@ -35,15 +35,22 @@ function privateDecryptOaep(privateKey, ciphertext, options) { var mgf1Hash = opts.mgf1Hash || oaepHash; var label = opts.oaepLabel || Buffer.alloc(0); + // Parse key before masking errors — operational failures (bad PEM, public-key-for-private) + // must surface, not masquerade as OAEP decode failures. + var keyObj = crypto.createPrivateKey(privateKey); + var k = Math.ceil(keyObj.asymmetricKeyDetails.modulusLength / 8); + // Ciphertext length comes from the document, so keep the failure generic. + if (ciphertext.length !== k) throw decodingError(); + var em; try { em = crypto.privateDecrypt( - { key: privateKey, padding: crypto.constants.RSA_NO_PADDING }, + { key: keyObj, padding: crypto.constants.RSA_NO_PADDING }, ciphertext ); } catch (e) { - // Random bytes or malformed ciphertext can exceed the modulus and fail - // before OAEP decode even starts. Don't reveal which check failed. + // Ciphertext ≥ modulus fails before OAEP decode starts. Also from the + // document, so stay generic. throw decodingError(); } @@ -63,8 +70,8 @@ function privateDecryptOaep(privateKey, ciphertext, options) { var found = 0; var messageStart = 0; for (var i = hLen; i < db.length; i++) { - var isOne = (db[i] ^ 0x01) === 0 ? 1 : 0; - var isZero = db[i] === 0 ? 1 : 0; + var isZero = ((db[i] - 1) >>> 31) & 1; + var isOne = (((db[i] ^ 1) - 1) >>> 31) & 1; var first = isOne & (found ^ 1); messageStart |= first * (i + 1); found |= isOne; diff --git a/test/oaep.js b/test/oaep.js index 259e585..4941f46 100644 --- a/test/oaep.js +++ b/test/oaep.js @@ -51,6 +51,101 @@ describe('oaep', function () { assert.equal(e.code, 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'); } }); + + // RFC 8017 §7.1.2 rejection guards: encrypt a valid message, corrupt one thing, decrypt. + it('rejects when leading byte is not 0x00', function () { + var fs = require('fs'); + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var key = fs.readFileSync(__dirname + '/test-auth0.key'); + var ct = oaep.publicEncryptOaep(pub, Buffer.from('test'), { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + // Decrypt to EM, corrupt leading byte, re-encrypt. + var em = crypto.privateDecrypt({ key: key, padding: crypto.constants.RSA_NO_PADDING }, ct); + em[0] = 0x01; + var badCt = crypto.publicEncrypt({ key: pub, padding: crypto.constants.RSA_NO_PADDING }, em); + assert.throws(function () { + oaep.privateDecryptOaep(key, badCt, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + }, function (e) { return e.code === 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'; }); + }); + + it('rejects when lHash does not match', function () { + var fs = require('fs'); + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var key = fs.readFileSync(__dirname + '/test-auth0.key'); + var ct = oaep.publicEncryptOaep(pub, Buffer.from('test'), { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + var em = crypto.privateDecrypt({ key: key, padding: crypto.constants.RSA_NO_PADDING }, ct); + // Corrupt a byte in the lHash region (db[0..31] after unmasking). Flip bit in maskedDB[0]. + var hLen = 32; // SHA-256 + var maskedDB = em.subarray(1 + hLen); + maskedDB[0] ^= 1; + var badCt = crypto.publicEncrypt({ key: pub, padding: crypto.constants.RSA_NO_PADDING }, em); + assert.throws(function () { + oaep.privateDecryptOaep(key, badCt, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + }, function (e) { return e.code === 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'; }); + }); + + it('rejects when no 0x01 separator is found', function () { + var fs = require('fs'); + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var key = fs.readFileSync(__dirname + '/test-auth0.key'); + // Craft an EM where db = lHash || all-zeros (no separator, no message). + var hLen = 32; + var keyObj = crypto.createPublicKey(pub); + var k = Math.ceil(keyObj.asymmetricKeyDetails.modulusLength / 8); + var lHash = crypto.createHash('sha256').digest(); + var db = Buffer.concat([lHash, Buffer.alloc(k - 1 - hLen - hLen)]); + var seed = crypto.randomBytes(hLen); + var maskedDB = oaep.mgf1(seed, db.length, 'sha1'); + for (var i = 0; i < db.length; i++) maskedDB[i] ^= db[i]; + var maskedSeed = oaep.mgf1(maskedDB, hLen, 'sha1'); + for (var j = 0; j < seed.length; j++) maskedSeed[j] ^= seed[j]; + var em = Buffer.concat([Buffer.from([0x00]), maskedSeed, maskedDB]); + var badCt = crypto.publicEncrypt({ key: pub, padding: crypto.constants.RSA_NO_PADDING }, em); + assert.throws(function () { + oaep.privateDecryptOaep(key, badCt, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + }, function (e) { return e.code === 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'; }); + }); + + it('rejects a too-short ciphertext', function () { + var fs = require('fs'); + var key = fs.readFileSync(__dirname + '/test-auth0.key'); + // Ciphertext length must equal k (modulus size in bytes). A shorter ciphertext + // is caught by the ciphertext-length check before raw RSA decrypt. + var keyObj = crypto.createPrivateKey(key); + var k = Math.ceil(keyObj.asymmetricKeyDetails.modulusLength / 8); + var shortCt = Buffer.alloc(k - 1); + assert.throws(function () { + oaep.privateDecryptOaep(key, shortCt, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + }, function (e) { return e.code === 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'; }); + }); + + it('rejects when PS contains non-zero bytes before the separator', function () { + var fs = require('fs'); + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var key = fs.readFileSync(__dirname + '/test-auth0.key'); + // Small message => long PS, so there's guaranteed space to inject 0x02 before the separator. + var ct = oaep.publicEncryptOaep(pub, Buffer.from('x'), { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + var em = crypto.privateDecrypt({ key: key, padding: crypto.constants.RSA_NO_PADDING }, ct); + var hLen = 32; + var maskedSeed = em.subarray(1, 1 + hLen); + var maskedDB = em.subarray(1 + hLen); + var seed = oaep.mgf1(maskedDB, hLen, 'sha1'); + for (var i = 0; i < seed.length; i++) seed[i] ^= maskedSeed[i]; + var db = oaep.mgf1(seed, maskedDB.length, 'sha1'); + for (var j = 0; j < db.length; j++) db[j] ^= maskedDB[j]; + // db: lHash (32) || PS || 0x01 || message. Inject 0x02 in PS well before the separator. + db[hLen + 10] = 0x02; + // Re-mask both DB and seed so the decode will recover this corrupted db. + var newMaskedDB = oaep.mgf1(seed, db.length, 'sha1'); + for (var m = 0; m < db.length; m++) newMaskedDB[m] ^= db[m]; + var newMaskedSeed = oaep.mgf1(newMaskedDB, hLen, 'sha1'); + for (var n = 0; n < seed.length; n++) newMaskedSeed[n] ^= seed[n]; + // Rebuild EM with the new masked values. + em = Buffer.concat([Buffer.from([0x00]), newMaskedSeed, newMaskedDB]); + var badCt = crypto.publicEncrypt({ key: pub, padding: crypto.constants.RSA_NO_PADDING }, em); + assert.throws(function () { + oaep.privateDecryptOaep(key, badCt, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + }, function (e) { return e.code === 'ERR_OSSL_RSA_OAEP_DECODING_ERROR'; }); + }); }); describe('mgf1', function () { @@ -71,6 +166,8 @@ describe('oaep', function () { var fs = require('fs'); var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); var key = fs.readFileSync(__dirname + '/test-auth0.key'); + var keyObj = crypto.createPublicKey(pub); + var k = Math.ceil(keyObj.asymmetricKeyDetails.modulusLength / 8); var combos = [ ['sha256', 'sha1'], ['sha512', 'sha1'], @@ -82,9 +179,8 @@ describe('oaep', function () { combos.forEach(function (combo) { var oaepHash = combo[0]; var mgf1Hash = combo[1]; - // 2048-bit key => k = 256 bytes; longest legal message is k - 2*hLen - 2. var hLen = crypto.createHash(oaepHash).digest().length; - [0, 1, 17, 256 - 2 * hLen - 2].forEach(function (len) { + [0, 1, 17, k - 2 * hLen - 2].forEach(function (len) { it('round trips oaep=' + oaepHash + ' mgf1=' + mgf1Hash + ' len=' + len, function () { var msg = crypto.randomBytes(len); var ct = oaep.publicEncryptOaep(pub, msg, { oaepHash: oaepHash, mgf1Hash: mgf1Hash }); @@ -96,7 +192,7 @@ describe('oaep', function () { it('rejects a message longer than the key allows', function () { assert.throws(function () { - oaep.publicEncryptOaep(pub, crypto.randomBytes(256), { oaepHash: 'sha256' }); + oaep.publicEncryptOaep(pub, crypto.randomBytes(k), { oaepHash: 'sha256' }); }, /message too long/); }); From d6385994bcfe41451d29cd5dd4a2cd3cac424b75 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 09:09:15 -0400 Subject: [PATCH 03/14] fix: resolve DigestMethod relative to the located EncryptionMethod An absolute XPath anchored under KeyInfo/EncryptedKey missed documents using EncryptedData/KeyInfo/RetrievalMethod, silently defaulting oaepHash to sha1. Co-Authored-By: Claude Opus 5 --- lib/xmlenc.js | 5 +++- test/xmlenc.digest.js | 53 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 50fa39e..71cd501 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -251,7 +251,10 @@ function decryptKeyInfo(doc, options) { } let oaepHash = 'sha1'; - const keyDigestMethod = xpath.select("//*[local-name(.)='KeyInfo']/*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']/*[local-name(.)='DigestMethod']", doc)[0]; + // Resolve DigestMethod relative to the EncryptionMethod we already located, + // not by an absolute path: with EncryptedData/KeyInfo/RetrievalMethod the + // EncryptedKey lives outside KeyInfo and an anchored XPath finds nothing. + const keyDigestMethod = xpath.select("./*[local-name(.)='DigestMethod']", keyEncryptionMethod)[0]; if (keyDigestMethod) { const keyDigestMethodAlgorithm = keyDigestMethod.getAttribute('Algorithm'); switch (keyDigestMethodAlgorithm) { diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index 4994415..3f20759 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -150,3 +150,56 @@ describe('keyEncryptionDigest', function () { }); }); }); + +describe('DigestMethod resolution with RetrievalMethod', function () { + var xpath = require('xpath'); + var xmldom = require('@xmldom/xmldom'); + + it('finds the DigestMethod when EncryptedKey is outside KeyInfo', function () { + var doc = new xmldom.DOMParser().parseFromString( + fs.readFileSync(__dirname + '/test-okta-enc-response.xml', 'utf8') + ); + // The pre-fix XPath, anchored under KeyInfo/EncryptedKey, finds nothing here. + var anchored = xpath.select( + "//*[local-name(.)='KeyInfo']/*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']/*[local-name(.)='DigestMethod']", + doc + ); + assert.equal(anchored.length, 0, 'fixture must exercise the RetrievalMethod shape'); + + // Resolving relative to the EncryptedKey's own EncryptionMethod does find it. + var relative = xpath.select( + "//*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']/*[local-name(.)='DigestMethod']", + doc + ); + assert.equal(relative.length, 1); + assert.equal(relative[0].getAttribute('Algorithm'), 'http://www.w3.org/2000/09/xmldsig#sha1'); + }); + + it('decrypts a RetrievalMethod document whose DigestMethod is sha256', function (done) { + // Build the RetrievalMethod shape with a sha256 digest, which the anchored + // XPath would misread as sha1. + var options = { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP, + keyEncryptionDigest: 'sha256' + }; + xmlenc.encrypt('retrieval method content', options, function (err, result) { + if (err) return done(err); + // Move EncryptedKey out of KeyInfo and point at it with a RetrievalMethod. + var m = //.exec(result); + assert(m, 'expected an EncryptedKey element'); + var encryptedKey = m[0].replace('') + .replace('', encryptedKey + ''); + + xmlenc.decrypt(rewritten, { key: fs.readFileSync(__dirname + '/test-auth0.key') }, function (err2, decrypted) { + if (err2) return done(err2); + assert.equal(decrypted, 'retrieval method content'); + done(); + }); + }); + }); +}); From 3488b65320310204fc7d6f9af15fc3d6269adaf8 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 09:13:46 -0400 Subject: [PATCH 04/14] fix!: pin MGF1 to SHA-1 for rsa-oaep-mgf1p on decrypt BREAKING CHANGE: the rsa-oaep-mgf1p identifier fixes MGF1 to SHA-1 per XML-Enc 1.1 section 5.5.2, and DigestMethod selects only the OAEP message digest. Ciphertext produced by this library with keyEncryptionDigest sha256 or sha512 (v3.1.0 through v5.0.0) used MGF1 matching the digest and no longer decrypts; it was never interoperable with compliant peers. --- lib/xmlenc.js | 19 +++++++++++++++---- test/xmlenc.digest.js | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 71cd501..65c4132 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -2,6 +2,7 @@ var crypto = require('crypto'); var xmldom = require('@xmldom/xmldom'); var xpath = require('xpath'); var utils = require('./utils'); +var oaep = require('./oaep'); const insecureAlgorithms = [ //https://www.w3.org/TR/xmlenc-core1/#rsav15note @@ -280,7 +281,12 @@ function decryptKeyInfo(doc, options) { switch (keyEncryptionAlgorithm) { case 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p': - return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash); + // The identifier fixes MGF1 to SHA-1 (XML-Enc 1.1 5.5.2); DigestMethod + // selects only the OAEP message digest. An xenc11:MGF child is a MUST NOT. + if (xpath.select("./*[local-name(.)='MGF']", keyEncryptionMethod)[0]) { + throw new Error('MGF element must not be present with ' + keyEncryptionAlgorithm); + } + return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash, 'sha1'); case 'http://www.w3.org/2001/04/xmlenc#rsa-1_5': utils.warnInsecureAlgorithm(keyEncryptionAlgorithm, options.warnInsecureAlgorithm); return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_PADDING); @@ -289,10 +295,15 @@ function decryptKeyInfo(doc, options) { } } -function decryptKeyInfoWithScheme(encryptedKey, options, padding, oaepHash) { +function decryptKeyInfoWithScheme(encryptedKey, options, padding, oaepHash, mgf1Hash) { const key = Buffer.from(encryptedKey.textContent, 'base64'); - const decrypted = crypto.privateDecrypt({ key: options.key, padding, oaepHash}, key); - return Buffer.from(decrypted, 'binary'); + // Node cannot set the MGF1 digest separately, so only fall back to the JS + // implementation when it actually differs. Everything else stays in OpenSSL. + if (padding !== crypto.constants.RSA_PKCS1_OAEP_PADDING || !mgf1Hash || mgf1Hash === oaepHash) { + const decrypted = crypto.privateDecrypt({ key: options.key, padding, oaepHash }, key); + return Buffer.from(decrypted, 'binary'); + } + return oaep.privateDecryptOaep(options.key, key, { oaepHash, mgf1Hash }); } function encryptWithAlgorithm(algorithm, symmetricKey, ivLength, content, encoding, callback) { diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index 3f20759..2aa40f4 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -203,3 +203,47 @@ describe('DigestMethod resolution with RetrievalMethod', function () { }); }); }); + +describe('rsa-oaep-mgf1p pins MGF1 to sha1', function () { + var crypto = require('crypto'); + var oaep = require('../lib/oaep'); + + // Build a KeyInfo whose EncryptedKey was wrapped with OAEP(sha256)/MGF1(sha1), + // i.e. what a spec-compliant IdP such as ADFS or Okta actually sends. + function specCompliantKeyInfo(symmetricKey, digest) { + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var wrapped = oaep.publicEncryptOaep(pub, symmetricKey, { oaepHash: digest, mgf1Hash: 'sha1' }); + return '' + + '' + + '' + + '' + + '' + + '' + wrapped.toString('base64') + '' + + ''; + } + + ['sha256', 'sha512'].forEach(function (digest) { + it('decrypts a spec-correct MGF1-sha1 key with DigestMethod ' + digest, function () { + var symmetricKey = crypto.randomBytes(32); + var recovered = xmlenc.decryptKeyInfo(specCompliantKeyInfo(symmetricKey, digest), { + key: fs.readFileSync(__dirname + '/test-auth0.key') + }); + assert.equal(Buffer.compare(Buffer.from(recovered), symmetricKey), 0); + }); + }); + + it('rejects a key wrapped with the non-spec MGF1=sha256', function () { + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var wrapped = oaep.publicEncryptOaep(pub, crypto.randomBytes(32), { oaepHash: 'sha256', mgf1Hash: 'sha256' }); + var keyInfo = '' + + '' + + '' + + '' + + '' + + '' + wrapped.toString('base64') + '' + + ''; + assert.throws(function () { + xmlenc.decryptKeyInfo(keyInfo, { key: fs.readFileSync(__dirname + '/test-auth0.key') }); + }, /oaep decoding error/); + }); +}); From d2548b11f1968950519640406270d8fd4ef510ac Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 09:34:09 -0400 Subject: [PATCH 05/14] fix!: emit MGF1-SHA1 ciphertext for rsa-oaep-mgf1p BREAKING CHANGE: encrypting with keyEncryptionDigest sha256 or sha512 under rsa-oaep-mgf1p now wraps the key with MGF1-SHA1, as the identifier requires. Peers that adapted to the previous non-compliant output must switch to the xmlenc11#rsa-oaep identifier with keyEncryptionMgf. --- lib/xmlenc.js | 28 ++++++++++++----- test/xmlenc.digest.js | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 65c4132..cd74017 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -14,15 +14,26 @@ const insecureAlgorithms = [ 'http://www.w3.org/2001/04/xmlenc#aes128-cbc', ]; -function encryptKeyInfoWithScheme(symmetricKey, options, padding, callback) { +function encryptKeyInfoWithScheme(symmetricKey, options, padding, mgf1Hash, callback) { const symmetricKeyBuffer = Buffer.isBuffer(symmetricKey) ? symmetricKey : Buffer.from(symmetricKey, 'utf-8'); try { - var encrypted = crypto.publicEncrypt({ - key: options.rsa_pub, - oaepHash: padding == crypto.constants.RSA_PKCS1_OAEP_PADDING ? options.keyEncryptionDigest : undefined, - padding: padding - }, symmetricKeyBuffer); + const isOAEP = padding == crypto.constants.RSA_PKCS1_OAEP_PADDING; + const oaepHash = isOAEP ? options.keyEncryptionDigest : undefined; + let encrypted; + if (isOAEP && mgf1Hash && mgf1Hash !== oaepHash) { + // Node cannot set MGF1 separately from the OAEP digest. + encrypted = oaep.publicEncryptOaep(options.rsa_pub, symmetricKeyBuffer, { + oaepHash: oaepHash, + mgf1Hash: mgf1Hash + }); + } else { + encrypted = crypto.publicEncrypt({ + key: options.rsa_pub, + oaepHash: oaepHash, + padding: padding + }, symmetricKeyBuffer); + } var base64EncodedEncryptedKey = encrypted.toString('base64'); var params = { @@ -56,11 +67,12 @@ function encryptKeyInfo(symmetricKey, options, callback) { options.keyEncryptionDigest = options.keyEncryptionDigest || 'sha1'; switch (options.keyEncryptionAlgorithm) { case 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p': - return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, callback); + // MGF1 is fixed to SHA-1 by this identifier (XML-Enc 1.1 5.5.2). + return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, 'sha1', callback); case 'http://www.w3.org/2001/04/xmlenc#rsa-1_5': utils.warnInsecureAlgorithm(options.keyEncryptionAlgorithm, options.warnInsecureAlgorithm); - return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_PADDING, callback); + return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_PADDING, undefined, callback); default: return callback(new Error('encryption key algorithm not supported')); diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index 2aa40f4..b11abd1 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -246,4 +246,75 @@ describe('rsa-oaep-mgf1p pins MGF1 to sha1', function () { xmlenc.decryptKeyInfo(keyInfo, { key: fs.readFileSync(__dirname + '/test-auth0.key') }); }, /oaep decoding error/); }); + + it('decrypts the external OpenSSL vector through the public API', function () { + // VECTOR from test/oaep.js (originally from repro-oaep-mgf1.cjs): OpenSSL OAEP(sha256)/MGF1(sha1) + var VECTOR_KEY = Buffer.from( + 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQzA5QmY1NTRXR0VxRXYKMmZyUWxOUG9ycWRMbld5RVAyTGlybGwvekZuUHFnK0c5RkdxYnNNb3o3UG1CUDRpZlRhVFRtSkViaUx1ajkyWQpQM3FieU9JUmN6MkFZQXJkZlE3M0RiSXhYaUZsazNObjlvVnRISVJUcHVvZkwzc2FkVlozMGg5c3JVeTZ4N0Z1ClZvL0ErNDJlSEVRNEdhaGJOSjJsMlh2QU5ydGQwUG5jNlc2MS9pVTdQK3Z4WDJyM0Fqb2VLMjNTWVRxbTkxRTkKMlN0WmVKQjJuSm4rSGxaamV6WTVUblhCZy9HRmFCZGNvR1JMb1diYzFsV2Q0SHNYa1BuVExyTW5UL0xiV1pQZQo4QVpyU253R1Fpa3dud245ZjF3K01ZQ1h6QU0yNWE4STkrZXRacEl0cFN5VUVtWE9yMnEzRkVkMG5RUUVzMHNFCmkrbENlaW5oQWdNQkFBRUNnZ0VBRUpDdDV6YytIblZ6SXczSjY3Rk1LdWRlTWtwaGhrUEZPaW9xMEV1MVJ4RHkKNWZCVXo0emZPY3UxMU05Tk1ud1M5RzQvQ2JPcFoveHNsVVR1WlBlQlZvYWRzVFJabWtnYUNCek5YTDZZd1JNOApBOTdwL1FDWXpvMmZyaVlyRjFONWpIT0VZKzhEY0svYU90Y2F4dGhnY1FKMmJrcFBBclp3M2g5b09FTHFhUjZTClJyNDgxSUZtS0JNdmhyVUQxVFU0MG5jWG43MTdvazlxalR4bFNuOElONElxSmVMTDFPTkFTMDlNSkhISTZPdG8KbHRZUjNWc1RFdE9YTGNsQ2ZubU5ZT2xpeVgrL1VoMTZBak0rSlJmOFRNL2lDYjNkUGFxMyt4UUFCL1oxRnZPZAo0UEFpa01LNVFTc09jdHhxYThwbm10MUlLK1N4MEZ0aUIweThzbWdYM3dLQmdRRHIyNmxLMlIwbmRpWVZDK3hLCjB2SmxYZ0FZeG9TU1IxOS9EdEFQbDdNMkFVVFRzblVFNmlpSCtDbU1ZK1k5Qm0wblkrNGsveThNUG9sdHZ5OUsKQ1ZVT21Ka0hFY3IvZmo3WHl2OEdkTTJSeXVQOHFhRVZxUlh5WU9PMzM5OUt0NzBFb3FFWVJsS0MxQllXcTVWcQovRExURXphSEowSHFXSk85QjB5eW00cDJId0tCZ1FERWFCdEZGN0NqbG1lMVVjUXRDZ0pqZnZPVWF6UTQ3WGdxClpkNTZ6emcyWm5vejZjYzkvTE40WnV5OC8rcFRYcS9GL3E5RjZtb1pyYktHVDBlNFg4dlVtQVZxd3NmQ01TOGcKTWk4Ui8zeGRZdG54Ly9IbW5DUmpYYTJTOUoraWU1Wks3RHpZWGl0Vi9yamEwRHhvWG9RMm82TG9URXQ1WVNTdQpFY2dEclNkZi93S0JnQXZ5dk1qRjV1d3cyQTBJNVplRXlETEthRWJaQjY1QlgxMFlhd0hmTlh6dTQ0VzE3S2VyCkZSS09SOHlNNHdVRVpsTXdoTWZyQlg4aFMrVDdZbkhsdHlGZUthSnFERmFWRnFubjVyTjFCMVR6YWtsS2JwYWkKVWpKTkpqd1NZMFZ0dVcyYXIzNkRVWHEvTTc5Q1FmZUJmekdpTDRqNVBDV2JCeUQwVmJaV210VVJBb0dBY3ZncAo5bUR1c21QSm8zY1FxZml3KzBNR0hMeEFYbzZMaCs0SHRNWDJOc24wQU1sNUt3endsYXRTS3pSM0c0UlN5a2pTCm1zK2tlaEdXYms2Y1FnNDVoK0hSVWZSZzhJalArRDNJRmZZQys3dHdydHRPNDlwRTVyR2dlR1NmeVlJa3NRam0KZVJWdXNyRWZ6bDZVN2RkZDk0b0VRNHpkcFZpN0d2WW5xaGRDOUVzQ2dZRUFwSkJ1SEpFZXYwd1lxUStnNERzVwpxZTVQYWJOY1dVaDJrNUswMGwza0lmMlpweVdWRWs5bnhRVzJIOFVmNHNVc0d0VTBvMmUycEtUanJ1WDRWcVR5CitoeWxrOXZEam1MU29CenFEREU4bFdOK1llU3hBYzJ0WnE2TnpmT0FPZGdHOVN1ODI0MDlEcUtuR1RlQzlKKzAKWHJ6Z21WOGp5K013cU9kQXJ2QXJ0MXM9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K', + 'base64' + ).toString('utf8'); + var VECTOR_CT = Buffer.from( + 'fcAWGHe0HIxC3LcLBwwrkts3005XTSznQZTZZU6EiLOSh/fAfPoe0vF60RcK0IYGW1oDUfuwCl3W+C3HOPTRvFGHiI6AfKCKkj8pTna6WuAZP5x4lBdSKxkIoECgBp+GYko2TMlRn6aW0mOhMCw60P1lT5x93blbbYf4nh0reOtODA8VQBCHnS0wu+qFqIzG/x2UgIbrasnlHo45UlbxdfpOYR08ckKZZrltMZrLcoQnTgrwevwafOg9OvfpY9Kw5Aml+aBhdsabr2aQC5quE6nho0ar/QobPmG5+WzEB5eHn59fTQExDdV2KDcyi7E8xACOjkFFWr+VZmf6t1l59Q==', + 'base64' + ); + // Wrap it with mgf1p + sha256 DigestMethod, which the library should decrypt with MGF1-sha1. + var keyInfo = '' + + '' + + '' + + '' + + '' + + '' + VECTOR_CT.toString('base64') + '' + + ''; + var recovered = xmlenc.decryptKeyInfo(keyInfo, { key: VECTOR_KEY }); + assert.equal(recovered.toString('utf8'), 'AES-128-key-1234'); + }); +}); + +describe('rsa-oaep-mgf1p emits MGF1-sha1 ciphertext', function () { + var oaep = require('../lib/oaep'); + var xpath = require('xpath'); + var xmldom = require('@xmldom/xmldom'); + + ['sha256', 'sha512'].forEach(function (digest) { + it('wraps the key with MGF1-sha1 when keyEncryptionDigest is ' + digest, function (done) { + var options = { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP, + keyEncryptionDigest: digest + }; + xmlenc.encrypt('mgf1 sha1 content', options, function (err, result) { + if (err) return done(err); + var doc = new xmldom.DOMParser().parseFromString(result); + var cipherValue = xpath.select("//*[local-name(.)='EncryptedKey']/*[local-name(.)='CipherData']/*[local-name(.)='CipherValue']", doc)[0]; + var wrapped = Buffer.from(cipherValue.textContent, 'base64'); + + // Unwrap with MGF1-sha1: succeeds only if encrypt used the spec MGF. + var withSha1 = oaep.privateDecryptOaep(fs.readFileSync(__dirname + '/test-auth0.key'), wrapped, { oaepHash: digest, mgf1Hash: 'sha1' }); + assert(withSha1.length > 0); + + // And the old non-spec MGF1=digest must no longer parse. + assert.throws(function () { + oaep.privateDecryptOaep(fs.readFileSync(__dirname + '/test-auth0.key'), wrapped, { oaepHash: digest, mgf1Hash: digest }); + }, /oaep decoding error/); + done(); + }); + }); + }); + + it('never emits an MGF element for mgf1p', function (done) { + var options = { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP, + keyEncryptionDigest: 'sha256' + }; + xmlenc.encrypt('x', options, function (err, result) { + if (err) return done(err); + assert(!/MGF/.test(result), 'MGF element must not be present with mgf1p'); + done(); + }); + }); }); From dbddd5454e674915eee33bd04eb99978e0bacec5 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 09:46:35 -0400 Subject: [PATCH 06/14] test: fix flaky MGF assertion and strengthen key length check - Parse XML to detect MGF elements instead of string matching base64 - Pin wrapped key length to 32 bytes (aes256-gcm key size) Co-Authored-By: Claude Opus 5 --- test/xmlenc.digest.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index b11abd1..91e3d36 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -292,7 +292,7 @@ describe('rsa-oaep-mgf1p emits MGF1-sha1 ciphertext', function () { // Unwrap with MGF1-sha1: succeeds only if encrypt used the spec MGF. var withSha1 = oaep.privateDecryptOaep(fs.readFileSync(__dirname + '/test-auth0.key'), wrapped, { oaepHash: digest, mgf1Hash: 'sha1' }); - assert(withSha1.length > 0); + assert.equal(withSha1.length, 32); // And the old non-spec MGF1=digest must no longer parse. assert.throws(function () { @@ -313,7 +313,9 @@ describe('rsa-oaep-mgf1p emits MGF1-sha1 ciphertext', function () { }; xmlenc.encrypt('x', options, function (err, result) { if (err) return done(err); - assert(!/MGF/.test(result), 'MGF element must not be present with mgf1p'); + var doc = new xmldom.DOMParser().parseFromString(result); + var mgf = xpath.select("//*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']/*[local-name(.)='MGF']", doc); + assert.equal(mgf.length, 0, 'MGF element must not be present with mgf1p'); done(); }); }); From 3bcca9bcfe6347edc7fe89a107cc4505d6be6380 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 09:53:43 -0400 Subject: [PATCH 07/14] feat: support xmlenc11#rsa-oaep with an explicit MGF element Adds the XML-Enc 1.1 key transport identifier whose xenc11:MGF child selects the mask generation function, which is how a digest other than SHA-1 for MGF1 is expressed. Unknown MGF URIs are rejected rather than defaulting to SHA-1. Co-Authored-By: Claude Opus 5 --- lib/templates/keyinfo.tpl.xml.js | 15 +++- lib/xmlenc.js | 60 ++++++++++++- test/xmlenc.digest.js | 146 +++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 4 deletions(-) diff --git a/lib/templates/keyinfo.tpl.xml.js b/lib/templates/keyinfo.tpl.xml.js index 9859d5f..8321521 100644 --- a/lib/templates/keyinfo.tpl.xml.js +++ b/lib/templates/keyinfo.tpl.xml.js @@ -7,15 +7,28 @@ const DIGEST_ALGORITHMS = { 'sha512': 'http://www.w3.org/2001/04/xmlenc#sha512' }; -module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, keyEncryptionDigest }) => { +const MGF_ALGORITHMS = { + 'sha1': 'http://www.w3.org/2009/xmlenc11#mgf1sha1', + 'sha224': 'http://www.w3.org/2009/xmlenc11#mgf1sha224', + 'sha256': 'http://www.w3.org/2009/xmlenc11#mgf1sha256', + 'sha384': 'http://www.w3.org/2009/xmlenc11#mgf1sha384', + 'sha512': 'http://www.w3.org/2009/xmlenc11#mgf1sha512' +}; + +module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, keyEncryptionDigest, keyEncryptionMgf }) => { const digestUri = DIGEST_ALGORITHMS[keyEncryptionDigest] || keyEncryptionDigest; // RSA-1.5 doesn't hash the key, so it has no digest or DigestMethod. RSA-OAEP does. const isOAEP = keyEncryptionMethod && keyEncryptionMethod.includes('rsa-oaep'); + // Only xmlenc11#rsa-oaep carries an MGF element. For rsa-oaep-mgf1p the MGF + // is fixed to SHA-1 and the element MUST NOT be present (XML-Enc 1.1 5.5.2). + const isOAEP11 = keyEncryptionMethod === 'http://www.w3.org/2009/xmlenc11#rsa-oaep'; + const mgfUri = MGF_ALGORITHMS[keyEncryptionMgf] || keyEncryptionMgf; return ` + ${isOAEP11 && mgfUri ? `` : ''} ${isOAEP ? `` : ''} diff --git a/lib/xmlenc.js b/lib/xmlenc.js index cd74017..6c250f5 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -14,14 +14,29 @@ const insecureAlgorithms = [ 'http://www.w3.org/2001/04/xmlenc#aes128-cbc', ]; +// XML-Enc 1.1 5.5.2. The normative list uses the xmlenc11#mgf1* URIs; the +// xmlenc#MGF1withSHA1 spelling is accepted on decrypt because Example 33 in +// that same section uses it and implementations copied it. +const MGF_ALGORITHMS = { + 'http://www.w3.org/2009/xmlenc11#mgf1sha1': 'sha1', + 'http://www.w3.org/2009/xmlenc11#mgf1sha224': 'sha224', + 'http://www.w3.org/2009/xmlenc11#mgf1sha256': 'sha256', + 'http://www.w3.org/2009/xmlenc11#mgf1sha384': 'sha384', + 'http://www.w3.org/2009/xmlenc11#mgf1sha512': 'sha512', + 'http://www.w3.org/2001/04/xmlenc#MGF1withSHA1': 'sha1' +}; + function encryptKeyInfoWithScheme(symmetricKey, options, padding, mgf1Hash, callback) { const symmetricKeyBuffer = Buffer.isBuffer(symmetricKey) ? symmetricKey : Buffer.from(symmetricKey, 'utf-8'); try { const isOAEP = padding == crypto.constants.RSA_PKCS1_OAEP_PADDING; const oaepHash = isOAEP ? options.keyEncryptionDigest : undefined; + if (isOAEP && !mgf1Hash) { + return callback(new Error('mgf1Hash is required for OAEP padding')); + } let encrypted; - if (isOAEP && mgf1Hash && mgf1Hash !== oaepHash) { + if (isOAEP && mgf1Hash !== oaepHash) { // Node cannot set MGF1 separately from the OAEP digest. encrypted = oaep.publicEncryptOaep(options.rsa_pub, symmetricKeyBuffer, { oaepHash: oaepHash, @@ -41,6 +56,7 @@ function encryptKeyInfoWithScheme(symmetricKey, options, padding, mgf1Hash, call encryptionPublicCert: '' + utils.pemToCert(options.pem.toString()) + '', keyEncryptionMethod: options.keyEncryptionAlgorithm, keyEncryptionDigest: options.keyEncryptionDigest, + keyEncryptionMgf: mgf1Hash, }; var result = utils.renderTemplate('keyinfo', params); @@ -60,16 +76,36 @@ function encryptKeyInfo(symmetricKey, options, callback) { if (!options.keyEncryptionAlgorithm) return callback(new Error('encryption without encrypted key is not supported yet')); - if (options.disallowEncryptionWithInsecureAlgorithm !== false + if (options.disallowEncryptionWithInsecureAlgorithm !== false && insecureAlgorithms.indexOf(options.keyEncryptionAlgorithm) >= 0) { return callback(new Error('encryption algorithm ' + options.keyEncryptionAlgorithm + 'is not secure')); } options.keyEncryptionDigest = options.keyEncryptionDigest || 'sha1'; + + if (options.keyEncryptionMgf + && options.keyEncryptionAlgorithm !== 'http://www.w3.org/2009/xmlenc11#rsa-oaep') { + return callback(new Error('keyEncryptionMgf is only supported with http://www.w3.org/2009/xmlenc11#rsa-oaep; ' + + options.keyEncryptionAlgorithm + ' fixes the mask generation function')); + } + switch (options.keyEncryptionAlgorithm) { case 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p': // MGF1 is fixed to SHA-1 by this identifier (XML-Enc 1.1 5.5.2). return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, 'sha1', callback); + case 'http://www.w3.org/2009/xmlenc11#rsa-oaep': { + // Normalize keyEncryptionMgf to a short digest name. + let mgf1Hash = options.keyEncryptionMgf || 'sha1'; + if (MGF_ALGORITHMS[mgf1Hash]) { + // It's a full URI, map to short name. + mgf1Hash = MGF_ALGORITHMS[mgf1Hash]; + } else if (!['sha1', 'sha224', 'sha256', 'sha384', 'sha512'].includes(mgf1Hash)) { + // It's neither a known URI nor a valid short name. + return callback(new Error('keyEncryptionMgf value ' + mgf1Hash + ' is not supported')); + } + return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, mgf1Hash, callback); + } + case 'http://www.w3.org/2001/04/xmlenc#rsa-1_5': utils.warnInsecureAlgorithm(options.keyEncryptionAlgorithm, options.warnInsecureAlgorithm); return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_PADDING, undefined, callback); @@ -299,6 +335,21 @@ function decryptKeyInfo(doc, options) { throw new Error('MGF element must not be present with ' + keyEncryptionAlgorithm); } return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash, 'sha1'); + + case 'http://www.w3.org/2009/xmlenc11#rsa-oaep': { + // MGF1 comes from the optional xenc11:MGF child; default MGF1-SHA1. + const mgfElement = xpath.select("./*[local-name(.)='MGF']", keyEncryptionMethod)[0]; + let mgf1Hash = 'sha1'; + if (mgfElement) { + const mgfAlgorithm = mgfElement.getAttribute('Algorithm'); + mgf1Hash = MGF_ALGORITHMS[mgfAlgorithm]; + if (!mgf1Hash) { + throw new Error('mask generation function ' + mgfAlgorithm + ' not supported'); + } + } + return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash, mgf1Hash); + } + case 'http://www.w3.org/2001/04/xmlenc#rsa-1_5': utils.warnInsecureAlgorithm(keyEncryptionAlgorithm, options.warnInsecureAlgorithm); return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_PADDING); @@ -311,7 +362,10 @@ function decryptKeyInfoWithScheme(encryptedKey, options, padding, oaepHash, mgf1 const key = Buffer.from(encryptedKey.textContent, 'base64'); // Node cannot set the MGF1 digest separately, so only fall back to the JS // implementation when it actually differs. Everything else stays in OpenSSL. - if (padding !== crypto.constants.RSA_PKCS1_OAEP_PADDING || !mgf1Hash || mgf1Hash === oaepHash) { + if (padding === crypto.constants.RSA_PKCS1_OAEP_PADDING && !mgf1Hash) { + throw new Error('mgf1Hash is required for OAEP padding'); + } + if (padding !== crypto.constants.RSA_PKCS1_OAEP_PADDING || mgf1Hash === oaepHash) { const decrypted = crypto.privateDecrypt({ key: options.key, padding, oaepHash }, key); return Buffer.from(decrypted, 'binary'); } diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index 91e3d36..54a3b4a 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -319,4 +319,150 @@ describe('rsa-oaep-mgf1p emits MGF1-sha1 ciphertext', function () { done(); }); }); + + it('rejects MGF element when present with mgf1p', function () { + var oaep = require('../lib/oaep'); + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var wrapped = oaep.publicEncryptOaep(pub, Buffer.alloc(32), { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + var keyInfo = '' + + '' + + '' + + '' + + '' + + '' + + '' + wrapped.toString('base64') + '' + + ''; + assert.throws(function () { + xmlenc.decryptKeyInfo(keyInfo, { key: fs.readFileSync(__dirname + '/test-auth0.key') }); + }, /MGF element must not be present/); + }); +}); + +describe('xmlenc11#rsa-oaep with explicit MGF', function () { + var RSA_OAEP_11 = 'http://www.w3.org/2009/xmlenc11#rsa-oaep'; + var oaep = require('../lib/oaep'); + + it('round trips sha256 digest with an explicit mgf1sha256', function (done) { + var options = { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP_11, + keyEncryptionDigest: 'sha256', + keyEncryptionMgf: 'sha256' + }; + xmlenc.encrypt('xmlenc11 content', options, function (err, result) { + if (err) return done(err); + assert(result.includes('http://www.w3.org/2009/xmlenc11#mgf1sha256'), 'expected MGF element'); + xmlenc.decrypt(result, { key: fs.readFileSync(__dirname + '/test-auth0.key') }, function (err2, decrypted) { + if (err2) return done(err2); + assert.equal(decrypted, 'xmlenc11 content'); + done(); + }); + }); + }); + + it('round trips sha256 digest with mgf1sha1 (the default)', function (done) { + var options = { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP_11, + keyEncryptionDigest: 'sha256' + }; + xmlenc.encrypt('default mgf', options, function (err, result) { + if (err) return done(err); + assert(result.includes('http://www.w3.org/2009/xmlenc11#mgf1sha1')); + xmlenc.decrypt(result, { key: fs.readFileSync(__dirname + '/test-auth0.key') }, function (err2, decrypted) { + if (err2) return done(err2); + assert.equal(decrypted, 'default mgf'); + done(); + }); + }); + }); + + it('rejects an unknown MGF URI rather than defaulting to sha1', function () { + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var wrapped = oaep.publicEncryptOaep(pub, Buffer.alloc(32), { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + var keyInfo = '' + + '' + + '' + + '' + + '' + + '' + + '' + wrapped.toString('base64') + '' + + ''; + assert.throws(function () { + xmlenc.decryptKeyInfo(keyInfo, { key: fs.readFileSync(__dirname + '/test-auth0.key') }); + }, /mask generation function/); + }); + + it('accepts the MGF1withSHA1 spelling from spec Example 33', function () { + // 5.5.2's normative list says xmlenc11#mgf1sha1, but Example 33 in the same + // section writes xmlenc#MGF1withSHA1. Implementations copied the example. + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var sym = require('crypto').randomBytes(32); + var wrapped = oaep.publicEncryptOaep(pub, sym, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + var keyInfo = '' + + '' + + '' + + '' + + '' + + '' + + '' + wrapped.toString('base64') + '' + + ''; + var recovered = xmlenc.decryptKeyInfo(keyInfo, { key: fs.readFileSync(__dirname + '/test-auth0.key') }); + assert.equal(Buffer.compare(Buffer.from(recovered), sym), 0); + }); + + it('rejects keyEncryptionMgf under mgf1p instead of silently ignoring it', function (done) { + xmlenc.encrypt('x', { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP, + keyEncryptionDigest: 'sha256', + keyEncryptionMgf: 'sha256' + }, function (err) { + assert(err, 'expected an error'); + assert(/keyEncryptionMgf/.test(err.message)); + done(); + }); + }); + + it('accepts keyEncryptionMgf as a full MGF URI', function (done) { + var options = { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP_11, + keyEncryptionDigest: 'sha256', + keyEncryptionMgf: 'http://www.w3.org/2009/xmlenc11#mgf1sha256' + }; + xmlenc.encrypt('uri form', options, function (err, result) { + if (err) return done(err); + assert(result.includes('http://www.w3.org/2009/xmlenc11#mgf1sha256')); + xmlenc.decrypt(result, { key: fs.readFileSync(__dirname + '/test-auth0.key') }, function (err2, decrypted) { + if (err2) return done(err2); + assert.equal(decrypted, 'uri form'); + done(); + }); + }); + }); + + it('rejects an unsupported keyEncryptionMgf value', function (done) { + xmlenc.encrypt('x', { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP_11, + keyEncryptionDigest: 'sha256', + keyEncryptionMgf: 'md5' + }, function (err) { + assert(err, 'expected an error'); + assert(/keyEncryptionMgf/.test(err.message)); + assert(/md5/.test(err.message)); + done(); + }); + }); }); From de54d5e6b3441277711a231dfdca7404999017e1 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 10:12:04 -0400 Subject: [PATCH 08/14] feat: honour OAEPparams as the RSA-OAEP label XML-Enc 1.1 5.5.2 permits OAEPparams as the base64 PSourceAlgorithm value. Documents carrying one previously failed with an opaque OAEP decoding error. Co-Authored-By: Claude Opus 5 --- lib/templates/keyinfo.tpl.xml.js | 11 ++--- lib/xmlenc.js | 42 ++++++++++++------ test/xmlenc.digest.js | 73 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/lib/templates/keyinfo.tpl.xml.js b/lib/templates/keyinfo.tpl.xml.js index 8321521..8d8172d 100644 --- a/lib/templates/keyinfo.tpl.xml.js +++ b/lib/templates/keyinfo.tpl.xml.js @@ -1,21 +1,21 @@ var escapehtml = require('escape-html'); -const DIGEST_ALGORITHMS = { +const DIGEST_ALGORITHMS = Object.assign(Object.create(null), { // SHA-2 was published after 2000/09/xmldsig was locked, so sha256/sha512 live under 2001/04/xmlenc. 'sha1': 'http://www.w3.org/2000/09/xmldsig#sha1', 'sha256': 'http://www.w3.org/2001/04/xmlenc#sha256', 'sha512': 'http://www.w3.org/2001/04/xmlenc#sha512' -}; +}); -const MGF_ALGORITHMS = { +const MGF_ALGORITHMS = Object.assign(Object.create(null), { 'sha1': 'http://www.w3.org/2009/xmlenc11#mgf1sha1', 'sha224': 'http://www.w3.org/2009/xmlenc11#mgf1sha224', 'sha256': 'http://www.w3.org/2009/xmlenc11#mgf1sha256', 'sha384': 'http://www.w3.org/2009/xmlenc11#mgf1sha384', 'sha512': 'http://www.w3.org/2009/xmlenc11#mgf1sha512' -}; +}); -module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, keyEncryptionDigest, keyEncryptionMgf }) => { +module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, keyEncryptionDigest, keyEncryptionMgf, keyEncryptionOaepParams }) => { const digestUri = DIGEST_ALGORITHMS[keyEncryptionDigest] || keyEncryptionDigest; // RSA-1.5 doesn't hash the key, so it has no digest or DigestMethod. RSA-OAEP does. @@ -28,6 +28,7 @@ module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, key + ${isOAEP && keyEncryptionOaepParams ? `${escapehtml(keyEncryptionOaepParams)}` : ''} ${isOAEP11 && mgfUri ? `` : ''} ${isOAEP ? `` : ''} diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 6c250f5..9f3457b 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -17,14 +17,15 @@ const insecureAlgorithms = [ // XML-Enc 1.1 5.5.2. The normative list uses the xmlenc11#mgf1* URIs; the // xmlenc#MGF1withSHA1 spelling is accepted on decrypt because Example 33 in // that same section uses it and implementations copied it. -const MGF_ALGORITHMS = { +const MGF_ALGORITHMS = Object.assign(Object.create(null), { 'http://www.w3.org/2009/xmlenc11#mgf1sha1': 'sha1', 'http://www.w3.org/2009/xmlenc11#mgf1sha224': 'sha224', 'http://www.w3.org/2009/xmlenc11#mgf1sha256': 'sha256', 'http://www.w3.org/2009/xmlenc11#mgf1sha384': 'sha384', 'http://www.w3.org/2009/xmlenc11#mgf1sha512': 'sha512', 'http://www.w3.org/2001/04/xmlenc#MGF1withSHA1': 'sha1' -}; +}); +const MGF_SHORT_NAMES = Object.values(MGF_ALGORITHMS); function encryptKeyInfoWithScheme(symmetricKey, options, padding, mgf1Hash, callback) { const symmetricKeyBuffer = Buffer.isBuffer(symmetricKey) ? symmetricKey : Buffer.from(symmetricKey, 'utf-8'); @@ -32,15 +33,21 @@ function encryptKeyInfoWithScheme(symmetricKey, options, padding, mgf1Hash, call try { const isOAEP = padding == crypto.constants.RSA_PKCS1_OAEP_PADDING; const oaepHash = isOAEP ? options.keyEncryptionDigest : undefined; + const oaepLabel = options.keyEncryptionOaepParams + ? (Buffer.isBuffer(options.keyEncryptionOaepParams) + ? options.keyEncryptionOaepParams + : Buffer.from(options.keyEncryptionOaepParams, 'base64')) + : Buffer.alloc(0); if (isOAEP && !mgf1Hash) { return callback(new Error('mgf1Hash is required for OAEP padding')); } let encrypted; - if (isOAEP && mgf1Hash !== oaepHash) { - // Node cannot set MGF1 separately from the OAEP digest. + if (isOAEP && (mgf1Hash !== oaepHash || oaepLabel.length > 0)) { + // Node cannot set MGF1 separately from the OAEP digest, and has no label option. encrypted = oaep.publicEncryptOaep(options.rsa_pub, symmetricKeyBuffer, { oaepHash: oaepHash, - mgf1Hash: mgf1Hash + mgf1Hash: mgf1Hash, + oaepLabel: oaepLabel }); } else { encrypted = crypto.publicEncrypt({ @@ -57,6 +64,7 @@ function encryptKeyInfoWithScheme(symmetricKey, options, padding, mgf1Hash, call keyEncryptionMethod: options.keyEncryptionAlgorithm, keyEncryptionDigest: options.keyEncryptionDigest, keyEncryptionMgf: mgf1Hash, + keyEncryptionOaepParams: oaepLabel.length ? oaepLabel.toString('base64') : null, }; var result = utils.renderTemplate('keyinfo', params); @@ -99,7 +107,7 @@ function encryptKeyInfo(symmetricKey, options, callback) { if (MGF_ALGORITHMS[mgf1Hash]) { // It's a full URI, map to short name. mgf1Hash = MGF_ALGORITHMS[mgf1Hash]; - } else if (!['sha1', 'sha224', 'sha256', 'sha384', 'sha512'].includes(mgf1Hash)) { + } else if (!MGF_SHORT_NAMES.includes(mgf1Hash)) { // It's neither a known URI nor a valid short name. return callback(new Error('keyEncryptionMgf value ' + mgf1Hash + ' is not supported')); } @@ -319,7 +327,7 @@ function decryptKeyInfo(doc, options) { } var keyEncryptionAlgorithm = keyEncryptionMethod.getAttribute('Algorithm'); - if (options.disallowDecryptionWithInsecureAlgorithm !== false + if (options.disallowDecryptionWithInsecureAlgorithm !== false && insecureAlgorithms.indexOf(keyEncryptionAlgorithm) >= 0) { throw new Error('encryption algorithm ' + keyEncryptionAlgorithm + ' is not secure, fail to decrypt'); } @@ -327,6 +335,10 @@ function decryptKeyInfo(doc, options) { xpath.select("//*[local-name(.)='EncryptedKey' and @Id='" + keyRetrievalMethodUri.substring(1) + "']/*[local-name(.)='CipherData']/*[local-name(.)='CipherValue']", keyInfo)[0] : xpath.select("//*[local-name(.)='CipherValue']", keyInfo)[0]; + // Read the OAEP label from the optional OAEPparams element (XML-Enc 1.1 5.5.2). + const oaepParams = xpath.select("./*[local-name(.)='OAEPparams']", keyEncryptionMethod)[0]; + const oaepLabel = oaepParams ? Buffer.from(oaepParams.textContent, 'base64') : Buffer.alloc(0); + switch (keyEncryptionAlgorithm) { case 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p': // The identifier fixes MGF1 to SHA-1 (XML-Enc 1.1 5.5.2); DigestMethod @@ -334,7 +346,7 @@ function decryptKeyInfo(doc, options) { if (xpath.select("./*[local-name(.)='MGF']", keyEncryptionMethod)[0]) { throw new Error('MGF element must not be present with ' + keyEncryptionAlgorithm); } - return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash, 'sha1'); + return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash, 'sha1', oaepLabel); case 'http://www.w3.org/2009/xmlenc11#rsa-oaep': { // MGF1 comes from the optional xenc11:MGF child; default MGF1-SHA1. @@ -347,7 +359,7 @@ function decryptKeyInfo(doc, options) { throw new Error('mask generation function ' + mgfAlgorithm + ' not supported'); } } - return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash, mgf1Hash); + return decryptKeyInfoWithScheme(encryptedKey, options, crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash, mgf1Hash, oaepLabel); } case 'http://www.w3.org/2001/04/xmlenc#rsa-1_5': @@ -358,18 +370,20 @@ function decryptKeyInfo(doc, options) { } } -function decryptKeyInfoWithScheme(encryptedKey, options, padding, oaepHash, mgf1Hash) { +function decryptKeyInfoWithScheme(encryptedKey, options, padding, oaepHash, mgf1Hash, oaepLabel) { const key = Buffer.from(encryptedKey.textContent, 'base64'); - // Node cannot set the MGF1 digest separately, so only fall back to the JS - // implementation when it actually differs. Everything else stays in OpenSSL. + const label = oaepLabel || Buffer.alloc(0); if (padding === crypto.constants.RSA_PKCS1_OAEP_PADDING && !mgf1Hash) { throw new Error('mgf1Hash is required for OAEP padding'); } - if (padding !== crypto.constants.RSA_PKCS1_OAEP_PADDING || mgf1Hash === oaepHash) { + // Node's privateDecrypt has no label option, so a non-empty label also needs the shim. + const needsShim = padding === crypto.constants.RSA_PKCS1_OAEP_PADDING + && (mgf1Hash !== oaepHash || label.length > 0); + if (!needsShim) { const decrypted = crypto.privateDecrypt({ key: options.key, padding, oaepHash }, key); return Buffer.from(decrypted, 'binary'); } - return oaep.privateDecryptOaep(options.key, key, { oaepHash, mgf1Hash }); + return oaep.privateDecryptOaep(options.key, key, { oaepHash, mgf1Hash, oaepLabel: label }); } function encryptWithAlgorithm(algorithm, symmetricKey, ivLength, content, encoding, callback) { diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index 54a3b4a..bb937bf 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -465,4 +465,77 @@ describe('xmlenc11#rsa-oaep with explicit MGF', function () { done(); }); }); + + it('rejects MGF with "constructor" to avoid prototype pollution', function (done) { + xmlenc.encrypt('x', { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP_11, + keyEncryptionDigest: 'sha256', + keyEncryptionMgf: 'constructor' + }, function (err) { + assert(err, 'expected an error'); + assert(/keyEncryptionMgf/.test(err.message)); + done(); + }); + }); + + it('rejects on decrypt', function () { + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var wrapped = oaep.publicEncryptOaep(pub, Buffer.alloc(32), { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + var keyInfo = '' + + '' + + '' + + '' + + '' + + '' + + '' + wrapped.toString('base64') + '' + + ''; + assert.throws(function () { + xmlenc.decryptKeyInfo(keyInfo, { key: fs.readFileSync(__dirname + '/test-auth0.key') }); + }, /mask generation function/); + }); +}); + +describe('OAEPparams', function () { + var oaep = require('../lib/oaep'); + var crypto = require('crypto'); + + it('decrypts a key wrapped with a non-empty OAEP label', function () { + var label = Buffer.from('MYLABEL'); + var sym = crypto.randomBytes(32); + var wrapped = oaep.publicEncryptOaep(fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), sym, { + oaepHash: 'sha256', mgf1Hash: 'sha1', oaepLabel: label + }); + var keyInfo = '' + + '' + + '' + + '' + label.toString('base64') + '' + + '' + + '' + + '' + wrapped.toString('base64') + '' + + ''; + var recovered = xmlenc.decryptKeyInfo(keyInfo, { key: fs.readFileSync(__dirname + '/test-auth0.key') }); + assert.equal(Buffer.compare(Buffer.from(recovered), sym), 0); + }); + + it('round trips keyEncryptionOaepParams through encrypt and decrypt', function (done) { + xmlenc.encrypt('labelled content', { + rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), + pem: fs.readFileSync(__dirname + '/test-auth0.pem'), + encryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + keyEncryptionAlgorithm: RSA_OAEP, + keyEncryptionDigest: 'sha256', + keyEncryptionOaepParams: Buffer.from('9lWu3Q==', 'base64') + }, function (err, result) { + if (err) return done(err); + assert(result.includes('9lWu3Q==')); + xmlenc.decrypt(result, { key: fs.readFileSync(__dirname + '/test-auth0.key') }, function (err2, decrypted) { + if (err2) return done(err2); + assert.equal(decrypted, 'labelled content'); + done(); + }); + }); + }); }); From dada365aadb17a8eff6f3804552f4ee6e3f1bf4d Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 10:23:43 -0400 Subject: [PATCH 09/14] docs: document MGF1 semantics and the xmlenc11#rsa-oaep identifier Co-Authored-By: Claude Opus 5 --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index aec498a..e5c95af 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Currently the library supports: * EncryptedKey to transport symmetric key using: * http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p + * http://www.w3.org/2009/xmlenc11#rsa-oaep * http://www.w3.org/2001/04/xmlenc#rsa-1_5 (Insecure Algorithm) * EncryptedData using: @@ -92,6 +93,30 @@ We recommend usage of AES-256-GCM (Galois/Counter Mode) for the strongest securi Note that `xml-encryption` versions prior to 4.0 supported AES-128-CBC and AES-256-CBC as secure algorithms. In version 4.0 onwards, these are treated as insecure because they use the Cipher Block Chaining (CBC) mode of encryption, which does not provide integrity guarantees. To continue using AES128-CBC and AES256-CBC, enable support for insecure algorithms via `disallowEncryptionWithInsecureAlgorithm/disallowDecryptionWithInsecureAlgorithm`. +### RSA-OAEP mask generation (MGF1) + +`http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p` fixes the mask generation function to **MGF1 with SHA-1**, per [XML Encryption 1.1 §5.5.2][xmlenc-oaep]. `keyEncryptionDigest` selects only the OAEP message digest, so `keyEncryptionDigest: 'sha256'` means OAEP-SHA256 with MGF1-SHA1. + +To use a different MGF1 digest, use the XML Encryption 1.1 identifier, which carries an explicit `` element: + +~~~js +var options = { + keyEncryptionAlgorithm: 'http://www.w3.org/2009/xmlenc11#rsa-oaep', + keyEncryptionDigest: 'sha256', + keyEncryptionMgf: 'sha256' // sha1 | sha224 | sha256 | sha384 | sha512, default sha1 +}; +~~~ + +`keyEncryptionMgf` accepts either a short digest name (`sha1`, `sha224`, `sha256`, `sha384`, or `sha512`) or a full `http://www.w3.org/2009/xmlenc11#mgf1*` URI. It is rejected with `rsa-oaep-mgf1p`, which has no valid MGF other than SHA-1. + +An optional OAEP label may be supplied as `keyEncryptionOaepParams` (a Buffer or a base64 string); it is emitted as `` and honoured on decrypt. + +Note: for the digest/MGF1 combinations Node's `crypto` cannot express, the OAEP padding is computed in JavaScript over the raw RSA primitive. That code path cannot offer the constant-time guarantees of OpenSSL's C implementation. It is used only when the MGF1 digest differs from the message digest or a label is present; all other combinations go through `crypto.privateDecrypt` unchanged. + +**Breaking change:** in versions 3.1.0 through 5.0.0, `rsa-oaep-mgf1p` with `keyEncryptionDigest: 'sha256'` or `'sha512'` produced ciphertext using MGF1-SHA256 or MGF1-SHA512, which was never compliant with the W3C specification. Starting in 5.1.0, `rsa-oaep-mgf1p` correctly produces MGF1-SHA1 ciphertext regardless of `keyEncryptionDigest`. Documents encrypted with the non-compliant behaviour will not decrypt with the current version. Such documents were never interoperable with Java xmlsec, .NET System.Security.Cryptography.Xml, or other spec-compliant peers. Callers who genuinely need MGF1-SHA256 or MGF1-SHA512 should use `http://www.w3.org/2009/xmlenc11#rsa-oaep` with the `keyEncryptionMgf` option. + +[xmlenc-oaep]: https://www.w3.org/TR/xmlenc-core1/#sec-RSA-OAEP + ### Allow listing specific algorithms when decrypting If decrypting with `disallowEncryptionWithInsecureAlgorithm: true`, you may wish to only support a subset of insecure algorithms (for example, supporting AES-256-CBC only). This can be achieved by extracting the encryption algorithm using the following code and applying validation as required. From 8c311749e6e4acf9a19ec23b5c80fbf3b68b5fa1 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 10:25:05 -0400 Subject: [PATCH 10/14] docs: drop invented version number from the MGF1 breaking-change note semantic-release derives the version from the commit history; naming 5.1.0 in prose was both unverifiable and wrong for a breaking change. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e5c95af..6ea22ed 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ An optional OAEP label may be supplied as `keyEncryptionOaepParams` (a Buffer or Note: for the digest/MGF1 combinations Node's `crypto` cannot express, the OAEP padding is computed in JavaScript over the raw RSA primitive. That code path cannot offer the constant-time guarantees of OpenSSL's C implementation. It is used only when the MGF1 digest differs from the message digest or a label is present; all other combinations go through `crypto.privateDecrypt` unchanged. -**Breaking change:** in versions 3.1.0 through 5.0.0, `rsa-oaep-mgf1p` with `keyEncryptionDigest: 'sha256'` or `'sha512'` produced ciphertext using MGF1-SHA256 or MGF1-SHA512, which was never compliant with the W3C specification. Starting in 5.1.0, `rsa-oaep-mgf1p` correctly produces MGF1-SHA1 ciphertext regardless of `keyEncryptionDigest`. Documents encrypted with the non-compliant behaviour will not decrypt with the current version. Such documents were never interoperable with Java xmlsec, .NET System.Security.Cryptography.Xml, or other spec-compliant peers. Callers who genuinely need MGF1-SHA256 or MGF1-SHA512 should use `http://www.w3.org/2009/xmlenc11#rsa-oaep` with the `keyEncryptionMgf` option. +**Breaking change:** in versions 3.1.0 through 5.0.0, `rsa-oaep-mgf1p` with `keyEncryptionDigest: 'sha256'` or `'sha512'` produced ciphertext using MGF1-SHA256 or MGF1-SHA512, which was never compliant with the W3C specification. `rsa-oaep-mgf1p` now produces MGF1-SHA1 ciphertext regardless of `keyEncryptionDigest`. Documents encrypted with the earlier behaviour will not decrypt with the current version; they were never interoperable with Java xmlsec, .NET `System.Security.Cryptography.Xml`, or other spec-compliant peers. Callers who genuinely need MGF1-SHA256 or MGF1-SHA512 should use `http://www.w3.org/2009/xmlenc11#rsa-oaep` with the `keyEncryptionMgf` option. [xmlenc-oaep]: https://www.w3.org/TR/xmlenc-core1/#sec-RSA-OAEP From ca4f33ef4d7f79cf6c9106445ff28bc2dc657adc Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 11:26:40 -0400 Subject: [PATCH 11/14] fix: correct OAEPparams namespace and derive MGF map single-source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAEPparams now emits as e:OAEPparams (xenc namespace), not bare OAEPparams (inherited xmldsig namespace). Schema-validating peers require it in xenc per EncryptionMethodType. - Test updated to assert namespaceURI and element ordering via DOM. - MGF_ALGORITHMS now defined once in lib/mgf-algorithms.js. Template derives short→URI map, ensuring sha1 emits xmlenc11#mgf1sha1 (not the legacy alias). Drift now caught: unmapped short names throw instead of emitting bare Algorithm="name". - Removed || keyEncryptionMgf fallback that silently serialized non-URIs. Unmapped values now fail loudly. Co-Authored-By: Claude Opus 5 --- lib/mgf-algorithms.js | 27 +++++++++++++++++++++++++++ lib/templates/keyinfo.tpl.xml.js | 16 ++++++---------- lib/xmlenc.js | 14 +------------- test/xmlenc.digest.js | 15 ++++++++++++++- 4 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 lib/mgf-algorithms.js diff --git a/lib/mgf-algorithms.js b/lib/mgf-algorithms.js new file mode 100644 index 0000000..790517a --- /dev/null +++ b/lib/mgf-algorithms.js @@ -0,0 +1,27 @@ +// Canonical MGF URI → short-name map. XML-Enc 1.1 5.5.2. The normative list +// uses the xmlenc11#mgf1* URIs; the xmlenc#MGF1withSHA1 spelling is accepted +// on decrypt because Example 33 in that same section uses it and implementations +// copied it. +const MGF_ALGORITHMS = Object.assign(Object.create(null), { + 'http://www.w3.org/2009/xmlenc11#mgf1sha1': 'sha1', + 'http://www.w3.org/2009/xmlenc11#mgf1sha224': 'sha224', + 'http://www.w3.org/2009/xmlenc11#mgf1sha256': 'sha256', + 'http://www.w3.org/2009/xmlenc11#mgf1sha384': 'sha384', + 'http://www.w3.org/2009/xmlenc11#mgf1sha512': 'sha512', + 'http://www.w3.org/2001/04/xmlenc#MGF1withSHA1': 'sha1' +}); + +const MGF_SHORT_NAMES = Object.values(MGF_ALGORITHMS); + +// Derive short-name → URI map for emit, excluding the legacy alias. +// xmlenc11#mgf1sha1 must win over xmlenc#MGF1withSHA1 for sha1. +const MGF_URI_FOR_EMIT = Object.assign(Object.create(null), {}); +for (const [uri, shortName] of Object.entries(MGF_ALGORITHMS)) { + // Only set if not already present (first occurrence wins). + // The xmlenc11#mgf1sha1 entry comes before the legacy entry, so it wins. + if (!MGF_URI_FOR_EMIT[shortName]) { + MGF_URI_FOR_EMIT[shortName] = uri; + } +} + +module.exports = { MGF_ALGORITHMS, MGF_SHORT_NAMES, MGF_URI_FOR_EMIT }; diff --git a/lib/templates/keyinfo.tpl.xml.js b/lib/templates/keyinfo.tpl.xml.js index 8d8172d..44ab87f 100644 --- a/lib/templates/keyinfo.tpl.xml.js +++ b/lib/templates/keyinfo.tpl.xml.js @@ -1,4 +1,5 @@ var escapehtml = require('escape-html'); +var { MGF_URI_FOR_EMIT } = require('../mgf-algorithms'); const DIGEST_ALGORITHMS = Object.assign(Object.create(null), { // SHA-2 was published after 2000/09/xmldsig was locked, so sha256/sha512 live under 2001/04/xmlenc. @@ -7,14 +8,6 @@ const DIGEST_ALGORITHMS = Object.assign(Object.create(null), { 'sha512': 'http://www.w3.org/2001/04/xmlenc#sha512' }); -const MGF_ALGORITHMS = Object.assign(Object.create(null), { - 'sha1': 'http://www.w3.org/2009/xmlenc11#mgf1sha1', - 'sha224': 'http://www.w3.org/2009/xmlenc11#mgf1sha224', - 'sha256': 'http://www.w3.org/2009/xmlenc11#mgf1sha256', - 'sha384': 'http://www.w3.org/2009/xmlenc11#mgf1sha384', - 'sha512': 'http://www.w3.org/2009/xmlenc11#mgf1sha512' -}); - module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, keyEncryptionDigest, keyEncryptionMgf, keyEncryptionOaepParams }) => { const digestUri = DIGEST_ALGORITHMS[keyEncryptionDigest] || keyEncryptionDigest; @@ -23,12 +16,15 @@ module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, key // Only xmlenc11#rsa-oaep carries an MGF element. For rsa-oaep-mgf1p the MGF // is fixed to SHA-1 and the element MUST NOT be present (XML-Enc 1.1 5.5.2). const isOAEP11 = keyEncryptionMethod === 'http://www.w3.org/2009/xmlenc11#rsa-oaep'; - const mgfUri = MGF_ALGORITHMS[keyEncryptionMgf] || keyEncryptionMgf; + const mgfUri = MGF_URI_FOR_EMIT[keyEncryptionMgf]; + if (isOAEP11 && keyEncryptionMgf && !mgfUri) { + throw new Error('keyEncryptionMgf value ' + keyEncryptionMgf + ' is not a known short name'); + } return ` - ${isOAEP && keyEncryptionOaepParams ? `${escapehtml(keyEncryptionOaepParams)}` : ''} + ${isOAEP && keyEncryptionOaepParams ? `${escapehtml(keyEncryptionOaepParams)}` : ''} ${isOAEP11 && mgfUri ? `` : ''} ${isOAEP ? `` : ''} diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 9f3457b..7312c20 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -3,6 +3,7 @@ var xmldom = require('@xmldom/xmldom'); var xpath = require('xpath'); var utils = require('./utils'); var oaep = require('./oaep'); +var { MGF_ALGORITHMS, MGF_SHORT_NAMES } = require('./mgf-algorithms'); const insecureAlgorithms = [ //https://www.w3.org/TR/xmlenc-core1/#rsav15note @@ -14,19 +15,6 @@ const insecureAlgorithms = [ 'http://www.w3.org/2001/04/xmlenc#aes128-cbc', ]; -// XML-Enc 1.1 5.5.2. The normative list uses the xmlenc11#mgf1* URIs; the -// xmlenc#MGF1withSHA1 spelling is accepted on decrypt because Example 33 in -// that same section uses it and implementations copied it. -const MGF_ALGORITHMS = Object.assign(Object.create(null), { - 'http://www.w3.org/2009/xmlenc11#mgf1sha1': 'sha1', - 'http://www.w3.org/2009/xmlenc11#mgf1sha224': 'sha224', - 'http://www.w3.org/2009/xmlenc11#mgf1sha256': 'sha256', - 'http://www.w3.org/2009/xmlenc11#mgf1sha384': 'sha384', - 'http://www.w3.org/2009/xmlenc11#mgf1sha512': 'sha512', - 'http://www.w3.org/2001/04/xmlenc#MGF1withSHA1': 'sha1' -}); -const MGF_SHORT_NAMES = Object.values(MGF_ALGORITHMS); - function encryptKeyInfoWithScheme(symmetricKey, options, padding, mgf1Hash, callback) { const symmetricKeyBuffer = Buffer.isBuffer(symmetricKey) ? symmetricKey : Buffer.from(symmetricKey, 'utf-8'); diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index bb937bf..e3a5a63 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -521,6 +521,8 @@ describe('OAEPparams', function () { }); it('round trips keyEncryptionOaepParams through encrypt and decrypt', function (done) { + var xmldom = require('@xmldom/xmldom'); + var xpath = require('xpath'); xmlenc.encrypt('labelled content', { rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), pem: fs.readFileSync(__dirname + '/test-auth0.pem'), @@ -530,7 +532,18 @@ describe('OAEPparams', function () { keyEncryptionOaepParams: Buffer.from('9lWu3Q==', 'base64') }, function (err, result) { if (err) return done(err); - assert(result.includes('9lWu3Q==')); + // OAEPparams must be in the xenc namespace, not xmldsig, and appear before MGF and DigestMethod. + var doc = new xmldom.DOMParser().parseFromString(result); + var encMethod = xpath.select("//*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']", doc)[0]; + var params = xpath.select("*[local-name(.)='OAEPparams']", encMethod); + assert.equal(params.length, 1, 'OAEPparams element must be present'); + assert.equal(params[0].namespaceURI, 'http://www.w3.org/2001/04/xmlenc#', 'OAEPparams must be in xenc namespace'); + assert.equal(params[0].textContent, '9lWu3Q==', 'OAEPparams value must match'); + // Verify element ordering: OAEPparams comes before DigestMethod + var children = Array.from(encMethod.childNodes).filter(function (n) { return n.nodeType === 1; }); + var oaepIdx = children.findIndex(function (n) { return n.localName === 'OAEPparams'; }); + var digestIdx = children.findIndex(function (n) { return n.localName === 'DigestMethod'; }); + assert(oaepIdx >= 0 && digestIdx >= 0 && oaepIdx < digestIdx, 'OAEPparams must come before DigestMethod'); xmlenc.decrypt(result, { key: fs.readFileSync(__dirname + '/test-auth0.key') }, function (err2, decrypted) { if (err2) return done(err2); assert.equal(decrypted, 'labelled content'); From eff713eb952d72ea4a03318778ac4d6c85693168 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 11:27:45 -0400 Subject: [PATCH 12/14] test: pin OAEP seed randomness property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two tests to verify publicEncryptOaep randomizes the OAEP seed: 1. Twenty encryptions of identical plaintext produce distinct ciphertexts. 2. The maskedSeed itself varies (extracted via RSA_NO_PADDING decrypt). This pins IND-CPA security — a constant seed would make OAEP deterministic and leak plaintext equality. The mutation test confirms: replacing crypto.randomBytes(hLen) with Buffer.alloc(hLen) drops the count from 100 passing to 98 passing (2 failing), and restoring it returns to 100. Co-Authored-By: Claude Opus 5 --- test/oaep.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/oaep.js b/test/oaep.js index 4941f46..a259d4a 100644 --- a/test/oaep.js +++ b/test/oaep.js @@ -205,5 +205,40 @@ describe('oaep', function () { oaep.privateDecryptOaep(key, ct, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); }, /oaep decoding error/); }); + + it('produces distinct ciphertexts for identical plaintexts (randomized seed)', function () { + var msg = Buffer.from('same message'); + var ciphertexts = []; + for (var i = 0; i < 20; i++) { + ciphertexts.push(oaep.publicEncryptOaep(pub, msg, { oaepHash: 'sha256', mgf1Hash: 'sha1' })); + } + // All ciphertexts should be distinct. + for (var j = 0; j < ciphertexts.length; j++) { + for (var k = j + 1; k < ciphertexts.length; k++) { + assert.notEqual(Buffer.compare(ciphertexts[j], ciphertexts[k]), 0, + 'ciphertext ' + j + ' and ' + k + ' should differ'); + } + } + }); + + it('randomizes the padding seed itself (not just RSA randomness)', function () { + var msg = Buffer.from('test'); + var seeds = []; + var hLen = crypto.createHash('sha256').digest().length; + for (var i = 0; i < 20; i++) { + var ct = oaep.publicEncryptOaep(pub, msg, { oaepHash: 'sha256', mgf1Hash: 'sha1' }); + // Recover EM = 0x00 || maskedSeed || maskedDB via raw RSA decrypt (no padding). + var em = crypto.privateDecrypt({ key: key, padding: crypto.constants.RSA_NO_PADDING }, ct); + var maskedSeed = em.subarray(1, 1 + hLen); + seeds.push(maskedSeed); + } + // All maskedSeed values should be distinct. + for (var j = 0; j < seeds.length; j++) { + for (var k = j + 1; k < seeds.length; k++) { + assert.notEqual(Buffer.compare(seeds[j], seeds[k]), 0, + 'maskedSeed ' + j + ' and ' + k + ' should differ'); + } + } + }); }); }); From 7e94135a2526f617cba11b8c1529505ca4cd4e43 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Fri, 31 Jul 2026 11:40:11 -0400 Subject: [PATCH 13/14] refactor: separate the legacy MGF alias from the canonical list MGF_URI_FOR_EMIT was derived from a map that also held the decrypt-only xmlenc#MGF1withSHA1 alias, with first-occurrence-wins deciding which URI sha1 emits. Reordering the literal would have silently started emitting a non-normative URI with every test still green. The emit map now derives from the canonical 5.5.2 list alone, and a test pins that every emitted value is an xmlenc11#mgf1* URI. Co-Authored-By: Claude Opus 5 --- lib/mgf-algorithms.js | 39 +++++++++++++++++++++++---------------- test/xmlenc.digest.js | 13 +++++++++++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/lib/mgf-algorithms.js b/lib/mgf-algorithms.js index 790517a..32a21bb 100644 --- a/lib/mgf-algorithms.js +++ b/lib/mgf-algorithms.js @@ -1,27 +1,34 @@ -// Canonical MGF URI → short-name map. XML-Enc 1.1 5.5.2. The normative list -// uses the xmlenc11#mgf1* URIs; the xmlenc#MGF1withSHA1 spelling is accepted -// on decrypt because Example 33 in that same section uses it and implementations -// copied it. -const MGF_ALGORITHMS = Object.assign(Object.create(null), { +// MGF URI ↔ short-name maps. XML-Enc 1.1 5.5.2. +// +// MGF_CANONICAL is the normative list, and the only source for what we emit. +// MGF_LEGACY_ALIASES is accepted on decrypt only: the xmlenc#MGF1withSHA1 +// spelling appears in Example 33 of that same section and implementations +// copied it, but it is not in the normative list and must never be emitted. +// Keeping the two separate means reordering either literal cannot change what +// we emit. +const MGF_CANONICAL = Object.assign(Object.create(null), { 'http://www.w3.org/2009/xmlenc11#mgf1sha1': 'sha1', 'http://www.w3.org/2009/xmlenc11#mgf1sha224': 'sha224', 'http://www.w3.org/2009/xmlenc11#mgf1sha256': 'sha256', 'http://www.w3.org/2009/xmlenc11#mgf1sha384': 'sha384', - 'http://www.w3.org/2009/xmlenc11#mgf1sha512': 'sha512', + 'http://www.w3.org/2009/xmlenc11#mgf1sha512': 'sha512' +}); + +const MGF_LEGACY_ALIASES = Object.assign(Object.create(null), { 'http://www.w3.org/2001/04/xmlenc#MGF1withSHA1': 'sha1' }); -const MGF_SHORT_NAMES = Object.values(MGF_ALGORITHMS); +// URI → short name, for decrypt. Accepts the legacy alias. +const MGF_ALGORITHMS = Object.assign(Object.create(null), MGF_CANONICAL, MGF_LEGACY_ALIASES); -// Derive short-name → URI map for emit, excluding the legacy alias. -// xmlenc11#mgf1sha1 must win over xmlenc#MGF1withSHA1 for sha1. -const MGF_URI_FOR_EMIT = Object.assign(Object.create(null), {}); -for (const [uri, shortName] of Object.entries(MGF_ALGORITHMS)) { - // Only set if not already present (first occurrence wins). - // The xmlenc11#mgf1sha1 entry comes before the legacy entry, so it wins. - if (!MGF_URI_FOR_EMIT[shortName]) { - MGF_URI_FOR_EMIT[shortName] = uri; - } +// Short name → URI, for encrypt. Derived from the canonical map alone, so a +// short name we accept on decrypt is either emittable as a normative URI or +// not emittable at all — never emittable as the legacy alias. +const MGF_URI_FOR_EMIT = Object.create(null); +for (const uri of Object.keys(MGF_CANONICAL)) { + MGF_URI_FOR_EMIT[MGF_CANONICAL[uri]] = uri; } +const MGF_SHORT_NAMES = Object.keys(MGF_URI_FOR_EMIT); + module.exports = { MGF_ALGORITHMS, MGF_SHORT_NAMES, MGF_URI_FOR_EMIT }; diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index e3a5a63..8b42b45 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -415,6 +415,19 @@ describe('xmlenc11#rsa-oaep with explicit MGF', function () { assert.equal(Buffer.compare(Buffer.from(recovered), sym), 0); }); + it('never emits the MGF1withSHA1 alias it accepts on decrypt', function () { + // The alias is decrypt-only: it is not in 5.5.2's normative list, so emitting + // it would send a non-normative URI to peers. The emit map is derived from the + // canonical list alone, which is what makes this hold. + var mgf = require('../lib/mgf-algorithms'); + assert.equal(mgf.MGF_URI_FOR_EMIT['sha1'], 'http://www.w3.org/2009/xmlenc11#mgf1sha1'); + assert.equal(mgf.MGF_ALGORITHMS['http://www.w3.org/2001/04/xmlenc#MGF1withSHA1'], 'sha1'); + Object.keys(mgf.MGF_URI_FOR_EMIT).forEach(function (shortName) { + assert(mgf.MGF_URI_FOR_EMIT[shortName].indexOf('http://www.w3.org/2009/xmlenc11#mgf1') === 0, + shortName + ' must emit a normative xmlenc11 URI, got ' + mgf.MGF_URI_FOR_EMIT[shortName]); + }); + }); + it('rejects keyEncryptionMgf under mgf1p instead of silently ignoring it', function (done) { xmlenc.encrypt('x', { rsa_pub: fs.readFileSync(__dirname + '/test-auth0_rsa.pub'), From d4ed029241fc61c36df76973e3e0a1903f50d279 Mon Sep 17 00:00:00 2001 From: Yamil Asusta Date: Tue, 4 Aug 2026 12:03:09 -0400 Subject: [PATCH 14/14] chore: comment --- lib/xmlenc.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 7312c20..e806568 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -296,9 +296,11 @@ function decryptKeyInfo(doc, options) { } let oaepHash = 'sha1'; - // Resolve DigestMethod relative to the EncryptionMethod we already located, - // not by an absolute path: with EncryptedData/KeyInfo/RetrievalMethod the - // EncryptedKey lives outside KeyInfo and an anchored XPath finds nothing. + // DigestMethod is a child of the EncryptionMethod in use, so read it from + // there: the two describe the same key. EncryptedKey is not always under + // KeyInfo -- with EncryptedData/KeyInfo/RetrievalMethod it sits elsewhere in + // the document -- and where several EncryptedKeys are present, each carries + // its own digest. const keyDigestMethod = xpath.select("./*[local-name(.)='DigestMethod']", keyEncryptionMethod)[0]; if (keyDigestMethod) { const keyDigestMethodAlgorithm = keyDigestMethod.getAttribute('Algorithm');