diff --git a/lib/dom-select.js b/lib/dom-select.js new file mode 100644 index 0000000..1e9fc6e --- /dev/null +++ b/lib/dom-select.js @@ -0,0 +1,115 @@ +// Explicit DOM traversal for locating XML-Enc elements. +// +// This module deliberately does not use XPath. Two properties of the previous +// XPath lookups made this key-selection path fragile: +// +// 1. Expressions were built from document content. The RetrievalMethod URI +// was spliced into a predicate, so a URI of "#x' or '1'='1" closed the +// quote and changed what the expression meant, matching every +// EncryptedKey in the document instead of comparing an Id. Here a +// document value is only ever compared, never concatenated into +// anything that gets parsed. +// +// 2. Scope was a string, not a node. "//" is descendant-or-self over the +// whole document regardless of the context node handed to select(), so +// lookups that read as relative were silently document-wide, and could +// pair one EncryptedKey's ciphertext with a different one's DigestMethod. +// Here scope is the root argument, and it is enforced by the walk itself. +// +// Only element nodes are candidates; text, comments and PIs never match. + +const ELEMENT_NODE = 1; + +// Element children of node, in document order. +function children(node, localName, namespaceUri) { + const out = []; + if (!node) return out; + for (let n = node.firstChild; n; n = n.nextSibling) { + if (n.nodeType !== ELEMENT_NODE) continue; + if (n.localName !== localName) continue; + if (namespaceUri !== undefined && n.namespaceURI !== namespaceUri) continue; + out.push(n); + } + return out; +} + +function child(node, localName, namespaceUri) { + return children(node, localName, namespaceUri)[0]; +} + +// Pre-order depth-first walk of root's subtree, root included. Pre-order is +// document order, so the first element visited that satisfies a predicate is +// the same element XPath's [0] would have returned. +function walk(root, visit) { + if (!root) return undefined; + const start = root.nodeType === ELEMENT_NODE ? root : root.documentElement; + if (!start) return undefined; + const stack = [start]; + while (stack.length) { + const node = stack.pop(); + const found = visit(node); + if (found !== undefined) return found; + // Push in reverse so the first child is visited first. + const kids = []; + for (let n = node.firstChild; n; n = n.nextSibling) { + if (n.nodeType === ELEMENT_NODE) kids.push(n); + } + for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); + } + return undefined; +} + +// True when node is the tail of localNames, each earlier name being its +// parent, and the head of the chain lies within root's subtree. This is the +// node-wise reading of "A/B/C": C's parent is B, B's parent is A, A anywhere +// under root. +function matchesPath(node, localNames, root) { + let current = node; + for (let i = localNames.length - 1; i >= 0; i--) { + if (!current || current.nodeType !== ELEMENT_NODE) return false; + if (current.localName !== localNames[i]) return false; + if (i > 0) current = current.parentNode; + } + // Confirm the head of the chain is inside the requested scope. + const scope = root.nodeType === ELEMENT_NODE ? root : root.documentElement; + for (let n = current; n; n = n.parentNode) { + if (n === scope) return true; + } + return false; +} + +// First element in root's subtree matching the child-step path, e.g. +// byPath(doc, ['EncryptedData', 'CipherData', 'CipherValue']). +function byPath(root, localNames) { + return walk(root, function (node) { + return matchesPath(node, localNames, root) ? node : undefined; + }); +} + +// First descendant-or-self of root with this name, optionally namespace-qualified. +function descendant(root, localName, namespaceUri) { + return walk(root, function (node) { + if (node.localName !== localName) return undefined; + if (namespaceUri !== undefined && node.namespaceURI !== namespaceUri) return undefined; + return node; + }); +} + +// The single element named localName carrying Id === id. +// +// Duplicate Id values are invalid XML. Resolving to the first of several makes +// the result depend on document order, so ambiguity is refused rather than +// resolved. +function elementById(root, localName, id) { + const matches = []; + walk(root, function (node) { + if (node.localName === localName && node.getAttribute('Id') === id) matches.push(node); + return undefined; // visit every node + }); + if (matches.length > 1) { + throw new Error('multiple ' + localName + ' elements share Id ' + id); + } + return matches[0]; +} + +module.exports = { children, child, byPath, descendant, elementById }; diff --git a/lib/xmlenc.js b/lib/xmlenc.js index 50fa39e..349eec0 100644 --- a/lib/xmlenc.js +++ b/lib/xmlenc.js @@ -1,6 +1,6 @@ var crypto = require('crypto'); var xmldom = require('@xmldom/xmldom'); -var xpath = require('xpath'); +var dom = require('./dom-select'); var utils = require('./utils'); const insecureAlgorithms = [ @@ -198,14 +198,14 @@ function decrypt(xml, options, callback) { var doc = typeof xml === 'string' ? new xmldom.DOMParser().parseFromString(xml) : xml; var symmetricKey = decryptKeyInfo(doc, options); - var encryptionMethod = xpath.select("//*[local-name(.)='EncryptedData']/*[local-name(.)='EncryptionMethod']", doc)[0]; + var encryptionMethod = dom.byPath(doc, ['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 = xpath.select("//*[local-name(.)='EncryptedData']/*[local-name(.)='CipherData']/*[local-name(.)='CipherValue']", doc)[0]; + var encryptedContent = dom.byPath(doc, ['EncryptedData', 'CipherData', 'CipherValue']); var encrypted = Buffer.from(encryptedContent.textContent, 'base64'); switch (encryptionAlgorithm) { @@ -233,25 +233,38 @@ function decrypt(xml, options, callback) { function decryptKeyInfo(doc, options) { if (typeof doc === 'string') doc = new xmldom.DOMParser().parseFromString(doc); - var keyRetrievalMethodUri; - var keyInfo = xpath.select("//*[local-name(.)='KeyInfo' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']", doc)[0]; - if (!keyInfo) { - keyInfo = xpath.select("//*[local-name(.)='EncryptedData']/*[local-name(.)='KeyInfo']", doc)[0]; + // The EncryptedKey holding the wrapped symmetric key. Either it is nested in + // KeyInfo, or KeyInfo points at it by Id via RetrievalMethod. Resolve the + // 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']); + + if (!encryptedKeyElement) { // try with EncryptedData->KeyInfo->RetrievalMethod + var keyRetrievalMethod = dom.byPath(doc, ['EncryptedData', 'KeyInfo', 'RetrievalMethod']); + var keyRetrievalMethodUri = keyRetrievalMethod ? keyRetrievalMethod.getAttribute('URI') : null; + if (keyRetrievalMethodUri) { + // A same-document reference only. An external URL or a bare fragment is + // not something this resolves. + 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)); + } } - var keyEncryptionMethod = xpath.select("//*[local-name(.)='KeyInfo']/*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']", doc)[0]; - if (!keyEncryptionMethod) { // try with EncryptedData->KeyInfo->RetrievalMethod - var keyRetrievalMethod = xpath.select("//*[local-name(.)='EncryptedData']/*[local-name(.)='KeyInfo']/*[local-name(.)='RetrievalMethod']", doc)[0]; - keyRetrievalMethodUri = keyRetrievalMethod ? keyRetrievalMethod.getAttribute('URI') : null; - keyEncryptionMethod = keyRetrievalMethodUri ? xpath.select("//*[local-name(.)='EncryptedKey' and @Id='" + keyRetrievalMethodUri.substring(1) + "']/*[local-name(.)='EncryptionMethod']", doc)[0] : null; - } + var keyEncryptionMethod = encryptedKeyElement + ? dom.child(encryptedKeyElement, 'EncryptionMethod') + : undefined; if (!keyEncryptionMethod) { throw new Error('cant find encryption algorithm'); } let oaepHash = 'sha1'; - const keyDigestMethod = xpath.select("//*[local-name(.)='KeyInfo']/*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']/*[local-name(.)='DigestMethod']", doc)[0]; + // A direct child of the EncryptionMethod in use. A document-wide lookup can + // return a DigestMethod belonging to a different EncryptedKey. + const keyDigestMethod = dom.child(keyEncryptionMethod, 'DigestMethod'); if (keyDigestMethod) { const keyDigestMethodAlgorithm = keyDigestMethod.getAttribute('Algorithm'); switch (keyDigestMethodAlgorithm) { @@ -271,9 +284,16 @@ function decryptKeyInfo(doc, options) { && insecureAlgorithms.indexOf(keyEncryptionAlgorithm) >= 0) { throw new Error('encryption algorithm ' + keyEncryptionAlgorithm + ' is not secure, fail to decrypt'); } - var encryptedKey = keyRetrievalMethodUri ? - 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]; + // Scoped to the EncryptedKey the EncryptionMethod came from, so the ciphertext + // and the algorithm used to unwrap it cannot come from different elements. + var encryptedKeyCipherData = dom.child(encryptedKeyElement, 'CipherData'); + var encryptedKey = encryptedKeyCipherData + ? dom.child(encryptedKeyCipherData, 'CipherValue') + : undefined; + + if (!encryptedKey) { + throw new Error('cant find encrypted key'); + } switch (keyEncryptionAlgorithm) { case 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p': diff --git a/package-lock.json b/package-lock.json index 9c32768..8469131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,7 @@ "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.13", - "escape-html": "^1.0.3", - "xpath": "0.0.32" + "escape-html": "^1.0.3" }, "devDependencies": { "@commitlint/cli": "^20.3.1", @@ -22,7 +21,8 @@ "husky": "^9.1.7", "mocha": "^7.1.2", "semantic-release": "^25.0.2", - "sinon": "^9.0.2" + "sinon": "^9.0.2", + "xpath": "0.0.32" } }, "node_modules/@actions/core": { @@ -713,7 +713,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1936,7 +1935,6 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2766,7 +2764,6 @@ "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" @@ -2845,7 +2842,6 @@ "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2895,7 +2891,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4642,7 +4637,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6956,7 +6950,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7729,7 +7722,6 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -8732,7 +8724,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9050,6 +9041,7 @@ "version": "0.0.32", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6.0" @@ -9664,7 +9656,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, - "peer": true, "requires": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -10437,7 +10428,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, - "peer": true, "requires": { "undici-types": "~7.16.0" } @@ -10973,7 +10963,6 @@ "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, - "peer": true, "requires": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" @@ -11074,8 +11063,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", - "dev": true, - "peer": true + "dev": true }, "conventional-commits-parser": { "version": "5.0.0", @@ -11106,7 +11094,6 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, - "peer": true, "requires": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -12244,8 +12231,7 @@ "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "dev": true, - "peer": true + "dev": true }, "marked-terminal": { "version": "7.3.0", @@ -13769,8 +13755,7 @@ "picomatch": { "version": "4.0.3", "bundled": true, - "dev": true, - "peer": true + "dev": true } } }, @@ -14297,7 +14282,6 @@ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.2.tgz", "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, - "peer": true, "requires": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -14942,8 +14926,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "peer": true + "dev": true } } }, @@ -15154,7 +15137,8 @@ "xpath": { "version": "0.0.32", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", - "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==" + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "dev": true }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index 0506a03..97b8688 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "husky": "^9.1.7", "mocha": "^7.1.2", "semantic-release": "^25.0.2", - "sinon": "^9.0.2" + "sinon": "^9.0.2", + "xpath": "0.0.32" }, "main": "./lib", "repository": "https://github.com/auth0/node-xml-encryption", @@ -27,8 +28,7 @@ "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.13", - "escape-html": "^1.0.3", - "xpath": "0.0.32" + "escape-html": "^1.0.3" }, "files": [ "lib", diff --git a/test/dom-select.js b/test/dom-select.js new file mode 100644 index 0000000..1d84829 --- /dev/null +++ b/test/dom-select.js @@ -0,0 +1,320 @@ +var assert = require('assert'); +var crypto = require('crypto'); +var fs = require('fs'); +var xmldom = require('@xmldom/xmldom'); +var xpath = require('xpath'); +var dom = require('../lib/dom-select'); +var xmlenc = require('../lib/xmlenc'); + +var XENC = 'http://www.w3.org/2001/04/xmlenc#'; +var DSIG = 'http://www.w3.org/2000/09/xmldsig#'; +var SHA256 = XENC + 'sha256'; +var SHA512 = XENC + 'sha512'; +var MGF1P = XENC + 'rsa-oaep-mgf1p'; + +function parse(xml) { + return new xmldom.DOMParser().parseFromString(xml); +} + +// An EncryptedKey wrapping ct, optionally declaring an OAEP DigestMethod. +function encryptedKey(id, digestUri, ct) { + return '' + + '' + + (digestUri ? '' : '') + + '' + + '' + ct.toString('base64') + '' + + ''; +} + +// EncryptedData whose KeyInfo points at an out-of-line EncryptedKey by Id. +function retrievalDoc(targetId, keys) { + return '' + + '' + + keys.join('') + ''; +} + +describe('dom-select', function () { + describe('scoping', function () { + it('confines byPath to the subtree of the root it is given', function () { + // The property XPath's "//" does not have: passing a context node + // actually restricts the search. + var doc = parse( + '' + + 'OUTSIDE' + + 'INSIDE' + + ''); + var b = dom.descendant(doc, 'b'); + + assert.equal(dom.byPath(b, ['target']).textContent, 'INSIDE'); + assert.equal(dom.descendant(b, 'target').textContent, 'INSIDE'); + // Contrast: the XPath this replaced ignored the context node entirely. + assert.equal(xpath.select("//*[local-name(.)='target']", b)[0].textContent, 'OUTSIDE'); + }); + + it('returns children only, never deeper descendants', function () { + var doc = parse(''); + var root = doc.documentElement; + + assert.equal(dom.child(root, 'DigestMethod'), undefined); + assert.equal(dom.descendant(root, 'DigestMethod').getAttribute('Algorithm'), 'deep'); + }); + + it('requires each step of a path to be a parent-child link', function () { + // 'a/c' must not match a c that sits under a/b. + var doc = parse('NESTED'); + assert.equal(dom.byPath(doc, ['a', 'c']), undefined); + assert.equal(dom.byPath(doc, ['a', 'b', 'c']).textContent, 'NESTED'); + }); + + it('matches in document order', function () { + var doc = parse('FIRSTSECOND'); + assert.equal(dom.byPath(doc, ['a', 'x']).textContent, 'FIRST'); + assert.equal(dom.descendant(doc, 'x').textContent, 'FIRST'); + }); + + it('does not climb above the scope to satisfy an earlier path step', function () { + // Scope is 'a'. The path 'x/a/target' matches by name walking parentNode + // up from target -- but that chain reaches x, which sits outside a's + // subtree. Matching it would let a path escape the scope it was given. + var doc = parse('LEAKED'); + var a = dom.descendant(doc, 'a'); + assert.equal(dom.byPath(a, ['x', 'a', 'target']), undefined); + // Every step is in scope when anchored where the chain actually lives. + assert.equal(dom.byPath(doc, ['x', 'a', 'target']).textContent, 'LEAKED'); + assert.equal(dom.byPath(a, ['target']).textContent, 'LEAKED'); + }); + + it('only ever returns element nodes', function () { + // Non-element nodes report localName === null, so a name match cannot + // select them; the nodeType guard is belt-and-braces over that. + var doc = parse('text'); + var el = dom.descendant(doc, 'a'); + assert.equal(el.nodeType, 1); + assert.equal(el.textContent, 'text'); + assert.equal(dom.descendant(doc, '#comment'), undefined); + assert.equal(dom.children(doc.documentElement, null).length, 0); + }); + + it('filters child() on namespace, not just descendant()', function () { + var doc = parse( + '' + + 'WRONG-NS' + + 'RIGHT-NS' + + ''); + var root = doc.documentElement; + assert.equal(dom.child(root, 'DigestMethod', DSIG).textContent, 'RIGHT-NS'); + assert.equal(dom.child(root, 'DigestMethod', XENC).textContent, 'WRONG-NS'); + assert.equal(dom.child(root, 'DigestMethod', 'urn:absent'), undefined); + assert.equal(dom.children(root, 'DigestMethod', DSIG).length, 1); + assert.equal(dom.children(root, 'DigestMethod').length, 2); + }); + + it('filters on namespace when one is given, and ignores prefix', function () { + var doc = parse( + '' + + 'WRONG-NS' + + 'RIGHT-NS' + + ''); + assert.equal(dom.descendant(doc, 'KeyInfo', DSIG).textContent, 'RIGHT-NS'); + assert.equal(dom.descendant(doc, 'KeyInfo', XENC).textContent, 'WRONG-NS'); + // Without a namespace argument, name alone decides. + assert.equal(dom.descendant(doc, 'KeyInfo').textContent, 'WRONG-NS'); + }); + }); + + describe('elementById', function () { + it('finds the element carrying the Id', function () { + var doc = parse( + '' + + 'A' + + 'B' + + ''); + assert.equal(dom.elementById(doc, 'EncryptedKey', 'b').textContent, 'B'); + assert.equal(dom.elementById(doc, 'EncryptedKey', 'nope'), undefined); + }); + + it('requires the element name to match, not the Id alone', function () { + // An Id is only unique within a document, not per element type, so + // resolving on Id alone lets an unrelated element answer for an + // EncryptedKey. + var doc = parse( + '' + + 'DECOY' + + 'REAL' + + ''); + assert.equal(dom.elementById(doc, 'EncryptedKey', 'k1'), undefined); + assert.equal(dom.elementById(doc, 'EncryptedKey', 'k2').textContent, 'REAL'); + }); + + it('treats an Id as an opaque value, not as expression syntax', function () { + // The XPath this replaced built its predicate by string concatenation, so + // an Id containing quotes changed the expression's meaning rather than + // being compared as a value. + var quoted = "x' or '1'='1"; + var doc = parse( + '' + + 'FIRST' + + 'SECOND' + + ''); + + // No element carries that Id, so nothing resolves. + assert.equal(dom.elementById(doc, 'EncryptedKey', quoted), undefined); + + // Contrast: concatenated into an expression, the same value made the + // predicate true for every element and [0] returned an arbitrary one. + var expr = "//*[local-name(.)='EncryptedKey' and @Id='" + quoted + "']"; + var matched = xpath.select(expr, doc); + assert(matched.length > 1, 'a concatenated Id changes the expression'); + assert(matched.indexOf(dom.elementById(doc, 'EncryptedKey', 'first')) >= 0); + }); + + it('refuses a duplicated Id instead of silently picking one', function () { + // Duplicate Id values are invalid XML. Resolving to the first match makes + // the choice depend on document order, so ambiguity is refused instead. + var doc = parse( + '' + + 'FIRST' + + 'SECOND' + + ''); + assert.throws(function () { + dom.elementById(doc, 'EncryptedKey', 'dup'); + }, /share Id dup/); + }); + }); +}); + +describe('decryptKeyInfo element resolution', function () { + var pub = fs.readFileSync(__dirname + '/test-auth0_rsa.pub'); + var key = fs.readFileSync(__dirname + '/test-auth0.key'); + + // master's decrypt path uses crypto directly; oaepHash follows DigestMethod. + function wrap(symmetricKey, oaepHash) { + return crypto.publicEncrypt({ + key: pub, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: oaepHash + }, symmetricKey); + } + + it('pairs DigestMethod with the EncryptedKey actually in use', function () { + // Two EncryptedKeys under one KeyInfo. The one in use declares no digest + // (so sha1); the other declares sha512. A document-wide DigestMethod lookup + // returns the sha512 belonging to the second, and decryption fails. + var inUse = Buffer.alloc(32, 0x11); + var other = Buffer.alloc(32, 0x22); + var xml = + '' + + encryptedKey(null, null, wrap(inUse, 'sha1')) + + encryptedKey(null, SHA512, wrap(other, 'sha512')) + + ''; + + var recovered = xmlenc.decryptKeyInfo(xml, { key: key }); + assert.equal(Buffer.compare(Buffer.from(recovered), inUse), 0); + + // Contrast: the document-wide lookup returns a digest from a different key. + var doc = parse(xml); + var em = dom.byPath(doc, ['KeyInfo', 'EncryptedKey', 'EncryptionMethod']); + var strayDigest = xpath.select( + "//*[local-name(.)='KeyInfo']/*[local-name(.)='EncryptedKey']/*[local-name(.)='EncryptionMethod']/*[local-name(.)='DigestMethod']", + doc)[0]; + assert.equal(strayDigest.getAttribute('Algorithm'), SHA512); + assert.notEqual(strayDigest.parentNode, em, 'stray digest belongs to a different EncryptionMethod'); + }); + + it('takes the wrapped key from the EncryptedKey, not the first CipherValue in the document', function () { + // EncryptedData's own CipherData precedes KeyInfo. A document-wide + // CipherValue lookup returns the content ciphertext as the wrapped key. + var symmetricKey = Buffer.alloc(32, 0x33); + var xml = + '' + + 'Q09OVEVOVA==' + + '' + encryptedKey(null, SHA256, wrap(symmetricKey, 'sha256')) + '' + + ''; + + var recovered = xmlenc.decryptKeyInfo(xml, { key: key }); + assert.equal(Buffer.compare(Buffer.from(recovered), symmetricKey), 0); + + // Contrast: unscoped, the content ciphertext comes back first. + var doc = parse(xml); + var keyInfo = dom.descendant(doc, 'KeyInfo', DSIG); + assert.equal(xpath.select("//*[local-name(.)='CipherValue']", keyInfo)[0].textContent, 'Q09OVEVOVA=='); + }); + + describe('RetrievalMethod', function () { + it('resolves the referenced key when it is not the first in the document', function () { + var first = Buffer.alloc(32, 0x44); + var target = Buffer.alloc(32, 0x55); + var xml = retrievalDoc('ek2', [ + encryptedKey('ek1', null, wrap(first, 'sha1')), + encryptedKey('ek2', SHA256, wrap(target, 'sha256')) + ]); + var recovered = xmlenc.decryptKeyInfo(xml, { key: key }); + assert.equal(Buffer.compare(Buffer.from(recovered), target), 0); + }); + + it('resolves the referenced key when a decoy follows it', function () { + var target = Buffer.alloc(32, 0x66); + var decoy = Buffer.alloc(32, 0x77); + var xml = retrievalDoc('ek1', [ + encryptedKey('ek1', SHA512, wrap(target, 'sha512')), + encryptedKey('ek2', SHA256, wrap(decoy, 'sha256')) + ]); + var recovered = xmlenc.decryptKeyInfo(xml, { key: key }); + assert.equal(Buffer.compare(Buffer.from(recovered), target), 0); + }); + + it('accepts a well-formed fragment reference', function () { + // Pins the guard's accept side, so a change that rejects everything + // cannot pass by making the rejection test below trivially true. + var symmetricKey = Buffer.alloc(32, 0xDD); + var xml = retrievalDoc('ek1', [encryptedKey('ek1', SHA256, wrap(symmetricKey, 'sha256'))]); + var recovered = xmlenc.decryptKeyInfo(xml, { key: key }); + assert.equal(Buffer.compare(Buffer.from(recovered), symmetricKey), 0); + }); + + it('rejects a URI that is not a same-document reference', function () { + // substring(1) chops the first character whatever it is, so without an + // explicit '#' check "/ek1" and "Xek1" both resolve to Id "ek1" -- a + // non-fragment URI treated as a local one. + ['/ek1', 'Xek1', 'ek1', 'https://example.org/keys#ek1', '#', ''].forEach(function (uri) { + var xml = '' + + '' + + encryptedKey('ek1', null, wrap(Buffer.alloc(32, 0x88), 'sha1')) + ''; + assert.throws(function () { + xmlenc.decryptKeyInfo(xml, { key: key }); + }, /same-document reference|cant find encryption algorithm/, 'URI ' + JSON.stringify(uri) + ' must be refused'); + }); + }); + + it('refuses a reference that resolves to more than one EncryptedKey', function () { + var xml = retrievalDoc('dup', [ + encryptedKey('dup', null, wrap(Buffer.alloc(32, 0x99), 'sha1')), + encryptedKey('dup', SHA256, wrap(Buffer.alloc(32, 0xAA), 'sha256')) + ]); + assert.throws(function () { + xmlenc.decryptKeyInfo(xml, { key: key }); + }, /share Id dup/); + }); + + it('does not let an Id change the meaning of resolution', function () { + var xml = '' + + '' + + encryptedKey('first', null, wrap(Buffer.alloc(32, 0xBB), 'sha1')) + + encryptedKey('second', SHA256, wrap(Buffer.alloc(32, 0xCC), 'sha256')) + ''; + // No element has that Id, so resolution finds nothing rather than matching all. + assert.throws(function () { + xmlenc.decryptKeyInfo(xml, { key: key }); + }, /cant find encryption algorithm/); + }); + }); + + it('reports a missing CipherValue rather than throwing on a null read', function () { + var xml = + '' + + '' + + ''; + assert.throws(function () { + xmlenc.decryptKeyInfo(xml, { key: key }); + }, /cant find encrypted key/); + }); +});