diff --git a/lib/xmlenc.js b/lib/xmlenc.js index a880744..2b1b212 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -254,14 +254,16 @@ function decrypt(xml, options, callback) { var doc = typeof xml === 'string' ? new xmldom.DOMParser().parseFromString(xml) : xml; var symmetricKey = decryptKeyInfo(doc, options); - var encryptionMethod = dom.byPath(doc, ['EncryptedData', 'EncryptionMethod']); + // Same EncryptedData that decryptKeyInfo resolved the key for. + var encryptedData = dom.byPath(doc, ['EncryptedData']); + var encryptionMethod = dom.child(encryptedData, 'EncryptionMethod'); var encryptionAlgorithm = encryptionMethod.getAttribute('Algorithm'); if (options.disallowDecryptionWithInsecureAlgorithm !== false && insecureAlgorithms.indexOf(encryptionAlgorithm) >= 0) { return callback(new Error('encryption algorithm ' + encryptionAlgorithm + ' is not secure, fail to decrypt')); } - var encryptedContent = dom.byPath(doc, ['EncryptedData', 'CipherData', 'CipherValue']); + var encryptedContent = dom.child(dom.child(encryptedData, 'CipherData'), 'CipherValue'); var encrypted = Buffer.from(encryptedContent.textContent, 'base64'); switch (encryptionAlgorithm) { @@ -294,10 +296,14 @@ function decryptKeyInfo(doc, options) { // element once and read both the EncryptionMethod and the ciphertext from it: // the two describe the same key, and taking them from one parent is what // keeps them consistent. - var encryptedKeyElement = dom.byPath(doc, ['KeyInfo', 'EncryptedKey']); + // Resolve the EncryptedData once so the key comes from the same block that + // is decrypted. With no EncryptedData the input is a bare KeyInfo. + var encryptedData = dom.byPath(doc, ['EncryptedData']); + var keyInfo = encryptedData ? dom.child(encryptedData, 'KeyInfo') : dom.byPath(doc, ['KeyInfo']); + var encryptedKeyElement = dom.child(keyInfo, 'EncryptedKey'); if (!encryptedKeyElement) { // try with EncryptedData->KeyInfo->RetrievalMethod - var keyRetrievalMethod = dom.byPath(doc, ['EncryptedData', 'KeyInfo', 'RetrievalMethod']); + var keyRetrievalMethod = dom.child(keyInfo, 'RetrievalMethod'); var keyRetrievalMethodUri = keyRetrievalMethod ? keyRetrievalMethod.getAttribute('URI') : null; if (keyRetrievalMethodUri) { // A same-document reference only. An external URL or a bare fragment is @@ -305,7 +311,12 @@ function decryptKeyInfo(doc, options) { if (keyRetrievalMethodUri.charAt(0) !== '#' || keyRetrievalMethodUri.length < 2) { throw new Error('RetrievalMethod URI must be a same-document reference'); } - encryptedKeyElement = dom.elementById(doc, 'EncryptedKey', keyRetrievalMethodUri.substring(1)); + // The EncryptedKey may be a sibling of EncryptedData (e.g. Okta), so + // search the enclosing element (EncryptedAssertion) rather than only + // the EncryptedData subtree. + var parent = encryptedData && encryptedData.parentNode; + var scope = parent && parent.nodeType === 1 ? parent : doc; + encryptedKeyElement = dom.elementById(scope, 'EncryptedKey', keyRetrievalMethodUri.substring(1)); } } diff --git a/test/test-okta-sibling-key.xml b/test/test-okta-sibling-key.xml new file mode 100644 index 0000000..fb58a7f --- /dev/null +++ b/test/test-okta-sibling-key.xml @@ -0,0 +1,22 @@ + + + + + + + + + + vsSeiTCI4zf0LRuFBxUmIhyhbPO6kVN152a3rMPGeNE4I5WrYzbd0JRUTAl6fXKF + + + + + + + + JhwBazYQHN81PuOKRbHt4lbCttx6F0BZvjeCGrU7tziGi5ncMwd0ld2kcvYaOwncgVWv3nPCVJGGtoJUILg8z71mZJXuoz3MRufQ0G1TACKOAKbWhvI5ZIDEOAzuZzr385zKrNV7Zs7yTGAV/RezogddIUD6e/UokXQgvOcByeOh+2xAP8QLWqrBgrFwSIr+HKwEp3T921y7EHjS5SzRXzjubwXw8PTZlwS/nj8VJ9IBL3yqjEEaX0fsPoCgbmNnJddxW7g9SVaI/1YmAkHGMlLx2ruY++UQDL/uz9Y0Dm91IMF0ZRLA0LHSjHDJvdu2FckWkwM98JpjYQnfopSS8w== + + + + diff --git a/test/xmlenc.digest.js b/test/xmlenc.digest.js index 4af25e5..6f3c7c5 100644 --- a/test/xmlenc.digest.js +++ b/test/xmlenc.digest.js @@ -603,3 +603,91 @@ describe('OAEPparams', function () { }); }); }); + +describe('RetrievalMethod to a sibling EncryptedKey (Okta layout)', function () { + var xmldom = require('@xmldom/xmldom'); + 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' + }; + var decryptOptions = { key: fs.readFileSync(__dirname + '/test-auth0.key') }; + + // Wraps the result in an EncryptedAssertion, with the EncryptedKey placed + // as a sibling of EncryptedData and referenced by RetrievalMethod. + function siblingLayout(result, wrap) { + var m = //.exec(result); + assert(m, 'expected an EncryptedKey element'); + var encryptedKey = m[0].replace(''); + return wrap(encryptedData, encryptedKey); + } + + function encryptedDataOf(xml) { + var doc = new xmldom.DOMParser().parseFromString(xml); + return doc.getElementsByTagNameNS('http://www.w3.org/2001/04/xmlenc#', 'EncryptedData')[0]; + } + + it('resolves the key from the EncryptedData parent', function (done) { + xmlenc.encrypt('sibling content', options, function (err, result) { + if (err) return done(err); + var xml = siblingLayout(result, function (data, key) { + return '' + + data + key + ''; + }); + var element = encryptedDataOf(xml); + assert.equal(element.parentNode.localName, 'EncryptedAssertion'); + xmlenc.decrypt(element, decryptOptions, function (err2, decrypted) { + if (err2) return done(err2); + assert.equal(decrypted, 'sibling content'); + done(); + }); + }); + }); + + it('finds the key in an Okta-shaped response (fails at the RSA unwrap, not the lookup)', function () { + // Okta layout with all identifiers and ciphertext replaced by dummies, so the + // key cannot unwrap; failing at the RSA step proves the key was located. + var xml = fs.readFileSync(__dirname + '/test-okta-sibling-key.xml', 'utf8'); + var element = encryptedDataOf(xml); + assert.throws(function () { + xmlenc.decryptKeyInfo(element, decryptOptions); + }, function (err) { + return !/cant find encryption algorithm/.test(err.message); + }); + }); + + it('uses the key of the EncryptedData being decrypted when another block has an inline key', function (done) { + xmlenc.encrypt('first block', options, function (err, first) { + if (err) return done(err); + xmlenc.encrypt('second block', options, function (err2, second) { + if (err2) return done(err2); + var xml = siblingLayout(first, function (data, key) { + return '' + data + key + second + ''; + }); + var doc = new xmldom.DOMParser().parseFromString(xml); + xmlenc.decrypt(doc, decryptOptions, function (err3, decrypted) { + if (err3) return done(err3); + assert.equal(decrypted, 'first block'); + done(); + }); + }); + }); + }); + + it('does not resolve an EncryptedKey outside the EncryptedData parent', function (done) { + xmlenc.encrypt('sibling content', options, function (err, result) { + if (err) return done(err); + var xml = siblingLayout(result, function (data, key) { + return '' + data + '' + key + ''; + }); + xmlenc.decrypt(encryptedDataOf(xml), decryptOptions, function (err2) { + assert(err2, 'expected failure'); + assert.match(err2.message, /cant find encryption algorithm/); + done(); + }); + }); + }); +}); diff --git a/test/xmlenc.integration.js b/test/xmlenc.integration.js index c78dcb6..64fabac 100644 --- a/test/xmlenc.integration.js +++ b/test/xmlenc.integration.js @@ -30,4 +30,25 @@ describe('integration', function() { } ); }); + + // passport-wsfed-saml2 passes only the EncryptedData element, while Okta + // places the referenced EncryptedKey beside it under EncryptedAssertion. + it('should decrypt Okta assertion when given only the EncryptedData element', function (done) { + var doc = new xmldom.DOMParser().parseFromString( + fs.readFileSync(__dirname + '/test-okta-enc-response.xml').toString() + ); + var encryptedData = doc.getElementsByTagNameNS('http://www.w3.org/2001/04/xmlenc#', 'EncryptedData')[0] + || doc.getElementsByTagName('EncryptedData')[0]; + assert.equal(encryptedData.parentNode.localName, 'EncryptedAssertion'); + + xmlenc.decrypt( + encryptedData, + {key: fs.readFileSync(__dirname + '/test-okta.pem'), disallowDecryptionWithInsecureAlgorithm: false}, + (err, res) => { + assert.ifError(err); + assert(/Assertion/.test(res)); + done(); + } + ); + }); });