From 10b6544564b6035356412e096704de8b11e1cf31 Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 17:04:26 +0530 Subject: [PATCH 1/4] Reject TLS chains carrying the compromised eMudhra intermediate keys Node still trusts the 8 eMudhra intermediates whose private keys are recoverable, so the endpoint lookup and the binary download could be served by anyone holding a certificate issued under them. Install a tls.checkServerIdentity wrapper in Local.js and in the two scripts startSync runs as child processes. Co-Authored-By: Claude Opus 5.5 --- lib/Local.js | 2 + lib/distrustedCaCheck.js | 86 +++++++++++++++++++++++++++++++++++ lib/download.js | 2 + lib/fetchDownloadSourceUrl.js | 2 + 4 files changed, 92 insertions(+) create mode 100644 lib/distrustedCaCheck.js diff --git a/lib/Local.js b/lib/Local.js index ffd8af9..10706f7 100644 --- a/lib/Local.js +++ b/lib/Local.js @@ -1,3 +1,5 @@ +require('./distrustedCaCheck').installDistrustedCaCheck(); + var childProcess = require('child_process'), os = require('os'), fs = require('fs'), diff --git a/lib/distrustedCaCheck.js b/lib/distrustedCaCheck.js new file mode 100644 index 0000000..e013f53 --- /dev/null +++ b/lib/distrustedCaCheck.js @@ -0,0 +1,86 @@ +'use strict'; + +/* + * The private keys of 8 eMudhra intermediate CAs are recoverable, so anyone can issue certificates + * under them. Browsers and OS trust stores block them, but Node's bundled roots still accept their + * chains. Reject any TLS chain carrying one of those keys, matched by SPKI SHA-256 so the emSign-issued + * and the Sectigo cross-signed copies are both caught. + * Source: https://emudhra.we-have-a-problem.com/impacted-intermediates.json + */ + +const tls = require('tls'); +const crypto = require('crypto'); + +const STATE_KEY = Symbol.for('browserstack.distrustedCaCheck'); + +const DISTRUSTED_SPKI_SHA256 = [ + 'EBED6475C2C2D80FCB199F3FE8DD6E6B3CE60B77EB15D4E42C3DEC0C6F5B5105', // EM DV TLS CA - G3B + '43BFFA19D6EF651451F6841E019931E96F6F2EAEFF28AB4B6F1CE4576DE2717B', // EM OV TLS CA - G3B + 'E0254C7FCDC265E2F0F8A428DABB75AB4AE039B4872D62F5EE57F0877D5A8A41', // EM EV TLS CA - G3B + '49DD353DC2B919A3770645117D0E60C16115E7D1BC259EEFE734B00EA434FE5F', // EM DV TLS CA - G3C + '63ECEFEE431D49523BC1136C4E72D0206A477086CAF606CD5CF025686C109CF8', // EM OV TLS CA - G3C + '5C0660A2B164910D8FF589C18E14552E9E0EC237DCEFE2CB736D41A4811F81A0', // EM EV TLS CA - G3C + 'EC4FFA1AD0AC88081314B7AAFFEF6A91F97CD57291BB5BA686C5415F5EB4F6B6', // EM OV CS CA - G3B + '92C063D67E8F14DB71D288E11266FFDAF69CBF115F7BAB14F3BC2F68D25E0E30' // EM OV CS CA - G3C +]; + +// The peer certificate's `pubkey` is the raw point for EC keys, not the SPKI, so re-export the SPKI from +// the certificate. createPublicKey rather than X509Certificate, which needs Node 15.6. +function spkiSha256(rawCert) { + const pem = `-----BEGIN CERTIFICATE-----\n${rawCert.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`; + const spki = crypto.createPublicKey(pem).export({type: 'spki', format: 'der'}); + + return crypto.createHash('sha256').update(spki).digest('hex').toUpperCase(); +} + +function findDistrustedCert(cert, distrusted) { + for (let current = cert, previous; current && current !== previous; previous = current, current = current.issuerCertificate) { + if (current.raw && distrusted.has(spkiSha256(current.raw))) { + return current; + } + } + + return null; +} + +// Every copy of this module in the process shares one hash set and one wrapper. +function installDistrustedCaCheck() { + if (typeof crypto.createPublicKey !== 'function') { + return false; + } + const existing = global[STATE_KEY]; + if (existing) { + DISTRUSTED_SPKI_SHA256.forEach((hash) => existing.distrusted.add(hash)); + + return false; + } + const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256)}; + global[STATE_KEY] = state; + + const originalCheckServerIdentity = tls.checkServerIdentity; + tls.checkServerIdentity = function checkServerIdentity(hostname, cert) { + const identityError = originalCheckServerIdentity(hostname, cert); + if (identityError) { + return identityError; + } + let distrustedCert; + try { + distrustedCert = findDistrustedCert(cert, state.distrusted); + } catch (err) { + // A key type createPublicKey cannot parse is not one of the distrusted EC keys. + return undefined; + } + if (!distrustedCert) { + return undefined; + } + const error = new Error(`Certificate chain for ${hostname} contains the distrusted eMudhra intermediate "${distrustedCert.subject && distrustedCert.subject.CN}"`); + error.code = 'ERR_TLS_DISTRUSTED_INTERMEDIATE'; + error.host = hostname; + + return error; + }; + + return true; +} + +module.exports = {installDistrustedCaCheck}; diff --git a/lib/download.js b/lib/download.js index 2d54cc4..a0f3c0c 100644 --- a/lib/download.js +++ b/lib/download.js @@ -1,3 +1,5 @@ +require('./distrustedCaCheck').installDistrustedCaCheck(); + const https = require('https'), fs = require('fs'), HttpsProxyAgent = require('https-proxy-agent'), diff --git a/lib/fetchDownloadSourceUrl.js b/lib/fetchDownloadSourceUrl.js index 6b5d37c..2238f7d 100644 --- a/lib/fetchDownloadSourceUrl.js +++ b/lib/fetchDownloadSourceUrl.js @@ -1,3 +1,5 @@ +require('./distrustedCaCheck').installDistrustedCaCheck(); + const https = require('https'), fs = require('fs'), HttpsProxyAgent = require('https-proxy-agent'), From 6a6ebbeca274aa9b6b8f4c7b2c7731cb44ed0155 Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 20:19:58 +0530 Subject: [PATCH 2/4] fix(tls): check each certificate separately and match the distrusted CA names (SDK-7999) Two gaps from review: a parse failure on one certificate ended the walk for the whole chain, and Node links issuerCertificate by name, so a decoy certificate sent ahead of the real intermediate diverted the walk past the distrusted key. Skip only the certificate that fails to parse, and also match the 8 intermediates' subject names against each certificate's subject and issuer (they are pathlen:0, so the leaf's issuer is always one of them). Co-Authored-By: Claude Opus 5.5 --- lib/distrustedCaCheck.js | 59 +++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/lib/distrustedCaCheck.js b/lib/distrustedCaCheck.js index e013f53..6e4c0f1 100644 --- a/lib/distrustedCaCheck.js +++ b/lib/distrustedCaCheck.js @@ -3,8 +3,8 @@ /* * The private keys of 8 eMudhra intermediate CAs are recoverable, so anyone can issue certificates * under them. Browsers and OS trust stores block them, but Node's bundled roots still accept their - * chains. Reject any TLS chain carrying one of those keys, matched by SPKI SHA-256 so the emSign-issued - * and the Sectigo cross-signed copies are both caught. + * chains. Reject any TLS chain that carries one of those keys (SPKI SHA-256, which catches both the + * emSign-issued and the Sectigo cross-signed copies) or one of their names. * Source: https://emudhra.we-have-a-problem.com/impacted-intermediates.json */ @@ -24,6 +24,24 @@ const DISTRUSTED_SPKI_SHA256 = [ '92C063D67E8F14DB71D288E11266FFDAF69CBF115F7BAB14F3BC2F68D25E0E30' // EM OV CS CA - G3C ]; +// Node links issuerCertificate by name, so a decoy certificate sent ahead of the real intermediate can divert +// the walk away from the distrusted key. The 8 intermediates are pathlen:0, so the leaf's issuer is always +// one of their names; matching names closes that gap. +const DISTRUSTED_NAMES = [ + 'EM DV TLS CA - G3B|eMudhra BV|NL', + 'EM OV TLS CA - G3B|eMudhra BV|NL', + 'EM EV TLS CA - G3B|eMudhra BV|NL', + 'EM DV TLS CA - G3C|eMudhra Inc|US', + 'EM OV TLS CA - G3C|eMudhra Inc|US', + 'EM EV TLS CA - G3C|eMudhra Inc|US', + 'EM OV CS CA - G3B|eMudhra BV|NL', + 'EM OV CS CA - G3C|eMudhra Inc|US' +]; + +function nameKey(name) { + return name ? `${name.CN || ''}|${name.O || ''}|${name.C || ''}` : ''; +} + // The peer certificate's `pubkey` is the raw point for EC keys, not the SPKI, so re-export the SPKI from // the certificate. createPublicKey rather than X509Certificate, which needs Node 15.6. function spkiSha256(rawCert) { @@ -33,17 +51,31 @@ function spkiSha256(rawCert) { return crypto.createHash('sha256').update(spki).digest('hex').toUpperCase(); } -function findDistrustedCert(cert, distrusted) { +// Returns the name of the distrusted intermediate found in the chain, or null. +function findDistrustedIntermediate(cert, state) { for (let current = cert, previous; current && current !== previous; previous = current, current = current.issuerCertificate) { - if (current.raw && distrusted.has(spkiSha256(current.raw))) { - return current; + if (state.distrustedNames.has(nameKey(current.subject))) { + return current.subject.CN; + } + if (state.distrustedNames.has(nameKey(current.issuer))) { + return current.issuer.CN; + } + let hash; + try { + hash = current.raw && spkiSha256(current.raw); + } catch (err) { + // A key createPublicKey cannot parse is not one of the distrusted EC keys; keep checking its issuers. + continue; + } + if (hash && state.distrusted.has(hash)) { + return current.subject && current.subject.CN; } } return null; } -// Every copy of this module in the process shares one hash set and one wrapper. +// Every copy of this module in the process shares one state and one wrapper. function installDistrustedCaCheck() { if (typeof crypto.createPublicKey !== 'function') { return false; @@ -51,10 +83,11 @@ function installDistrustedCaCheck() { const existing = global[STATE_KEY]; if (existing) { DISTRUSTED_SPKI_SHA256.forEach((hash) => existing.distrusted.add(hash)); + DISTRUSTED_NAMES.forEach((name) => existing.distrustedNames.add(name)); return false; } - const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256)}; + const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256), distrustedNames: new Set(DISTRUSTED_NAMES)}; global[STATE_KEY] = state; const originalCheckServerIdentity = tls.checkServerIdentity; @@ -63,17 +96,11 @@ function installDistrustedCaCheck() { if (identityError) { return identityError; } - let distrustedCert; - try { - distrustedCert = findDistrustedCert(cert, state.distrusted); - } catch (err) { - // A key type createPublicKey cannot parse is not one of the distrusted EC keys. - return undefined; - } - if (!distrustedCert) { + const distrustedName = findDistrustedIntermediate(cert, state); + if (!distrustedName) { return undefined; } - const error = new Error(`Certificate chain for ${hostname} contains the distrusted eMudhra intermediate "${distrustedCert.subject && distrustedCert.subject.CN}"`); + const error = new Error(`Certificate chain for ${hostname} contains the distrusted eMudhra intermediate "${distrustedName}"`); error.code = 'ERR_TLS_DISTRUSTED_INTERMEDIATE'; error.host = hostname; From 43e4a1766f20006673aa8990b78228fb7cbbfcdb Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 20:25:08 +0530 Subject: [PATCH 3/4] fix(tls): compare distrusted CA names the way OpenSSL does (SDK-7999) OpenSSL chains names case-insensitively with whitespace collapsed, so a leaf naming its issuer 'em dv tls ca - g3b' still validates through the real intermediate; with a differently-cased decoy diverting the issuer walk, exact matching missed both names. Normalize names the same way before comparing, and never report an empty name when a key matches. Co-Authored-By: Claude Opus 5.5 --- lib/distrustedCaCheck.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/distrustedCaCheck.js b/lib/distrustedCaCheck.js index 6e4c0f1..af1d06b 100644 --- a/lib/distrustedCaCheck.js +++ b/lib/distrustedCaCheck.js @@ -36,10 +36,15 @@ const DISTRUSTED_NAMES = [ 'EM EV TLS CA - G3C|eMudhra Inc|US', 'EM OV CS CA - G3B|eMudhra BV|NL', 'EM OV CS CA - G3C|eMudhra Inc|US' -]; +].map((name) => name.toLowerCase()); + +// OpenSSL chains names case-insensitively with whitespace collapsed, so compare them the same way. +function normalize(value) { + return String((Array.isArray(value) ? value.join(',') : value) || '').trim().replace(/\s+/g, ' ').toLowerCase(); +} function nameKey(name) { - return name ? `${name.CN || ''}|${name.O || ''}|${name.C || ''}` : ''; + return name ? `${normalize(name.CN)}|${normalize(name.O)}|${normalize(name.C)}` : ''; } // The peer certificate's `pubkey` is the raw point for EC keys, not the SPKI, so re-export the SPKI from @@ -68,7 +73,7 @@ function findDistrustedIntermediate(cert, state) { continue; } if (hash && state.distrusted.has(hash)) { - return current.subject && current.subject.CN; + return (current.subject && current.subject.CN) || 'unknown'; } } From 3b0e1178ec9b17b8329b93a590570cc657e70b2c Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 21 Oct 2026 20:46:47 +0530 Subject: [PATCH 4/4] fix(tls): keep the install state on the tls module (SDK-7999) Jest runs each test file in its own context with its own global but shares core modules, so the SDK's per-file setup re-ran the install and wrapped tls.checkServerIdentity again for every test file. Keep the install state on the tls module instead; worker threads still get their own tls module and install their own check. Co-Authored-By: Claude Opus 5.5 --- lib/distrustedCaCheck.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/distrustedCaCheck.js b/lib/distrustedCaCheck.js index af1d06b..31009b0 100644 --- a/lib/distrustedCaCheck.js +++ b/lib/distrustedCaCheck.js @@ -80,12 +80,13 @@ function findDistrustedIntermediate(cert, state) { return null; } -// Every copy of this module in the process shares one state and one wrapper. +// Every copy of this module in the process shares one state and one wrapper. The state lives on the tls module, +// not on global: Jest gives each test file its own global but shares core modules across them. function installDistrustedCaCheck() { if (typeof crypto.createPublicKey !== 'function') { return false; } - const existing = global[STATE_KEY]; + const existing = tls[STATE_KEY]; if (existing) { DISTRUSTED_SPKI_SHA256.forEach((hash) => existing.distrusted.add(hash)); DISTRUSTED_NAMES.forEach((name) => existing.distrustedNames.add(name)); @@ -93,7 +94,7 @@ function installDistrustedCaCheck() { return false; } const state = {distrusted: new Set(DISTRUSTED_SPKI_SHA256), distrustedNames: new Set(DISTRUSTED_NAMES)}; - global[STATE_KEY] = state; + tls[STATE_KEY] = state; const originalCheckServerIdentity = tls.checkServerIdentity; tls.checkServerIdentity = function checkServerIdentity(hostname, cert) {