Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
79 changes: 68 additions & 11 deletions packages/devtools-proxy-support/src/system-ca.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { expect } from 'chai';
import { removeCertificatesWithoutIssuer } from './system-ca';
import type { ParsedX509Cert } from './system-ca';
import {
parseCACerts,
removeCertificatesWithoutIssuer,
sortByExpirationDate,
} from './system-ca';

describe('removeCertificatesWithoutIssuer', function () {
it('removes certificates that do not have an issuer', function () {
const certs = [
`-----BEGIN CERTIFICATE-----
describe('system-ca helpers', function () {
describe('removeCertificatesWithoutIssuer', function () {
it('removes certificates that do not have an issuer', function () {
const certs = [
`-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkG
A1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUw
EwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBP
Expand Down Expand Up @@ -32,7 +38,7 @@ qKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcY
xn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----`,
`-----BEGIN CERTIFICATE-----
`-----BEGIN CERTIFICATE-----
MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow
Expand Down Expand Up @@ -63,12 +69,63 @@ WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O
he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC
Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5
-----END CERTIFICATE-----`,
];
expect(removeCertificatesWithoutIssuer(certs)).to.deep.equal({
ca: [certs[0]],
messages: [
];
const messages = [];
const filtered = removeCertificatesWithoutIssuer(
parseCACerts(certs, messages),
messages
);
expect(
filtered.map((cert) => {
return cert.pem;
})
).to.deep.equal([certs[0]]);
expect(messages).to.deep.eq([
`Removing certificate for 'C=US\nO=Internet Security Research Group\nCN=ISRG Root X1' because issuer 'O=Digital Signature Trust Co.\nCN=DST Root CA X3' could not be found (serial no '4001772137D4E942B8EE76AA3C640AB7')`,
],
]);
});
});

describe('sortByExpirationDate', function () {
it('pushes all expired certs to the bottom', function () {
const mockCerts = [
{
serialNumber: '01',
validTo: new Date(Date.now() + 10_000).toUTCString(),
},
{
serialNumber: '02',
validTo: new Date(Date.now() - 60_000).toUTCString(),
},
{
serialNumber: '03',
validTo: new Date(Date.now() - 50_000).toUTCString(),
},
{
serialNumber: '04',
validTo: new Date(Date.now() + 30_000).toUTCString(),
},
{
serialNumber: '05',
validTo: new Date(Date.now() + 20_000).toUTCString(),
},
{
serialNumber: '06',
validTo: new Date(Date.now() + 20_000).toUTCString(),
},
];

const sorted = sortByExpirationDate(
mockCerts.map((parsed) => {
return { pem: '', parsed } as ParsedX509Cert;
})
);

expect(
sorted.map((cert) => {
return cert.parsed?.serialNumber;
})
).to.deep.eq(['04', '05', '06', '01', '03', '02']);
});
});
});
157 changes: 105 additions & 52 deletions packages/devtools-proxy-support/src/system-ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,50 +31,39 @@ function systemCertsCached(systemCAOpts: SystemCAOptions = {}): Promise<{
return systemCertsCachePromise;
}

function certToString(cert: string | Uint8Array) {
return typeof cert === 'string'
? cert
: Buffer.from(cert.buffer, cert.byteOffset, cert.byteLength).toString(
'utf8'
);
}

export function mergeCA(...args: (NodeJSCAOption | undefined)[]): string {
const ca = new Set<string>();
for (const item of args) {
if (!item) continue;
const caList: readonly (string | Uint8Array)[] = Array.isArray(item)
? item
: [item];
const caList = Array.isArray(item) ? item : [item];
for (const cert of caList) {
const asString =
typeof cert === 'string'
? cert
: Buffer.from(cert.buffer, cert.byteOffset, cert.byteLength).toString(
'utf8'
);
ca.add(asString);
ca.add(certToString(cert));
}
}
return [...ca].join('\n');
}

const pemWithParsedCache = new WeakMap<
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This cache is still here, just got moved down with the function and renamed to clarify the purpose

string[],
{
ca: string[];
messages: string[];
}
>();
// TODO(COMPASS-8253): Remove this in favor of OpenSSL's X509_V_FLAG_PARTIAL_CHAIN
// See linked tickets for details on why we need this (tl;dr: the system certificate
// store may contain intermediate certficiates without the corresponding trusted root,
// and OpenSSL does not seem to accept that)
export function removeCertificatesWithoutIssuer(ca: string[]): {
ca: string[];
messages: string[];
} {
let result:
| {
ca: string[];
messages: string[];
}
| undefined = pemWithParsedCache.get(ca);
export type ParsedX509Cert = { pem: string; parsed: X509Certificate | null };

const messages: string[] = [];
let caWithParsedCerts = ca.map((pem) => {
/**
* Safely parse provided certs, push any encountered errors to the provided
* messages array
*/
export function parseCACerts(
Copy link
Collaborator Author

@gribnoysup gribnoysup Sep 20, 2024

Choose a reason for hiding this comment

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

Split parsing out of removing function so that I can parse once, then optionally remove the ones with missing issuer, then use the same parsed certs when sorting without loosing the parsed certs inside the "removing" function

ca: NodeJSCAOption,
messages: string[]
): ParsedX509Cert[] {
ca = Array.isArray(ca) ? ca : [ca];
return ca.map((cert) => {
const pem = certToString(cert);
let parsed: X509Certificate | null = null;
try {
parsed = new X509Certificate(pem);
Expand All @@ -89,23 +78,84 @@ export function removeCertificatesWithoutIssuer(ca: string[]): {
}
return { pem, parsed };
});
caWithParsedCerts = caWithParsedCerts.filter(({ parsed }) => {
const keep =
!parsed ||
parsed.checkIssued(parsed) ||
caWithParsedCerts.find(
({ parsed: issuer }) => issuer && parsed.checkIssued(issuer)
);
if (!keep) {
messages.push(
}

function doesCertificateHasMatchingIssuer(
Copy link
Collaborator

Choose a reason for hiding this comment

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

grammar nit: have, not has :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

TY! Will fix

Copy link
Collaborator

@lerouxb lerouxb Sep 24, 2024

Choose a reason for hiding this comment

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

so either doesCertificateHaveMatchingIssuer or certificateHasMatchingIssuer.

{ parsed }: ParsedX509Cert,
certs: ParsedX509Cert[]
) {
return (
!parsed ||
Copy link
Collaborator

Choose a reason for hiding this comment

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

So if parsed is falsey we return that it does have a matching issuer?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I suppose that's old code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I'm just keeping it as-is, but FWIW Annas reasoning in the PR that introduced it seems fair to me. I'll add it as a comment here so that we don't need to track down the PR every time

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ended up moving the !parsed check outside of the method just so this function name clearly describes and matches what it does. Moved the not parsed check to the filter itself and added a comment, seems a bit cleaner that way?

parsed.checkIssued(parsed) ||
certs.some(({ parsed: issuer }) => {
return issuer && parsed.checkIssued(issuer);
})
);
}

const withRemovedMissingIssuerCache = new WeakMap<
ParsedX509Cert[],
{
ca: ParsedX509Cert[];
messages: string[];
}
>();

// TODO(COMPASS-8253): Remove this in favor of OpenSSL's X509_V_FLAG_PARTIAL_CHAIN
// See linked tickets for details on why we need this (tl;dr: the system certificate
// store may contain intermediate certficiates without the corresponding trusted root,
// and OpenSSL does not seem to accept that)
export function removeCertificatesWithoutIssuer(
ca: ParsedX509Cert[],
messages: string[]
): ParsedX509Cert[] {
const result:
| {
ca: ParsedX509Cert[];
messages: string[];
}
| undefined = withRemovedMissingIssuerCache.get(ca);

if (result) {
messages.push(...result.messages);
return result.ca;
}

const _messages: string[] = [];
const filteredCAlist = ca.filter((cert) => {
const keep = doesCertificateHasMatchingIssuer(cert, ca);
if (!keep && cert.parsed) {
const { parsed } = cert;
_messages.push(
`Removing certificate for '${parsed.subject}' because issuer '${parsed.issuer}' could not be found (serial no '${parsed.serialNumber}')`
);
}
return keep;
});
result = { ca: caWithParsedCerts.map(({ pem }) => pem), messages };
pemWithParsedCache.set(ca, result);
return result;
withRemovedMissingIssuerCache.set(ca, {
ca: filteredCAlist,
messages: _messages,
});
messages.push(..._messages);
return filteredCAlist;
}

/**
* Sorts cerificates by the Not After value. Items that are higher in the list
* get picked up first by the CA issuer finding logic
*
* @see {@link https://jira.mongodb.org/browse/COMPASS-8322}
*/
export function sortByExpirationDate(ca: ParsedX509Cert[]) {
return ca.slice().sort((a, b) => {
if (!a.parsed || !b.parsed) {
return 0;
}
return (
new Date(b.parsed.validTo).getTime() -
new Date(a.parsed.validTo).getTime()
);
});
}

// Thin wrapper around system-ca, which merges:
Expand Down Expand Up @@ -135,11 +185,14 @@ export async function systemCA(

let systemCertsError: Error | undefined;
let asyncFallbackError: Error | undefined;
let systemCerts: string[] = [];
let messages: string[] = [];
let systemCerts: ParsedX509Cert[] = [];

const messages: string[] = [];

try {
({ certs: systemCerts, asyncFallbackError } = await systemCertsCached());
const systemCertsResult = await systemCertsCached();
asyncFallbackError = systemCertsResult.asyncFallbackError;
systemCerts = parseCACerts(systemCertsResult.certs, messages);
} catch (err: any) {
systemCertsError = err;
}
Expand All @@ -150,14 +203,14 @@ export async function systemCA(
!!process.env.DEVTOOLS_ALLOW_CERTIFICATES_WITHOUT_ISSUER
)
) {
const reducedList = removeCertificatesWithoutIssuer(systemCerts);
systemCerts = reducedList.ca;
messages = messages.concat(reducedList.messages);
systemCerts = removeCertificatesWithoutIssuer(systemCerts, messages);
}

return {
ca: mergeCA(
Copy link
Collaborator Author

@gribnoysup gribnoysup Sep 23, 2024

Choose a reason for hiding this comment

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

Not something I'm planning to pursue right now, but assuming openssl doesn't fail when using the same CA list that we used to repro the issue because it is doing the sorting somewhere internally and Node.js doesn't, it seems like we might want to sort all the options combined, and not only the ones coming from system CA, but I'm not feeling confident enough to do this change right now, would like to confirm exactly why this is not happening when trying the same connection with openssl directly and not with Node.js tls module

systemCerts,
sortByExpirationDate(systemCerts).map((cert) => {
return cert.pem;
}),
rootCertificates,
existingOptions.ca,
await readTLSCAFilePromise
Expand Down
Loading