lib/mcapi/crypto/jwe-crypto.js wraps the content encryption key with node's
crypto.publicEncrypt and asks for SHA-256:
const encryptedSecretKey = nodeCrypto.publicEncrypt(
{
key: this.encryptionCertificate,
padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256",
},
secretKeyBuffer
);
node honours oaepHash. The crypto-browserify polyfill that bundlers substitute does
not. public-encrypt hard-codes SHA-1 for both the OAEP label hash
(publicEncrypt.js:44) and MGF1 (mgf.js:10), and never reads oaepHash at all.
The result is a JWE whose protected header advertises "alg": "RSA-OAEP-256" over a key
that was actually wrapped with SHA-1. Nothing throws. The token looks structurally valid.
It fails only at the recipient, as an opaque decrypt error.
Reproduction
No Mastercard credentials or network needed:
const crypto = require("node:crypto");
const browser = require("public-encrypt/browser"); // what a bundler resolves
const OAEP = crypto.constants.RSA_PKCS1_OAEP_PADDING;
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const cek = crypto.randomBytes(32);
const wrapped = browser.publicEncrypt(
{ key: publicKey.export({ type: "spki", format: "pem" }),
padding: OAEP, oaepHash: "sha256" },
cek
);
crypto.privateDecrypt({ key: privateKey, padding: OAEP, oaepHash: "sha256" }, wrapped);
// ERR_OSSL_RSA_OAEP_DECODING_ERROR
crypto.privateDecrypt({ key: privateKey, padding: OAEP, oaepHash: "sha1" }, wrapped);
// succeeds, returns the original key
Note the require path. public-encrypt/index.js re-exports native crypto when it is
available, so requiring the package root under node hides the defect entirely. The
"browser": "browser.js" field in its package.json is what a bundler resolves.
Why this is worth fixing rather than documenting
webpack.config.js in this repo builds a browser bundle
(client-encryption-nodejs.min.js, global mcencrypt), so a non-node runtime is a
supported target rather than an unusual one.
The field-level path is unaffected, because field-level-crypto.js already goes through
node-forge with an explicit digest via createOAEPOptions. Only the JWE path takes the
native route.
Suggested fix
node-forge is already a dependency and already imported by jwe-crypto.js, so the wrap
can go through it with an explicit SHA-256 digest, with mgf1 set explicitly because
forge otherwise defaults MGF1 to SHA-1.
Doing that unconditionally would cost node callers roughly 9x on the wrap (measured 0.373
ms against 0.042 ms for a 2048-bit key), so the attached PR keeps the native path and only
falls back where the runtime is demonstrably ignoring oaepHash.
lib/mcapi/crypto/jwe-crypto.jswraps the content encryption key with node'scrypto.publicEncryptand asks for SHA-256:node honours
oaepHash. Thecrypto-browserifypolyfill that bundlers substitute doesnot.
public-encrypthard-codes SHA-1 for both the OAEP label hash(
publicEncrypt.js:44) and MGF1 (mgf.js:10), and never readsoaepHashat all.The result is a JWE whose protected header advertises
"alg": "RSA-OAEP-256"over a keythat was actually wrapped with SHA-1. Nothing throws. The token looks structurally valid.
It fails only at the recipient, as an opaque decrypt error.
Reproduction
No Mastercard credentials or network needed:
Note the require path.
public-encrypt/index.jsre-exports native crypto when it isavailable, so requiring the package root under node hides the defect entirely. The
"browser": "browser.js"field in its package.json is what a bundler resolves.Why this is worth fixing rather than documenting
webpack.config.jsin this repo builds a browser bundle(
client-encryption-nodejs.min.js, globalmcencrypt), so a non-node runtime is asupported target rather than an unusual one.
The field-level path is unaffected, because
field-level-crypto.jsalready goes throughnode-forge with an explicit digest via
createOAEPOptions. Only the JWE path takes thenative route.
Suggested fix
node-forge is already a dependency and already imported by
jwe-crypto.js, so the wrapcan go through it with an explicit SHA-256 digest, with
mgf1set explicitly becauseforge otherwise defaults MGF1 to SHA-1.
Doing that unconditionally would cost node callers roughly 9x on the wrap (measured 0.373
ms against 0.042 ms for a 2048-bit key), so the attached PR keeps the native path and only
falls back where the runtime is demonstrably ignoring
oaepHash.