Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 `<MGF>` 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 `<OAEPparams>` 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.
Expand Down
115 changes: 115 additions & 0 deletions lib/dom-select.js
Original file line number Diff line number Diff line change
@@ -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 };
34 changes: 34 additions & 0 deletions lib/mgf-algorithms.js
Original file line number Diff line number Diff line change
@@ -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 };
122 changes: 122 additions & 0 deletions lib/oaep.js
Original file line number Diff line number Diff line change
@@ -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
};
16 changes: 13 additions & 3 deletions lib/templates/keyinfo.tpl.xml.js
Original file line number Diff line number Diff line change
@@ -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 `
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<e:EncryptedKey xmlns:e="http://www.w3.org/2001/04/xmlenc#">
<e:EncryptionMethod Algorithm="${escapehtml(keyEncryptionMethod)}">
${isOAEP && keyEncryptionOaepParams ? `<e:OAEPparams>${escapehtml(keyEncryptionOaepParams)}</e:OAEPparams>` : ''}
${isOAEP11 && mgfUri ? `<MGF xmlns="http://www.w3.org/2009/xmlenc11#" Algorithm="${escapehtml(mgfUri)}" />` : ''}
${isOAEP ? `<DigestMethod Algorithm="${escapehtml(digestUri)}" />` : ''}
</e:EncryptionMethod>
<KeyInfo>
Expand Down
Loading