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
153 changes: 137 additions & 16 deletions lib/mcapi/crypto/jwe-crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ function JweCrypto(config) {

this.encryptedValueFieldName = config.encryptedValueFieldName;

// Only parsed if this runtime needs the node-forge fallback, so callers on
// node never pay for it. A config may also legitimately omit the private
// key.
let parsedPublicKey = null;
let parsedPrivateKey = null;

/**
* Perform data encryption
*
Expand All @@ -52,14 +58,31 @@ function JweCrypto(config) {
const secretKey = nodeCrypto.randomBytes(32);
const secretKeyBuffer = Buffer.from(secretKey, c.BINARY);

const encryptedSecretKey = nodeCrypto.publicEncrypt(
{
key: this.encryptionCertificate,
padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
secretKeyBuffer
);
let encryptedSecretKey;
if (honoursOaepHash(this.encryptionCertificate)) {
encryptedSecretKey = nodeCrypto.publicEncrypt(
{
key: this.encryptionCertificate,
padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
secretKeyBuffer
);
} else {
if (!parsedPublicKey) {
parsedPublicKey = forge.pki.certificateFromPem(
this.encryptionCertificate
).publicKey;
}
encryptedSecretKey = Buffer.from(
parsedPublicKey.encrypt(
secretKeyBuffer.toString(c.BINARY),
"RSA-OAEP",
createOAEPOptions()
),
c.BINARY
);
}

const iv = nodeCrypto.randomBytes(16);

Expand Down Expand Up @@ -110,14 +133,29 @@ function JweCrypto(config) {
const encryptedText = Buffer.from(jweTokenParts[3], c.BASE64);
const authTag = Buffer.from(jweTokenParts[4], c.BASE64);

let secretKey = nodeCrypto.privateDecrypt(
{
key: this.privateKey,
padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
Buffer.from(encryptedSecretKey, c.BINARY)
);
let secretKey;
if (honoursOaepHash(this.encryptionCertificate)) {
secretKey = nodeCrypto.privateDecrypt(
{
key: this.privateKey,
padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
Buffer.from(encryptedSecretKey, c.BINARY)
);
} else {
if (!parsedPrivateKey) {
parsedPrivateKey = forge.pki.privateKeyFromPem(this.privateKey);
}
secretKey = Buffer.from(
parsedPrivateKey.decrypt(
Buffer.from(encryptedSecretKey, c.BINARY).toString(c.BINARY),
"RSA-OAEP",
createOAEPOptions()
),
c.BINARY
);
}

let decryptionEncoding = JSON.parse(jweHeader).enc;
let gcmMode = true;
Expand Down Expand Up @@ -267,6 +305,89 @@ function validateFingerprint(config, contains) {
}
}

/**
* Does this runtime's crypto honour the `oaepHash` option?
*
* node validates the digest name and throws on an unrecognised one. The
* crypto-browserify polyfill ignores `oaepHash` altogether and hard-codes
* SHA-1, so it accepts a nonsense value without complaint. Attempting a wrap
* under a bogus digest is therefore a cheap and side-effect free probe.
*
* This matters because a polyfilled `publicEncrypt` reports no error while
* producing a token whose header advertises RSA-OAEP-256 over a key that was
* actually wrapped with SHA-1. The failure only surfaces at the recipient.
*
* node parses the key before it resolves the digest, so the probe needs a
* usable key to reach the digest check at all. It takes the encryption
* certificate, which config validation requires, for both directions; a
* decrypt-only caller still has one.
*
* The native path is chosen only on positive evidence: node's own
* invalid-digest error. Any other failure, such as a key the runtime cannot
* parse, says nothing about the runtime. It is not memoised and that call
* takes the node-forge path, which applies SHA-256 regardless.
*
* Memoised once the answer is known: it is a property of the bundle, not of
* the key.
*
* @private
*/
let nativeOaepHash = null;
function honoursOaepHash(certificatePem) {
if (nativeOaepHash !== null) {
return nativeOaepHash;
}
if (
typeof nodeCrypto.publicEncrypt !== "function" ||
!nodeCrypto.constants
) {
// `resolve.fallback.crypto = false`, as set in this repo's webpack config,
// leaves an empty module behind. Treat anything unusable as unsupported.
nativeOaepHash = false;
return nativeOaepHash;
}
try {
nodeCrypto.publicEncrypt(
{
key: certificatePem,
padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "mcapi-oaep-support-probe",
},
Buffer.alloc(1)
);
// Accepted a digest that does not exist, so it is not using the option.
nativeOaepHash = false;
} catch (err) {
if (!err || err.code !== "ERR_OSSL_EVP_INVALID_DIGEST") {
return false;
}
nativeOaepHash = true;
}
return nativeOaepHash;
}

/**
* Build node-forge OAEP options for RSA-OAEP-256.
*
* The JWE header emitted by this module is always `"alg": "RSA-OAEP-256"`, so
* the label digest and the MGF1 digest are both SHA-256. MGF1 has to be set
* explicitly: node-forge defaults it to SHA-1, which yields a key wrap that no
* conforming RSA-OAEP-256 implementation can undo.
*
* Mirrors createOAEPOptions in field-level-crypto.js.
*
* @private
*/
function createOAEPOptions() {
const md = forge.md.sha256.create();
return {
md: md,
mgf1: {
md: md,
},
};
}

/**
* @private
*/
Expand Down
Loading
Loading