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
119 changes: 119 additions & 0 deletions bin/helpers/distrustedCaCheck.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'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 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
*/

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
];

// 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'
].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 ? `${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
// 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();
}

// 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 (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) || 'unknown';
}
}

return null;
}

// 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 = tls[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), distrustedNames: new Set(DISTRUSTED_NAMES)};
tls[STATE_KEY] = state;

const originalCheckServerIdentity = tls.checkServerIdentity;
tls.checkServerIdentity = function checkServerIdentity(hostname, cert) {
const identityError = originalCheckServerIdentity(hostname, cert);
if (identityError) {
return identityError;
}
const distrustedName = findDistrustedIntermediate(cert, state);
if (!distrustedName) {
return undefined;
}
const error = new Error(`Certificate chain for ${hostname} contains the distrusted eMudhra intermediate "${distrustedName}"`);
error.code = 'ERR_TLS_DISTRUSTED_INTERMEDIATE';
error.host = hostname;

return error;
};

return true;
}

module.exports = {installDistrustedCaCheck};
7 changes: 7 additions & 0 deletions bin/helpers/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
"use strict";
// Installed here rather than in runner.js: the CLI and the reporter/plugins that run inside the customer's Cypress all load this file.
try {
require('./distrustedCaCheck').installDistrustedCaCheck();
} catch (err) {
require('./logger').winstonLogger.debug(`Distrusted CA check failed to install: ${err && err.message}`);
}

const os = require("os");
const path = require("path");
const fs = require("fs");
Expand Down
95 changes: 95 additions & 0 deletions test/unit/bin/helpers/distrustedCaCheck.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use strict';
const chai = require("chai"),
expect = chai.expect,
tls = require('tls');

const { installDistrustedCaCheck } = require("../../../../bin/helpers/distrustedCaCheck");

// EM DV TLS CA - G3B as issued by emSign Root TLS CA - G3, from https://emudhra.we-have-a-problem.com/impacted-intermediates.json
const DISTRUSTED_INTERMEDIATE_PEM = `-----BEGIN CERTIFICATE-----
MIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw
CQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl
ZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1
NDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11
ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J
hZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p
1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L
WUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow
KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB
BAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY
aHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw
b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f
BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j
cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E
AwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO
Ibh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8
gt2TlfJbHii+KNvK
-----END CERTIFICATE-----`;

const NOT_A_CERTIFICATE = Buffer.from('not a certificate');

function peerChain(intermediate, leafIssuer = intermediate.subject) {
const root = { subject: { CN: 'Test Root', O: 'Test', C: 'US' }, issuer: { CN: 'Test Root', O: 'Test', C: 'US' }, raw: NOT_A_CERTIFICATE };
root.issuerCertificate = root;
intermediate.issuer = root.subject;
intermediate.issuerCertificate = root;
return {
subject: { CN: 'example.com' },
subjectaltname: 'DNS:example.com',
issuer: leafIssuer,
raw: NOT_A_CERTIFICATE,
issuerCertificate: intermediate,
};
}

describe("distrustedCaCheck", () => {
before(() => {
installDistrustedCaCheck();
});

it("refuses a chain carrying a distrusted intermediate's name", () => {
const cert = peerChain({ subject: { CN: 'EM DV TLS CA - G3B', O: 'eMudhra BV', C: 'NL' }, raw: NOT_A_CERTIFICATE });

const error = tls.checkServerIdentity('example.com', cert);

expect(error).to.be.an('error');
expect(error.code).to.equal('ERR_TLS_DISTRUSTED_INTERMEDIATE');
});

it("compares names case-insensitively with whitespace collapsed", () => {
const cert = peerChain(
{ subject: { CN: 'Test Intermediate', O: 'Test', C: 'US' }, raw: NOT_A_CERTIFICATE },
{ CN: ' em dv tls ca - g3b', O: 'EMUDHRA BV', C: 'nl' }
);

expect(tls.checkServerIdentity('example.com', cert).code).to.equal('ERR_TLS_DISTRUSTED_INTERMEDIATE');
});

it("refuses a chain carrying a distrusted key under another name", () => {
const raw = Buffer.from(DISTRUSTED_INTERMEDIATE_PEM.replace(/-----[^-]+-----|\s/g, ''), 'base64');
const cert = peerChain({ subject: { CN: 'Test Intermediate', O: 'Test', C: 'US' }, raw });

expect(tls.checkServerIdentity('example.com', cert).code).to.equal('ERR_TLS_DISTRUSTED_INTERMEDIATE');
});

it("allows a chain without a distrusted intermediate", () => {
const cert = peerChain({ subject: { CN: 'Test Intermediate', O: 'Test', C: 'US' }, raw: NOT_A_CERTIFICATE });

expect(tls.checkServerIdentity('example.com', cert)).to.be.undefined;
});

it("returns the hostname error before checking the chain", () => {
const cert = peerChain({ subject: { CN: 'EM DV TLS CA - G3B', O: 'eMudhra BV', C: 'NL' }, raw: NOT_A_CERTIFICATE });

expect(tls.checkServerIdentity('other.com', cert).code).to.equal('ERR_TLS_CERT_ALTNAME_INVALID');
});

it("does not wrap tls.checkServerIdentity a second time", () => {
const wrapped = tls.checkServerIdentity;

expect(installDistrustedCaCheck()).to.equal(false);
expect(tls.checkServerIdentity).to.equal(wrapped);
});
});
Loading