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/);
+ });
+});