diff --git a/README.md b/README.md index aec498a..6ea22ed 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. `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 + ### 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. diff --git a/lib/mgf-algorithms.js b/lib/mgf-algorithms.js new file mode 100644 index 0000000..32a21bb --- /dev/null +++ b/lib/mgf-algorithms.js @@ -0,0 +1,34 @@ +// 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' +}); + +const MGF_LEGACY_ALIASES = Object.assign(Object.create(null), { + 'http://www.w3.org/2001/04/xmlenc#MGF1withSHA1': 'sha1' +}); + +// URI → short name, for decrypt. Accepts the legacy alias. +const MGF_ALGORITHMS = Object.assign(Object.create(null), MGF_CANONICAL, MGF_LEGACY_ALIASES); + +// 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/lib/oaep.js b/lib/oaep.js new file mode 100644 index 0000000..78d3659 --- /dev/null +++ b/lib/oaep.js @@ -0,0 +1,122 @@ +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); + + // 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: keyObj, padding: crypto.constants.RSA_NO_PADDING }, + ciphertext + ); + } catch (e) { + // Ciphertext ≥ modulus fails before OAEP decode starts. Also from the + // document, so stay generic. + 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 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; + 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/lib/templates/keyinfo.tpl.xml.js b/lib/templates/keyinfo.tpl.xml.js index 9859d5f..44ab87f 100644 --- a/lib/templates/keyinfo.tpl.xml.js +++ b/lib/templates/keyinfo.tpl.xml.js @@ -1,21 +1,31 @@ var escapehtml = require('escape-html'); +var { MGF_URI_FOR_EMIT } = require('../mgf-algorithms'); -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' -}; +}); -module.exports = ({ encryptionPublicCert, encryptedKey, keyEncryptionMethod, keyEncryptionDigest }) => { +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. 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_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)}` : ''} + ${isOAEP11 && mgfUri ? `` : ''} ${isOAEP ? `` : ''} diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 50fa39e..e806568 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -2,6 +2,8 @@ var crypto = require('crypto'); 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 @@ -13,15 +15,35 @@ 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; + 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 || 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, + oaepLabel: oaepLabel + }); + } else { + encrypted = crypto.publicEncrypt({ + key: options.rsa_pub, + oaepHash: oaepHash, + padding: padding + }, symmetricKeyBuffer); + } var base64EncodedEncryptedKey = encrypted.toString('base64'); var params = { @@ -29,6 +51,8 @@ function encryptKeyInfoWithScheme(symmetricKey, options, padding, callback) { encryptionPublicCert: '' + utils.pemToCert(options.pem.toString()) + '', keyEncryptionMethod: options.keyEncryptionAlgorithm, keyEncryptionDigest: options.keyEncryptionDigest, + keyEncryptionMgf: mgf1Hash, + keyEncryptionOaepParams: oaepLabel.length ? oaepLabel.toString('base64') : null, }; var result = utils.renderTemplate('keyinfo', params); @@ -48,18 +72,39 @@ 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': - 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/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 (!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')); + } + 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, callback); + return encryptKeyInfoWithScheme(symmetricKey, options, crypto.constants.RSA_PKCS1_PADDING, undefined, callback); default: return callback(new Error('encryption key algorithm not supported')); @@ -251,7 +296,12 @@ 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]; + // 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'); switch (keyDigestMethodAlgorithm) { @@ -267,7 +317,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'); } @@ -275,9 +325,33 @@ 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': - 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', oaepLabel); + + 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, oaepLabel); + } + 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); @@ -286,10 +360,20 @@ function decryptKeyInfo(doc, options) { } } -function decryptKeyInfoWithScheme(encryptedKey, options, padding, oaepHash) { +function decryptKeyInfoWithScheme(encryptedKey, options, padding, oaepHash, mgf1Hash, oaepLabel) { const key = Buffer.from(encryptedKey.textContent, 'base64'); - const decrypted = crypto.privateDecrypt({ key: options.key, padding, oaepHash}, key); - return Buffer.from(decrypted, 'binary'); + const label = oaepLabel || Buffer.alloc(0); + if (padding === crypto.constants.RSA_PKCS1_OAEP_PADDING && !mgf1Hash) { + throw new Error('mgf1Hash is required for OAEP padding'); + } + // 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, oaepLabel: label }); } function encryptWithAlgorithm(algorithm, symmetricKey, ivLength, content, encoding, callback) { diff --git a/test/oaep.js b/test/oaep.js new file mode 100644 index 0000000..a259d4a --- /dev/null +++ b/test/oaep.js @@ -0,0 +1,244 @@ +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'); + } + }); + + // 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 () { + 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 keyObj = crypto.createPublicKey(pub); + var k = Math.ceil(keyObj.asymmetricKeyDetails.modulusLength / 8); + var combos = [ + ['sha256', 'sha1'], + ['sha512', 'sha1'], + ['sha1', 'sha256'], + ['sha384', 'sha256'], + ['sha256', 'sha256'] + ]; + + combos.forEach(function (combo) { + var oaepHash = combo[0]; + var mgf1Hash = combo[1]; + var hLen = crypto.createHash(oaepHash).digest().length; + [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 }); + 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(k), { 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/); + }); + + 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'); + } + } + }); + }); +}); diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index 4994415..8b42b45 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -150,3 +150,418 @@ 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(); + }); + }); + }); +}); + +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/); + }); + + 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.equal(withSha1.length, 32); + + // 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); + 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(); + }); + }); + + 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('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'), + 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(); + }); + }); + + 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) { + 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'), + 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); + // 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'); + done(); + }); + }); + }); +});