@@ -31,54 +31,47 @@ function systemCertsCached(systemCAOpts: SystemCAOptions = {}): Promise<{
3131 return systemCertsCachePromise ;
3232}
3333
34+ function certToString ( cert : string | Uint8Array ) {
35+ return typeof cert === 'string'
36+ ? cert
37+ : Buffer . from ( cert . buffer , cert . byteOffset , cert . byteLength ) . toString (
38+ 'utf8'
39+ ) ;
40+ }
41+
3442export function mergeCA ( ...args : ( NodeJSCAOption | undefined ) [ ] ) : string {
3543 const ca = new Set < string > ( ) ;
3644 for ( const item of args ) {
3745 if ( ! item ) continue ;
38- const caList : readonly ( string | Uint8Array ) [ ] = Array . isArray ( item )
39- ? item
40- : [ item ] ;
46+ const caList = Array . isArray ( item ) ? item : [ item ] ;
4147 for ( const cert of caList ) {
42- const asString =
43- typeof cert === 'string'
44- ? cert
45- : Buffer . from ( cert . buffer , cert . byteOffset , cert . byteLength ) . toString (
46- 'utf8'
47- ) ;
48- ca . add ( asString ) ;
48+ ca . add ( certToString ( cert ) ) ;
4949 }
5050 }
5151 return [ ...ca ] . join ( '\n' ) ;
5252}
5353
54- const pemWithParsedCache = new WeakMap <
55- string [ ] ,
56- {
57- ca : string [ ] ;
58- messages : string [ ] ;
59- }
60- > ( ) ;
61- // TODO(COMPASS-8253): Remove this in favor of OpenSSL's X509_V_FLAG_PARTIAL_CHAIN
62- // See linked tickets for details on why we need this (tl;dr: the system certificate
63- // store may contain intermediate certficiates without the corresponding trusted root,
64- // and OpenSSL does not seem to accept that)
65- export function removeCertificatesWithoutIssuer ( ca : string [ ] ) : {
66- ca : string [ ] ;
67- messages : string [ ] ;
68- } {
69- let result :
70- | {
71- ca : string [ ] ;
72- messages : string [ ] ;
73- }
74- | undefined = pemWithParsedCache . get ( ca ) ;
54+ export type ParsedX509Cert = { pem : string ; parsed : X509Certificate | null } ;
7555
76- const messages : string [ ] = [ ] ;
77- let caWithParsedCerts = ca . map ( ( pem ) => {
56+ /**
57+ * Safely parse provided certs, push any encountered errors to the provided
58+ * messages array
59+ */
60+ export function parseCACerts (
61+ ca : NodeJSCAOption ,
62+ messages : string [ ]
63+ ) : ParsedX509Cert [ ] {
64+ ca = Array . isArray ( ca ) ? ca : [ ca ] ;
65+ return ca . map ( ( cert ) => {
66+ const pem = certToString ( cert ) ;
7867 let parsed : X509Certificate | null = null ;
7968 try {
8069 parsed = new X509Certificate ( pem ) ;
8170 } catch ( err : unknown ) {
71+ // Most definitely should happen never or extremely rarely, in case it
72+ // does, if this cert will affect the TLS connection verification, the
73+ // connection will most definitely fail and we'll see it in the logs. For
74+ // that reason we're just logging, but not throwing an error here
8275 messages . push (
8376 `Unable to parse certificate: ${
8477 err && typeof err === 'object' && 'message' in err
@@ -89,23 +82,87 @@ export function removeCertificatesWithoutIssuer(ca: string[]): {
8982 }
9083 return { pem, parsed } ;
9184 } ) ;
92- caWithParsedCerts = caWithParsedCerts . filter ( ( { parsed } ) => {
93- const keep =
94- ! parsed ||
95- parsed . checkIssued ( parsed ) ||
96- caWithParsedCerts . find (
97- ( { parsed : issuer } ) => issuer && parsed . checkIssued ( issuer )
98- ) ;
99- if ( ! keep ) {
100- messages . push (
85+ }
86+
87+ function certificateHasMatchingIssuer (
88+ cert : X509Certificate ,
89+ certs : ParsedX509Cert [ ]
90+ ) {
91+ return (
92+ cert . checkIssued ( cert ) ||
93+ certs . some ( ( { parsed : issuer } ) => {
94+ return issuer && cert . checkIssued ( issuer ) ;
95+ } )
96+ ) ;
97+ }
98+
99+ const withRemovedMissingIssuerCache = new WeakMap <
100+ ParsedX509Cert [ ] ,
101+ {
102+ ca : ParsedX509Cert [ ] ;
103+ messages : string [ ] ;
104+ }
105+ > ( ) ;
106+
107+ // TODO(COMPASS-8253): Remove this in favor of OpenSSL's X509_V_FLAG_PARTIAL_CHAIN
108+ // See linked tickets for details on why we need this (tl;dr: the system certificate
109+ // store may contain intermediate certficiates without the corresponding trusted root,
110+ // and OpenSSL does not seem to accept that)
111+ export function removeCertificatesWithoutIssuer (
112+ ca : ParsedX509Cert [ ] ,
113+ messages : string [ ]
114+ ) : ParsedX509Cert [ ] {
115+ const result :
116+ | {
117+ ca : ParsedX509Cert [ ] ;
118+ messages : string [ ] ;
119+ }
120+ | undefined = withRemovedMissingIssuerCache . get ( ca ) ;
121+
122+ if ( result ) {
123+ messages . push ( ...result . messages ) ;
124+ return result . ca ;
125+ }
126+
127+ const _messages : string [ ] = [ ] ;
128+ const filteredCAlist = ca . filter ( ( cert ) => {
129+ // If cert was not parsed, we want to keep it in the list. The case should
130+ // be generally very rare, but in case it happens and this cert will affect
131+ // the TLS handshake, it will show up in the logs as the connection error
132+ // anyway, so it's safe to keep it
133+ const keep = ! cert . parsed || certificateHasMatchingIssuer ( cert . parsed , ca ) ;
134+ if ( ! keep && cert . parsed ) {
135+ const { parsed } = cert ;
136+ _messages . push (
101137 `Removing certificate for '${ parsed . subject } ' because issuer '${ parsed . issuer } ' could not be found (serial no '${ parsed . serialNumber } ')`
102138 ) ;
103139 }
104140 return keep ;
105141 } ) ;
106- result = { ca : caWithParsedCerts . map ( ( { pem } ) => pem ) , messages } ;
107- pemWithParsedCache . set ( ca , result ) ;
108- return result ;
142+ withRemovedMissingIssuerCache . set ( ca , {
143+ ca : filteredCAlist ,
144+ messages : _messages ,
145+ } ) ;
146+ messages . push ( ..._messages ) ;
147+ return filteredCAlist ;
148+ }
149+
150+ /**
151+ * Sorts cerificates by the Not After value. Items that are higher in the list
152+ * get picked up first by the CA issuer finding logic
153+ *
154+ * @see {@link https://jira.mongodb.org/browse/COMPASS-8322 }
155+ */
156+ export function sortByExpirationDate ( ca : ParsedX509Cert [ ] ) {
157+ return ca . slice ( ) . sort ( ( a , b ) => {
158+ if ( ! a . parsed || ! b . parsed ) {
159+ return 0 ;
160+ }
161+ return (
162+ new Date ( b . parsed . validTo ) . getTime ( ) -
163+ new Date ( a . parsed . validTo ) . getTime ( )
164+ ) ;
165+ } ) ;
109166}
110167
111168// Thin wrapper around system-ca, which merges:
@@ -135,11 +192,14 @@ export async function systemCA(
135192
136193 let systemCertsError : Error | undefined ;
137194 let asyncFallbackError : Error | undefined ;
138- let systemCerts : string [ ] = [ ] ;
139- let messages : string [ ] = [ ] ;
195+ let systemCerts : ParsedX509Cert [ ] = [ ] ;
196+
197+ const messages : string [ ] = [ ] ;
140198
141199 try {
142- ( { certs : systemCerts , asyncFallbackError } = await systemCertsCached ( ) ) ;
200+ const systemCertsResult = await systemCertsCached ( ) ;
201+ asyncFallbackError = systemCertsResult . asyncFallbackError ;
202+ systemCerts = parseCACerts ( systemCertsResult . certs , messages ) ;
143203 } catch ( err : any ) {
144204 systemCertsError = err ;
145205 }
@@ -150,14 +210,14 @@ export async function systemCA(
150210 ! ! process . env . DEVTOOLS_ALLOW_CERTIFICATES_WITHOUT_ISSUER
151211 )
152212 ) {
153- const reducedList = removeCertificatesWithoutIssuer ( systemCerts ) ;
154- systemCerts = reducedList . ca ;
155- messages = messages . concat ( reducedList . messages ) ;
213+ systemCerts = removeCertificatesWithoutIssuer ( systemCerts , messages ) ;
156214 }
157215
158216 return {
159217 ca : mergeCA (
160- systemCerts ,
218+ sortByExpirationDate ( systemCerts ) . map ( ( cert ) => {
219+ return cert . pem ;
220+ } ) ,
161221 rootCertificates ,
162222 existingOptions . ca ,
163223 await readTLSCAFilePromise
0 commit comments