Skip to content

JWE key wrap silently falls back to SHA-1 in any runtime that is not node #105

Description

@mmorales-post

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions