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
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