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
156 changes: 140 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,92 @@ 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, `ERR_OSSL_EVP_INVALID_DIGEST`. Every node release that
* passes nodeVersionSupportsJWE throws it, on OpenSSL 1.1.1 (node 13-16) and
* 3.x (node 17 onward) alike. 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") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you confirm this is the same exception thrown for all node versions supported for JWE flow? i.e. Node >v12

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I ran the probe against a real certificate on every major from 13 to 24 (node:<v>-alpine), plus 26 locally. All of them throw ERR_OSSL_EVP_INVALID_DIGEST ("Invalid digest used"), on OpenSSL 1.1.1 (13 to 16) and 3.x (17 on). 13 is the floor because nodeVersionSupportsJWE requires > 12. I've added the range to the doc comment.

v13.14.0 openssl=1.1.1g       code=ERR_OSSL_EVP_INVALID_DIGEST
v14.21.3 openssl=1.1.1t       code=ERR_OSSL_EVP_INVALID_DIGEST
v15.14.0 openssl=1.1.1k+quic  code=ERR_OSSL_EVP_INVALID_DIGEST
v16.20.2 openssl=1.1.1v+quic  code=ERR_OSSL_EVP_INVALID_DIGEST
v17.9.1  openssl=3.0.3+quic   code=ERR_OSSL_EVP_INVALID_DIGEST
v18.20.8 openssl=3.0.16       code=ERR_OSSL_EVP_INVALID_DIGEST
v19.9.0  openssl=3.0.8+quic   code=ERR_OSSL_EVP_INVALID_DIGEST
v20.20.2 openssl=3.0.19       code=ERR_OSSL_EVP_INVALID_DIGEST
v22.23.4 openssl=3.5.9        code=ERR_OSSL_EVP_INVALID_DIGEST
v24.22.2 openssl=3.5.9        code=ERR_OSSL_EVP_INVALID_DIGEST

If a future release changes the code, the probe fails safe: nothing is cached and that call goes through forge.

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. `md` has to be set:
* node-forge defaults the OAEP digest to SHA-1, which yields a key wrap that no
* conforming RSA-OAEP-256 implementation can undo. MGF1 follows `md` when it is
* not given, so setting it as well only makes the intent explicit.
*
* 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