@blamejs/pki 0.8.7 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,772 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+
5
+ var net = require("net");
6
+ var asn1 = require("./asn1-der");
7
+ var constants = require("./constants");
8
+ var oid = require("./oid");
9
+ var guard = require("./guard-all");
10
+ var intrinsic = require("./guard-intrinsic");
11
+ var frameworkError = require("./framework-error");
12
+ var pkix = require("./schema-pkix");
13
+ var ocsp = require("./ocsp");
14
+ var crlSchema = require("./schema-crl");
15
+ var crlVerify = require("./crl-verify");
16
+ var httpTransport = require("./http-transport");
17
+ var mime = require("./mime");
18
+ var pathValidate = require("./path-validate");
19
+ var ocspSchema = require("./schema-ocsp");
20
+
21
+ var PathError = frameworkError.PathError;
22
+ function E(code, message, cause) { return new PathError(code, message, cause); }
23
+
24
+ var NS = pkix.makeNS("path", PathError, oid);
25
+ var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
26
+
27
+ var OID_CDP = oid.byName("cRLDistributionPoints");
28
+ var OID_FRESHEST = oid.byName("freshestCRL");
29
+ var OID_AIA = oid.byName("authorityInfoAccess");
30
+ var OID_AD_OCSP = oid.byName("ocsp");
31
+ var OID_OCSP_NONCE = oid.byName("ocspNonce");
32
+
33
+ var _append = guard.list.append;
34
+ var LIMITS = constants.LIMITS;
35
+
36
+ var CHECKER_OPTS = intrinsic.assign(intrinsic.create(null), {
37
+ transport: 1, allowPlaintextHttp: 1, useDeltas: 1, nonce: 1, tls: 1,
38
+ maxFetches: 1, maxPerCert: 1, maxResponseBytes: 1, totalDeadlineMs: 1,
39
+ sourceTimeoutMs: 1, retries: 1, toleranceMs: 1, cache: 1,
40
+ });
41
+
42
+ /** @internal A candidate is `{ kind, url, reasons }`, where `kind` says which parser the body goes
43
+ * to. The structure is decided by where the URL came from and never by what came back, so a
44
+ * Content-Type header cannot choose a parser. */
45
+ var KIND_CRL = "crl";
46
+ var KIND_OCSP = "ocsp";
47
+
48
+ var TRANSPORT_SHAPE = "(request) => Promise<{ status, headers, body }>";
49
+
50
+ /** @internal An extension the certificate may not carry, and may carry malformed. A decode fault is
51
+ * a reason rather than a throw, because this runs inside a checker whose every outcome is a
52
+ * verdict (RFC 5280 sec. 6.3.3 closing paragraph: an undetermined status, never an unrevoked one). */
53
+ /** @internal An extension that does not decode names no URL, which is not the same as naming none:
54
+ * a certificate carrying an unreadable cRLDistributionPoints has a revocation location an operator
55
+ * cannot reach, and one carrying no such extension has none to reach. Both end at an undetermined
56
+ * status, so the difference lives in the reason, and the refusal is recorded as a skip rather than
57
+ * swallowed. */
58
+ function _softDecode(holder, extOid, skips) {
59
+ var list = holder.extensions || holder.crlExtensions || [];
60
+ var found = guard.list.anyMatches(list, function (e) { return !!e && e.oid === extOid; })
61
+ ? _firstWithOid(list, extOid) : null;
62
+ if (found === null) return null;
63
+ try { return EXT_DECODERS[extOid](found.value); }
64
+ catch (e) {
65
+ if (skips) _append(skips, "the " + (oid.name(extOid) || extOid) + " extension does not decode: " + _say(e));
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function _firstWithOid(list, extOid) {
71
+ for (var i = 0; i < list.length; i++) {
72
+ if (list[i] && list[i].oid === extOid) return list[i];
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** @internal RFC 5280 sec. 4.2.1.13 classification, on the DER shape and never on a string. A
78
+ * distribution point is fetchable over HTTP only as a `fullName` carrying a `uniformResourceIdentifier`
79
+ * whose scheme this client speaks. Everything else is a skip with its reason named, which is what
80
+ * keeps "no fetchable distribution point" distinct from "the fetch failed". */
81
+ function _crlCandidates(points, net_, skips) {
82
+ var out = [];
83
+ if (!intrinsic.isArray(points)) return out;
84
+ for (var i = 0; i < points.length; i++) {
85
+ var dp = points[i];
86
+ if (!dp.distributionPoint) {
87
+ _append(skips, dp.cRLIssuer ? "a distribution point names a cRLIssuer and no location" :
88
+ "a distribution point names no location");
89
+ continue;
90
+ }
91
+ if (dp.distributionPoint.kind !== "fullName") {
92
+ _append(skips, "a distribution point uses nameRelativeToCRLIssuer, which names no location");
93
+ continue;
94
+ }
95
+ var names = dp.distributionPoint.names || [];
96
+ var tookOne = false;
97
+ for (var n = 0; n < names.length; n++) {
98
+ var gn = _generalName(names[n]);
99
+ if (gn === null || gn.tag !== 6) { _append(skips, "a distribution point name is not a URI"); continue; }
100
+ var got = _acceptableUrl(gn.value, net_);
101
+ if (got.url === null) { _append(skips, "a distribution point URI " + got.why + ": " + guard.text.showValue(gn.value)); continue; }
102
+ _append(out, { kind: KIND_CRL, url: got.url, reasons: dp.reasons || null });
103
+ tookOne = true;
104
+ }
105
+ if (!tookOne && names.length === 0) _append(skips, "a distribution point carries an empty fullName");
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** @internal The decoded fullName entry is the GeneralName bytes; the decoder surfaces the value
111
+ * when it was asked to decode one, and the raw bytes when it was not. Both shapes are read here so
112
+ * the caller of this module does not have to know which drive the extension took. */
113
+ function _generalName(entry) {
114
+ if (!entry) return null;
115
+ if (typeof entry === "object" && typeof entry.tag === "number") return { tag: entry.tag, value: entry.value };
116
+ if (typeof entry === "object" && typeof entry.tagNumber === "number") return { tag: entry.tagNumber, value: entry.value };
117
+ if (guard.bytes.isByteSource(entry)) {
118
+ /** @internal The fullName entries arrive as the GeneralName's own DER, so the tag and the
119
+ * content come from the codec. Reading the length by hand would be right only while every URI
120
+ * stayed under 128 bytes. */
121
+ var node;
122
+ try { node = asn1.decode(guard.bytes.source(entry, PathError, "path/bad-input", "a distribution point name")); }
123
+ catch (_e) { return null; } // allow:swallow-unverified a name that does not decode names no location
124
+ if (node.tagClass !== "context") return null;
125
+ return { tag: node.tagNumber, value: intrinsic.bufToString(node.content, "latin1") };
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /** @internal RFC 5280 sec. 4.2.2.1 puts the responder at an AccessDescription whose accessMethod is
131
+ * id-ad-ocsp and whose accessLocation is a URI. No CRL URL is ever taken from this extension, which
132
+ * that section states outright. */
133
+ function _ocspCandidates(descriptions, net_, skips) {
134
+ var out = [];
135
+ if (!intrinsic.isArray(descriptions)) return out;
136
+ for (var i = 0; i < descriptions.length; i++) {
137
+ var ad = descriptions[i];
138
+ if (ad.accessMethod !== OID_AD_OCSP) continue;
139
+ if (!ad.accessLocation || ad.accessLocation.tag !== 6) {
140
+ _append(skips, "an id-ad-ocsp access location is not a URI (RFC 5280 sec. 4.2.2.1)");
141
+ continue;
142
+ }
143
+ var got = _acceptableUrl(ad.accessLocation.value, net_);
144
+ if (got.url === null) { _append(skips, "an id-ad-ocsp URI " + got.why + ": " + guard.text.showValue(ad.accessLocation.value)); continue; }
145
+ _append(out, { kind: KIND_OCSP, url: got.url, reasons: null });
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** @internal The URL this client will open, with the clause naming why it will not when it will
151
+ * not. The scheme decides first, then the SSRF rules the AIA route already applies: no private,
152
+ * loopback or link-local destination, and a hostname only where the transport filters the address
153
+ * it resolved. The fragment is dropped, since it is never sent.
154
+ *
155
+ * An address literal is decided here, because the destination is in the URL. A name is not: what
156
+ * it resolves to is decided inside whoever opens the socket, so a certificate naming
157
+ * `crl.internal.example` reaches a private network unless that resolution is filtered and pinned.
158
+ * The transport `pki.transport.https(defaults)` returns declares `blocksPrivateAddresses` and does
159
+ * both; a transport that declares nothing is never handed a name. The declaration is read off the
160
+ * transport's own slot, because `Function.prototype` carries whatever has been written to it for
161
+ * every function in the process. */
162
+ function _acceptableUrl(value, net_) {
163
+ if (typeof value !== "string" || value === "") return _no("is not one this client fetches");
164
+ var u;
165
+ try { u = new URL(value); }
166
+ catch (_e) { return _no("is not one this client fetches"); } // allow:swallow-unverified a value that is not a URL names no location
167
+ if (u.protocol !== "https:" && !(net_.allowHttp && u.protocol === "http:")) return _no("is not one this client fetches");
168
+ var host = u.hostname;
169
+ if (host.charAt(0) === "[" && host.charAt(host.length - 1) === "]") host = host.slice(1, -1);
170
+ if (net.isIP(host) !== 0) {
171
+ if (httpTransport.isBlockedIp(host)) return _no("is not one this client fetches");
172
+ } else if (!net_.guardsAddresses) {
173
+ return _no("names a host whose resolved address opts.transport does not declare it filters");
174
+ }
175
+ u.hash = "";
176
+ return { url: u.href, why: null };
177
+ }
178
+
179
+ function _no(why) { return { url: null, why: why }; }
180
+
181
+ /** @internal A switch that turns a security control on is read as the boolean it documents, never
182
+ * coerced. `nonce: "true"` written by an operator asking for replay binding would read as off
183
+ * under a `=== true` test, and the requests would go out carrying no nonce with nothing said. An
184
+ * absent value is the documented default; anything else is a configuration fault. */
185
+ function _booleanOption(value, label) {
186
+ if (value === undefined || value === null) return false;
187
+ if (value === true || value === false) return value;
188
+ throw E("path/bad-input", label + " must be true or false, got " + guard.text.showValue(value));
189
+ }
190
+
191
+ /** @internal Whether this transport says it filters the address it resolved. A transport is a
192
+ * function, so the flag is asked of the slot rather than of the chain: reading it through the
193
+ * transport finds a value on `Function.prototype`, which every function in the process carries and
194
+ * which nobody who wrote a transport declared. What the flag buys is a hostname destination taken
195
+ * from a certificate, so an undeclared transport being read as declaring it is the whole guard.
196
+ * @guard-via guard.identifier.declaresOwn */
197
+ function _declaresAddressGuard(transport) {
198
+ return guard.identifier.declaresOwn(transport, "blocksPrivateAddresses");
199
+ }
200
+
201
+ /** @internal Reuse is decided from the signed window and from nothing else: RFC 5019 sec. 7.5 has a
202
+ * client rely on the values in the signed response, and sec. 6.1 admits only a response whose
203
+ * signature validated. The HTTP caching headers are not read here at all, so they can neither
204
+ * extend an entry's life nor end it.
205
+ *
206
+ * Two key shapes, kept apart on purpose. An OCSP entry is keyed on the CertID tuple and the
207
+ * responder URL it was asked of; a CRL entry on the request URL and the encoded issuer name of the
208
+ * CRL that came back. Neither is the URL the socket was opened on, and neither is the encoded name
209
+ * the sec. 6.3.3(b) distribution point match reads, so a cache hit can never stand in for a name
210
+ * match. */
211
+ function _makeCache(maxEntries, maxBytes) {
212
+ var entries = new Map();
213
+ var bytes = 0;
214
+ function evictOldest() {
215
+ var it = entries.keys().next();
216
+ if (it.done) return;
217
+ var old = entries.get(it.value);
218
+ bytes -= old ? old.size : 0;
219
+ entries.delete(it.value);
220
+ }
221
+ return {
222
+ get: function (key, at) {
223
+ var e = entries.get(key);
224
+ if (!e) return null;
225
+ if (!(at < e.notAfter) || !(at >= e.notBefore)) { bytes -= e.size; entries.delete(key); return null; }
226
+ return e.value;
227
+ },
228
+ put: function (key, value, notBefore, notAfter, size) {
229
+ if (!(notAfter instanceof Date) || !(notBefore instanceof Date)) return;
230
+ if (size > maxBytes) return;
231
+ if (entries.has(key)) { bytes -= entries.get(key).size; entries.delete(key); }
232
+ while (entries.size >= maxEntries || bytes + size > maxBytes) {
233
+ if (entries.size === 0) return;
234
+ evictOldest();
235
+ }
236
+ entries.set(key, { value: value, notBefore: notBefore, notAfter: notAfter, size: size });
237
+ bytes += size;
238
+ },
239
+ size: function () { return entries.size; },
240
+ bytes: function () { return bytes; },
241
+ };
242
+ }
243
+
244
+ function _ocspCacheKey(certId, url) {
245
+ return "ocsp|" + url + "|" + certId.hashAlgorithm.oid + "|" +
246
+ intrinsic.bufToString(certId.issuerNameHash, "hex") + "|" +
247
+ intrinsic.bufToString(certId.issuerKeyHash, "hex") + "|" + certId.serialNumberHex;
248
+ }
249
+
250
+ /** @internal What identifies a kept list: the URL it came from, the issuer name it is about, and
251
+ * the key that has to verify it. A CA that rotates its key keeps both its name and its CRL URL,
252
+ * so neither tells the old list from the new one; an entry found on those two alone would be
253
+ * handed to a certificate under the new key, fail its signature check, and leave the status
254
+ * undetermined with no request made until the entry aged out. */
255
+ function _crlCacheKey(url, issuerBytes, keyBytes) {
256
+ return "crl|" + url + "|" + intrinsic.bufToString(issuerBytes, "hex") +
257
+ "|" + intrinsic.bufToString(guard.bytes.source(keyBytes, PathError, "path/bad-input", "the issuer public key"), "hex");
258
+ }
259
+
260
+ /** @internal One request. A non-200 is a refusal and no redirect is followed, which matches the AIA
261
+ * route: a 3xx would move the fetch to a location the certificate never named. The body is held to
262
+ * the response cap before anything parses it. */
263
+ async function _fetchOne(state, req, what) {
264
+ if (state.deadline.expired()) throw E("path/revocation-fetch-failed", "the revocation deadline passed before " + what);
265
+ state.fetches.tick();
266
+ var timeout = state.sourceTimeoutMs;
267
+ var left = state.deadline.remaining();
268
+ if (left < timeout) timeout = left;
269
+ /** @internal The `timeout` field asks the transport to stop; the bound here makes it stop. A
270
+ * transport is caller code and need not implement the field, and one that never settles would
271
+ * otherwise hold the validation open with no deadline reached, since the deadline is only read
272
+ * between requests. The bound is whatever the deadline has left, so the totals an operator set
273
+ * hold whatever the transport does. */
274
+ var res = await guard.async.bounded(
275
+ guard.async.invoked(state.transport,
276
+ [{ method: req.method, url: req.url, headers: req.headers, body: req.body, tls: state.tls,
277
+ timeout: timeout, maxResponseBytes: state.maxResponseBytes, blockPrivateAddresses: true,
278
+ allowPlaintextHttp: state.allowPlaintextHttp }],
279
+ E, "path/bad-input", "opts.transport"),
280
+ timeout, E, "path/revocation-fetch-failed", what);
281
+ res = res || {};
282
+ if (res.status !== 200) {
283
+ throw E("path/revocation-fetch-failed", what + " returned HTTP " + res.status + " (only 200 carries a revocation object; no redirect is followed)");
284
+ }
285
+ var body = guard.bytes.isByteSource(res.body)
286
+ ? guard.bytes.source(res.body, PathError, "path/revocation-fetch-failed", "the revocation response body")
287
+ : Buffer.from(res.body == null ? "" : String(res.body), "latin1");
288
+ if (body.length === 0) throw E("path/revocation-fetch-failed", what + " returned an empty body");
289
+ if (body.length > state.maxResponseBytes) {
290
+ throw E("path/revocation-fetch-failed", what + " returned " + body.length + " bytes, over the " + state.maxResponseBytes + "-byte cap");
291
+ }
292
+ return { body: body, contentType: _contentType(res.headers) };
293
+ }
294
+
295
+ function _contentType(headers) {
296
+ var h = headers || {};
297
+ var keys = Object.keys(h);
298
+ for (var i = 0; i < keys.length; i++) {
299
+ if (keys[i].toLowerCase() !== "content-type") continue;
300
+ try { return mime.parse(String(h[keys[i]])).type; }
301
+ catch (_e) { return null; } // allow:swallow-unverified an unreadable media type is reported as absent, and it never selects a parser
302
+ }
303
+ return null;
304
+ }
305
+
306
+ /** @internal One responder. The request is the RFC 5019 lightweight profile, so one Request, SHA-1
307
+ * CertID hashes and no singleRequestExtensions, and the HTTP shape is decided by sec. 5. The
308
+ * verdict is `pathValidate.verifyOcspResponse`, which is the single acceptance gate: the CertID
309
+ * match, the responder authorization including the delegate path, the signature and the currency
310
+ * all live there, and none of them is restated here. */
311
+ async function _tryOcsp(state, cert, issuer, ctx, candidate) {
312
+ var issuerCert = issuer.issuerCert;
313
+ if (!issuerCert) throw E("path/revocation-fetch-failed", "an OCSP request needs the issuer certificate, which this path does not carry");
314
+ var der = await ocsp.buildRequest({ cert: cert.bytes || cert, issuer: issuerCert.bytes || issuerCert },
315
+ { profile: "lightweight", nonce: state.nonce });
316
+ var parsedReq = state.parseRequest(der);
317
+ var certId = parsedReq.requestList[0].certID;
318
+ var key = _ocspCacheKey(certId, candidate.url);
319
+ /** @internal A nonce asks this responder this question now, and every request carries a fresh
320
+ * one, so no response produced before this request can answer it. Reading the cache would hand
321
+ * back the previous response, whose nonce is the previous one, and the check below would refuse
322
+ * it: the cache would turn a working responder into a permanent undetermined. RFC 5019 sec. 6.1
323
+ * caches for the lightweight profile, which sec. 5 defines without a nonce, so the two are
324
+ * alternatives rather than a pair. */
325
+ var hit = state.nonce ? null : state.cache.get(key, ctx.time);
326
+ if (hit === null) {
327
+ var http = ocsp.httpRequest(der, candidate.url);
328
+ var got = await _fetchOne(state, http, "an OCSP request to " + candidate.url);
329
+ if (got.contentType !== null && got.contentType !== "application/ocsp-response") {
330
+ _append(state.notes, "the OCSP response carried content-type " + guard.text.showValue(got.contentType));
331
+ }
332
+ hit = got.body;
333
+ }
334
+ var v = await state.verifyOcspResponse(hit, cert, issuerCert, ctx.time,
335
+ { historicalMode: ctx.historicalMode === true, requestNonce: state.nonce ? _requestNonce(parsedReq) : undefined });
336
+ /** @internal `valid` on that verdict means the certificate is good, not that the response was
337
+ * acceptable, so reading it as acceptance would turn a `revoked` answer into an undetermined
338
+ * one that `opts.softFail` could then waive. Acceptance is the three conditions the gate
339
+ * reports: the CertID matched, the signature verified, and the responder was authorized
340
+ * (RFC 6960 sec. 3.2). The status is then whatever the responder said. */
341
+ var accepted = !!v && v.matched === true && v.signatureValid === true && v.responderAuthorized === true;
342
+ if (!accepted) {
343
+ throw E("path/revocation-fetch-failed", "the OCSP response was not accepted: " + ((v && v.reason) || "no reason given"));
344
+ }
345
+ if (v.status !== "good" && v.status !== "revoked") {
346
+ throw E("path/revocation-fetch-failed", "the OCSP responder answered " + guard.text.showValue(v.status) +
347
+ ((v && v.reason) ? ": " + v.reason : ""));
348
+ }
349
+ /** @internal RFC 5019 sec. 6.1 stores only an authoritative response, and sec. 4 refuses one with
350
+ * no nextUpdate, so the window an entry is reused within always exists by the time this runs. A
351
+ * nonce-bearing response is not stored, since nothing would ever read it back. */
352
+ if (!state.nonce && v.nextUpdate && v.thisUpdate) {
353
+ state.cache.put(key, hit, v.thisUpdate, v.nextUpdate, hit.length);
354
+ }
355
+ return { status: v.status, reason: "an OCSP response from " + candidate.url,
356
+ revocationReason: v.revocationReason, revocationTime: v.revocationTime };
357
+ }
358
+
359
+ /** @internal The bytes the nonce extension carries, which is what the response echoes. The
360
+ * extension's own `value` is those bytes inside their OCTET STRING, so comparing it would never
361
+ * match and the check would report every response as unechoed. */
362
+ function _requestNonce(parsedReq) {
363
+ var exts = parsedReq.requestExtensions || [];
364
+ for (var i = 0; i < exts.length; i++) {
365
+ if (exts[i].oid === OID_OCSP_NONCE) return exts[i].nonce || undefined;
366
+ }
367
+ return undefined;
368
+ }
369
+
370
+ /** @internal A cache entry stands in for a fetch for as long as the object's own nextUpdate says,
371
+ * so whatever is admitted is trusted until then and a forged list with a distant nextUpdate would
372
+ * silence the real one for that whole window. Admission is therefore decided about the object being
373
+ * kept: its signature has to verify under the key the path is working with, checked by the same
374
+ * verb `crlChecker` checks it with.
375
+ *
376
+ * The surrounding verdict cannot stand in for that. A base CRL and the delta it names are fetched
377
+ * in one pass and answered together, so a determinate answer there may have come from either, and
378
+ * the forged one would ride in on the genuine one's verdict. Scope and authorization are not
379
+ * decided here, because a cached object is handed back to `crlChecker` on every read and gated
380
+ * again; what the cache has to refuse is an object that is not the issuer's at all. */
381
+ function _keep(state, url, parsed, fetchedBytes, issuerNameBytes, keyBytes) {
382
+ if (!(fetchedBytes > 0) || !parsed.nextUpdate || !parsed.thisUpdate) return;
383
+ /** @internal Keyed on the name the CERTIFICATE carries, which is what the lookup above forms,
384
+ * and not on the name the CRL carries. The two are compared by the RFC 5280 sec. 7.1 canonical
385
+ * rule, so the same DN in another string encoding is accepted and still has different bytes:
386
+ * writing under the CRL's spelling would file the entry where nothing looks, and every
387
+ * validation would refetch inside a window the CRL had already signed for. */
388
+ state.crlCache.put(_crlCacheKey(url, issuerNameBytes, keyBytes), parsed, parsed.thisUpdate, parsed.nextUpdate, fetchedBytes);
389
+ }
390
+
391
+ /** @internal Whether this CRL was signed by the key the path is working with, asked with the verb
392
+ * `crlChecker` asks it with. It answers only that one question: scope, the cRLSign authorization
393
+ * and currency are the checker's, and a CRL that fails any of those is still the issuer's. */
394
+ async function _isIssuers(state, parsed, issuer) {
395
+ try { return (await crlVerify.verifyCrlSignature(parsed, issuer.workingPublicKey)) === true; }
396
+ catch (e) {
397
+ _append(state.notes, "a fetched CRL could not be checked against the issuer's key: " + _say(e));
398
+ return false;
399
+ }
400
+ }
401
+
402
+ /** @internal `maxPerCert` bounds the destinations ONE certificate can send this client to, so the
403
+ * set it counts spans every route: a certificate naming five responders and five distribution
404
+ * points chose ten destinations, and counting each route's own list would let it reach twice the
405
+ * figure. A URL already counted is free to be asked again, which is what a retry is. */
406
+ function _takeUrl(state, url) {
407
+ if (state.urls.has(url)) return true;
408
+ if (state.urls.size >= state.maxPerCert) return false;
409
+ state.urls.add(url);
410
+ return true;
411
+ }
412
+
413
+ /** @internal Every fetchable distribution point, until the CRLs gathered cover the certificate or
414
+ * the points run out. The acceptance rules are `crlChecker`'s: the issuer match, the cRLSign
415
+ * authorization, the IDP scope, the sec. 6.3.3(b) distribution point correspondence, the currency
416
+ * and the signature. This function decides which bytes to hand it and nothing else. */
417
+ async function _tryCrl(state, cert, issuer, ctx, candidates, certDeltas) {
418
+ /** @internal Counted and tried are different. `state.urls` counts the destinations one
419
+ * certificate names, across both routes, against `maxPerCert`; this set records what THIS walk
420
+ * has already asked, so a point named twice is asked once. A certificate naming one URL as its
421
+ * responder and as its distribution point is ordinary, and reading the counted set as the asked
422
+ * set drops the CRL route for it: the responder had counted the URL, so the fallback the OCSP
423
+ * route is documented to have never ran. */
424
+ var gathered = [], asked = new intrinsic.Set();
425
+ for (var i = 0; i < candidates.length; i++) {
426
+ if (state.deadline.expired()) break;
427
+ var url = candidates[i].url;
428
+ if (asked.has(url)) continue;
429
+ if (!_takeUrl(state, url)) break;
430
+ asked.add(url);
431
+ /** @internal A CRL is reused inside the window it signs for, the same rule the responses
432
+ * follow: RFC 5280 sec. 6.3.3(a)(1)(ii) has a replacement checked for currency, so a cached
433
+ * one is kept only while `thisUpdate <= now < nextUpdate`. The key carries the issuer name the
434
+ * CRL itself declared, which is a different string from the URL the socket was opened on and
435
+ * from the encoded name the sec. 6.3.3(b) match reads. */
436
+ /** @internal One unreachable location must not lose the others: RFC 5280 sec. 4.2.1.13 has a
437
+ * certificate name several distribution points so a client can try them, and a point whose
438
+ * fetch or parse fails is noted and the walk continues. Only the exhausted list is a verdict. */
439
+ /** @internal The key carries the issuer the certificate names, not the URL alone. One URL
440
+ * serving several issuers' lists is an ordinary hosting arrangement, and a key that ignores
441
+ * the issuer hands the first certificate's list to the next certificate and suppresses its
442
+ * fetch, so which certificate was validated first decides the second one's verdict. */
443
+ var parsed = state.crlCache.get(_crlCacheKey(url, cert.issuer.bytes, issuer.workingPublicKey), ctx.time);
444
+ var fetchedBytes = 0;
445
+ if (parsed === null) {
446
+ try {
447
+ var got = await _fetchOne(state, { method: "GET", url: url, headers: { accept: "application/pkix-crl" } },
448
+ "a CRL fetch from " + url);
449
+ if (got.contentType !== null && got.contentType !== "application/pkix-crl") {
450
+ _append(state.notes, "the CRL from " + url + " carried content-type " + guard.text.showValue(got.contentType));
451
+ }
452
+ /** @internal RFC 5280 sec. 4.2.1.13 with RFC 2585 sec. 3: the body is exactly one DER
453
+ * CertificateList. One strict parser reads it, chosen by where the URL came from, so a PEM
454
+ * body, a bundle of two CRLs or a PKCS#7 wrapper is refused rather than unwrapped. */
455
+ parsed = crlSchema.parse(got.body);
456
+ fetchedBytes = got.body.length;
457
+ } catch (e) {
458
+ _append(state.notes, "a CRL fetch from " + url + " failed: " + _say(e));
459
+ if (state.fetches.count() >= state.maxFetches || state.deadline.expired()) break;
460
+ continue;
461
+ }
462
+ }
463
+ _append(gathered, parsed);
464
+ /** @internal Whether this CRL is the issuer's, asked once and used twice: it decides whether
465
+ * the locations the CRL names are worth opening, and whether it may be kept. A CRL fetched
466
+ * over a plaintext hop can be replaced in transit, so a base that parses says nothing about
467
+ * who wrote it, and the `freshestCRL` of one that did not verify is the replacement's choice
468
+ * of destination rather than the issuer's. */
469
+ var authentic = await _isIssuers(state, parsed, issuer);
470
+ /** @internal RFC 5280 sec. 6.3.3(a) allows the delta location to be named by the certificate
471
+ * or by the base CRL, so both are read. The certificate's own freshestCRL is the certificate's
472
+ * to name, and the path already authenticated the certificate; the base CRL's is only followed
473
+ * once the base is the issuer's. A delta settles nothing on its own, which is why these are
474
+ * fetched beside a base rather than tried as candidates of their own. */
475
+ var deltaUrls = authentic ? _deltaUrlsFor(parsed, state) : [];
476
+ if (!authentic) _append(state.notes, "a CRL from " + url + " did not verify under the issuer's key, so the locations it names were not followed");
477
+ for (var cd = 0; cd < (certDeltas || []).length; cd++) _append(deltaUrls, certDeltas[cd].url);
478
+ for (var d = 0; d < deltaUrls.length && state.useDeltas; d++) {
479
+ if (asked.has(deltaUrls[d]) || state.deadline.expired()) continue;
480
+ if (!_takeUrl(state, deltaUrls[d])) continue;
481
+ asked.add(deltaUrls[d]);
482
+ try {
483
+ var dg = await _fetchOne(state, { method: "GET", url: deltaUrls[d], headers: { accept: "application/pkix-crl" } },
484
+ "a delta CRL fetch from " + deltaUrls[d]);
485
+ _append(gathered, crlSchema.parse(dg.body));
486
+ } catch (e) {
487
+ _append(state.notes, "a delta CRL fetch failed: " + _say(e));
488
+ }
489
+ }
490
+ var interim = await state.crlChecker(gathered, { useDeltas: state.useDeltas }).check(cert, issuer, ctx);
491
+ if (interim && (interim.status === "good" || interim.status === "revoked")) {
492
+ if (authentic) _keep(state, url, parsed, fetchedBytes, cert.issuer.bytes, issuer.workingPublicKey);
493
+ return { status: interim.status, reason: "a CRL from " + url,
494
+ revocationReason: _reasonCodeOf(interim), revocationTime: interim.revocationTime };
495
+ }
496
+ }
497
+ if (gathered.length === 0) throw E("path/revocation-fetch-failed", "no CRL was fetched");
498
+ var last = await state.crlChecker(gathered, { useDeltas: state.useDeltas }).check(cert, issuer, ctx);
499
+ return { status: (last && last.status) || "unknown", reason: (last && last.reason) || "the fetched CRLs did not settle the status",
500
+ revocationReason: _reasonCodeOf(last), revocationTime: last && last.revocationTime };
501
+ }
502
+
503
+ /** @internal `crlChecker` names the CRLReason integer `reasonCode` and `verifyOcspResponse` names it
504
+ * `revocationReason`. This verb answers from either source, so it reads both and reports the one
505
+ * name. Reading only the OCSP name off a CRL verdict drops the reason from every CRL revocation. */
506
+ function _reasonCodeOf(verdict) {
507
+ if (verdict == null) return undefined;
508
+ var code = verdict.revocationReason;
509
+ if (typeof code !== "number") code = verdict.reasonCode;
510
+ return typeof code === "number" ? code : undefined;
511
+ }
512
+
513
+ /** @internal The delta locations a base CRL names in its own freshestCRL extension. The certificate's
514
+ * own freshestCRL is read beside it by the caller, so both sources RFC 5280 sec. 6.3.3(a) allows are
515
+ * covered. */
516
+ function _deltaUrlsFor(parsedCrl, state) {
517
+ var out = [];
518
+ var decoded = _softDecode(parsedCrl, OID_FRESHEST, state.notes);
519
+ if (decoded === null) return out;
520
+ var cands = _crlCandidates(decoded, state.net, state.notes);
521
+ for (var c = 0; c < cands.length; c++) _append(out, cands[c].url);
522
+ return out;
523
+ }
524
+
525
+ /**
526
+ * @primitive pki.path.fetchingChecker
527
+ * @signature pki.path.fetchingChecker(opts) -> { check }
528
+ * @since 0.8.8
529
+ * @status stable
530
+ * @spec RFC 5280, RFC 6960, RFC 5019
531
+ * @related pki.path.crlChecker, pki.path.ocspChecker, pki.ocsp.httpRequest
532
+ *
533
+ * A revocation checker that fetches what it needs, for `pki.path.validate`'s
534
+ * `opts.revocationChecker`. The shipped checkers are handed their CRLs and responses; this one
535
+ * reads the locations out of the certificate and asks for them over `opts.transport`.
536
+ *
537
+ * Fetching is off unless an operator builds this and passes it, which is how `opts.fetchAia`
538
+ * works: the default validation touches no network. It runs during `pki.path.validate` and not
539
+ * during `pki.path.build`, because RFC 5019 sec. 3.2 has a status check requested only after
540
+ * the chain's signatures are validated, and a build that fetched per candidate path would ask
541
+ * about certificates it has not yet accepted.
542
+ *
543
+ * OCSP is attempted first and a CRL after it, which RFC 5019 sec. 3.1 directs. The responder
544
+ * URL comes from an authorityInfoAccess AccessDescription whose accessMethod is id-ad-ocsp;
545
+ * the CRL URLs come from cRLDistributionPoints, and never from authorityInfoAccess, which
546
+ * RFC 5280 sec. 4.2.2.1 states outright. A distribution point is fetched only as a `fullName`
547
+ * carrying a URI whose scheme this client speaks: an ldap or ftp point, a
548
+ * nameRelativeToCRLIssuer, and a point naming a cRLIssuer and no location are each counted as
549
+ * a skip, so a certificate naming no fetchable point reads as that and never as a failed fetch.
550
+ *
551
+ * Nothing this module fetches is trusted by having been fetched. A CRL goes to
552
+ * `pki.path.crlChecker` and a response to `pki.path.verifyOcspResponse`, which are the one
553
+ * place each rule lives: the issuer match, the cRLSign authorization, the IDP scope, the
554
+ * sec. 6.3.3(b) correspondence, the CertID match, the responder authorization and both
555
+ * currency windows. The structure a body is parsed as is decided by which extension named the
556
+ * URL, so a Content-Type header is reported on a mismatch and never selects a parser.
557
+ *
558
+ * Every fault is an undetermined status carrying its reason, and `check` throws nothing: a
559
+ * transport fault, a non-200, an unparsable body, a stale object and a non-successful
560
+ * OCSPResponseStatus all land on `{ status: "unknown", reason }`. That is what keeps
561
+ * `opts.softFail` meaningful, since a checker that throws is not waivable. A configuration
562
+ * fault, such as a missing transport or a cap above the toolkit's own, throws here at
563
+ * construction.
564
+ *
565
+ * The verdict names the source that answered and lists the locations skipped and the faults
566
+ * noted in its `reason`; a `revoked` verdict adds `revocationReason` (the CRLReason integer)
567
+ * and `revocationTime` from whichever source decided it. `pki.path.validate` carries all three
568
+ * onto the certificate's `revocation` check row.
569
+ *
570
+ * Plaintext `http` is accepted by default, because a CRL and an OCSP response are signed
571
+ * objects whose signatures this toolkit verifies, and because an https-only revocation fetch
572
+ * needs the responder's own certificate validated first. `allowPlaintextHttp: false` narrows
573
+ * it to https. The SSRF guards do not move either way: no private, loopback or link-local
574
+ * destination, no redirect followed, a response-size cap, a per-certificate URL cap, a total
575
+ * fetch count and a wall-clock deadline across the whole validation.
576
+ *
577
+ * A URL taken from a certificate names a destination the certificate chose, so the two forms are
578
+ * decided in different places. An address literal is decided here and a private, loopback or
579
+ * link-local one is a skip. A name is decided by whatever resolves it, which is inside the
580
+ * transport, so a hostname candidate is a skip unless the transport declares
581
+ * `blocksPrivateAddresses === true` on itself, the flag the transport `pki.transport.https(defaults)`
582
+ * returns sets and backs by filtering the resolved address and pinning it for the connection.
583
+ * `pki.transport.https` is the factory rather than a transport, so pass what it returns. Without
584
+ * that declaration a certificate naming `crl.internal.example` would reach whatever the resolver
585
+ * returns.
586
+ *
587
+ * `maxPerCert` counts the distinct destinations one certificate sends this client to across both
588
+ * routes together, so a certificate naming five responders and five distribution points reaches
589
+ * the figure, not twice it. `maxFetches` and `totalDeadlineMs` bound a validation rather than a
590
+ * certificate: `pki.path.validate` calls a checker once per certificate and again for each anchor
591
+ * it tries, and all of those share one budget. A checker is reusable, each validation starting
592
+ * from the figures again; a `check` called directly is a validation of its own.
593
+ *
594
+ * A verified response is cached in process, keyed on the CertID and the responder URL, and a
595
+ * CRL on its request URL and the issuer name it carries. Reuse is decided from the signed
596
+ * thisUpdate and nextUpdate and from nothing else, which RFC 5019 sec. 7.5 directs; the HTTP
597
+ * caching headers are not read.
598
+ *
599
+ * An `ldap` distribution point is not fetched. That is a second protocol client rather than a
600
+ * flag, and it is re-opened when an operator presents a deployment whose only distribution
601
+ * point is one. An `ftp` point is a permanent non-goal.
602
+ *
603
+ * @opts transport REQUIRED. The `pki.transport` seam, or any `(request) -> Promise`
604
+ * of the same shape. A transport that does not declare that it blocks
605
+ * private addresses is refused a hostname.
606
+ * @opts allowPlaintextHttp Whether an `http` URL is fetched. Default `true`.
607
+ * @opts useDeltas Whether a freshestCRL delta is fetched and merged. Default `true`.
608
+ * @opts nonce Whether an OCSP request carries a 32-octet nonce. `true` or `false`,
609
+ * and anything else is `path/bad-input` rather than the default, since a
610
+ * control read as off is one an operator asked for and did not get.
611
+ * Default `false`, since a pre-produced response carries none and the
612
+ * currency window is the replay bound. Set, the response must echo the
613
+ * nonce or the status is undetermined and the route falls through to
614
+ * the CRL.
615
+ * @opts tls Passed to the transport for an https URL.
616
+ * @opts maxFetches Requests across one validation. Default `C.LIMITS.REVOCATION_MAX_FETCHES`.
617
+ * @opts maxPerCert Distinct destinations one certificate names, responders and distribution
618
+ * points counted together. Default `C.LIMITS.REVOCATION_MAX_PER_CERT`.
619
+ * @opts maxResponseBytes Default `C.LIMITS.REVOCATION_MAX_RESPONSE_BYTES`.
620
+ * @opts totalDeadlineMs Wall clock across one validation. Default `C.LIMITS.REVOCATION_TOTAL_DEADLINE_MS`.
621
+ * @opts sourceTimeoutMs Per request, narrowed to whatever the deadline has left; default
622
+ * `C.LIMITS.REVOCATION_SOURCE_TIMEOUT_MS`.
623
+ * @opts retries Attempts at the OCSP responder before the CRL is tried. Default `1`.
624
+ * @example
625
+ * // pki.transport.https is the factory; the transport is what it returns, and only that
626
+ * // carries the blocksPrivateAddresses declaration a hostname destination needs.
627
+ * var checker = pki.path.fetchingChecker({ transport: pki.transport.https({}) });
628
+ * console.log(typeof checker.check);
629
+ */
630
+ function fetchingChecker(opts) {
631
+ opts = guard.identifier.optionsObject(opts, E, "path/bad-input", "fetchingChecker: opts");
632
+ guard.identifier.assertKnownKeys(opts, CHECKER_OPTS, E, "path/bad-input",
633
+ "pki.path.fetchingChecker has an unknown option. The unknown option was: ");
634
+ /** @internal The checked value is the one used. Reading the option a second time would ask an
635
+ * accessor twice, so the transport that passed the door is the transport that gets called. */
636
+ var transport = guard.identifier.assertCallable(opts.transport, E, "path/bad-input",
637
+ "fetchingChecker: opts.transport", TRANSPORT_SHAPE);
638
+ var allowPlaintextHttp = opts.allowPlaintextHttp === undefined ? true : opts.allowPlaintextHttp === true;
639
+ var cfg = {
640
+ transport: transport,
641
+ /** @internal Read off the transport once, here, beside the transport itself: a flag read again
642
+ * at fetch time would be a second read of a caller's object, and the value that passed the
643
+ * door is the value the rule is applied on. */
644
+ net: intrinsic.freeze({ allowHttp: allowPlaintextHttp, guardsAddresses: _declaresAddressGuard(transport) }),
645
+ allowPlaintextHttp: allowPlaintextHttp,
646
+ useDeltas: !(opts.useDeltas === false),
647
+ nonce: _booleanOption(opts.nonce, "fetchingChecker: opts.nonce"),
648
+ tls: opts.tls,
649
+ maxFetches: guard.limits.cap(opts.maxFetches, "fetchingChecker: opts.maxFetches", LIMITS.REVOCATION_MAX_FETCHES,
650
+ { E: E, code: "path/bad-input", min: 1, max: LIMITS.REVOCATION_MAX_FETCHES }),
651
+ maxPerCert: guard.limits.cap(opts.maxPerCert, "fetchingChecker: opts.maxPerCert", LIMITS.REVOCATION_MAX_PER_CERT,
652
+ { E: E, code: "path/bad-input", min: 1, max: LIMITS.REVOCATION_MAX_PER_CERT }),
653
+ maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "fetchingChecker: opts.maxResponseBytes", LIMITS.REVOCATION_MAX_RESPONSE_BYTES,
654
+ { E: E, code: "path/bad-input", min: 1, max: LIMITS.REVOCATION_MAX_RESPONSE_BYTES }),
655
+ totalDeadlineMs: guard.limits.cap(opts.totalDeadlineMs, "fetchingChecker: opts.totalDeadlineMs", LIMITS.REVOCATION_TOTAL_DEADLINE_MS,
656
+ { E: E, code: "path/bad-input", min: 1, max: LIMITS.REVOCATION_TOTAL_DEADLINE_MS }),
657
+ sourceTimeoutMs: guard.limits.cap(opts.sourceTimeoutMs, "fetchingChecker: opts.sourceTimeoutMs", LIMITS.REVOCATION_SOURCE_TIMEOUT_MS,
658
+ { E: E, code: "path/bad-input", min: 1, max: LIMITS.REVOCATION_SOURCE_TIMEOUT_MS }),
659
+ retries: guard.limits.cap(opts.retries, "fetchingChecker: opts.retries", 1, { E: E, code: "path/bad-input", min: 0, max: 4 }),
660
+ crlChecker: pathValidate.crlChecker,
661
+ verifyOcspResponse: pathValidate.verifyOcspResponse,
662
+ parseRequest: ocspSchema.parseRequest,
663
+ };
664
+ var cache = _makeCache(LIMITS.REVOCATION_CACHE_MAX_ENTRIES, LIMITS.REVOCATION_CACHE_MAX_BYTES);
665
+ var crlCache = _makeCache(LIMITS.REVOCATION_CACHE_MAX_ENTRIES, LIMITS.REVOCATION_CACHE_MAX_BYTES);
666
+ /** @internal `maxFetches` and `totalDeadlineMs` bound a validation, and a validation is several
667
+ * `check` calls: one per certificate on the path, and the set again for each anchor tried. A
668
+ * budget minted per call would be those figures multiplied by the path length, which is a bound
669
+ * the operator did not set. `pki.path.validate` names its run and the budget is held against that
670
+ * name, so the certificates of one path share it. The map is weak, so the entry goes when the
671
+ * validation does and a checker is reusable at its full figure on the next one. A `check` called
672
+ * directly, with no run named, is its own validation and gets its own budget. */
673
+ var budgets = new intrinsic.WeakMap();
674
+ function budgetFor(run) {
675
+ var fresh = {
676
+ deadline: guard.limits.deadline(cfg.totalDeadlineMs),
677
+ fetches: guard.limits.counter(cfg.maxFetches, E, "path/revocation-fetch-failed", "revocation fetch"),
678
+ };
679
+ if (run === null || typeof run !== "object") return fresh;
680
+ var held = intrinsic.weakGet(budgets, run);
681
+ if (held !== undefined) return held;
682
+ intrinsic.weakSet(budgets, run, fresh);
683
+ return fresh;
684
+ }
685
+
686
+ return {
687
+ check: async function (cert, issuer, ctx) {
688
+ var notes = [], skips = [];
689
+ var budget = budgetFor(ctx == null ? null : ctx.run);
690
+ var state = intrinsic.assign(intrinsic.create(null), cfg, {
691
+ notes: notes, cache: cache, crlCache: crlCache,
692
+ deadline: budget.deadline,
693
+ fetches: budget.fetches,
694
+ urls: new intrinsic.Set(),
695
+ });
696
+ var ocspCands, crlCands, certDeltas;
697
+ try {
698
+ ocspCands = _ocspCandidates(_softDecode(cert, OID_AIA, skips), cfg.net, skips);
699
+ crlCands = _crlCandidates(_softDecode(cert, OID_CDP, skips), cfg.net, skips);
700
+ var fresh = _softDecode(cert, OID_FRESHEST, skips);
701
+ certDeltas = (fresh && cfg.useDeltas) ? _crlCandidates(fresh, cfg.net, skips) : [];
702
+ } catch (e) {
703
+ return _unknown("the revocation locations could not be read: " + _say(e), notes, skips);
704
+ }
705
+ /** @internal A certificate may name a delta location and no base one. `crlChecker` treats an
706
+ * unmerged delta that lists the serial as authoritative, so such a certificate has a real
707
+ * revoked answer available and returning undetermined without asking would lose it. The
708
+ * delta is tried as a candidate of its own here, which is the only place it can be: below,
709
+ * the deltas are fetched beside a base that answered. */
710
+ if (crlCands.length === 0 && certDeltas.length > 0) crlCands = certDeltas;
711
+ if (ocspCands.length === 0 && crlCands.length === 0) {
712
+ return _unknown("the certificate names no revocation location this client fetches", notes, skips);
713
+ }
714
+ /** @internal RFC 5019 sec. 3.1: the responder first, the CRL after a configured timeout and
715
+ * number of retries. A responder that answers `revoked` or `good` ends it; anything else
716
+ * falls through, because sec. 5 of RFC 6960 makes the CRL the fallback and never an
717
+ * assumption of good standing. */
718
+ var lastReason = null;
719
+ for (var a = 0; a <= cfg.retries && ocspCands.length > 0; a++) {
720
+ for (var o = 0; o < ocspCands.length; o++) {
721
+ if (!_takeUrl(state, ocspCands[o].url)) {
722
+ _append(skips, "a responder beyond the maxPerCert destinations this certificate may name");
723
+ break;
724
+ }
725
+ try {
726
+ var got = await _tryOcsp(state, cert, issuer, ctx, ocspCands[o]);
727
+ if (got.status === "good" || got.status === "revoked") return _verdict(got, notes, skips);
728
+ lastReason = got.reason + " reported " + got.status;
729
+ } catch (e) { lastReason = _say(e); }
730
+ }
731
+ }
732
+ if (crlCands.length > 0) {
733
+ try {
734
+ var viaCrl = await _tryCrl(state, cert, issuer, ctx, crlCands, certDeltas);
735
+ if (viaCrl.status === "good" || viaCrl.status === "revoked") return _verdict(viaCrl, notes, skips);
736
+ lastReason = viaCrl.reason;
737
+ } catch (e2) { lastReason = _say(e2); }
738
+ }
739
+ return _unknown(lastReason || "no revocation source answered", notes, skips);
740
+ },
741
+ };
742
+ }
743
+
744
+ /** @internal Every reason this module reports about a fault it did not cause passes through here,
745
+ * and the value is the transport's: `check` promises a verdict rather than a throw, and a value
746
+ * whose `message` refuses to be read would break that promise at the one place that formats it.
747
+ * @guard-via guard.text.describeThrown */
748
+ function _say(e) { return guard.text.describeThrown(e); }
749
+
750
+ function _verdict(got, notes, skips) {
751
+ return guard.verdict.of({
752
+ status: got.status, reason: _reason(got.reason, notes, skips),
753
+ revocationReason: got.revocationReason, revocationTime: got.revocationTime,
754
+ });
755
+ }
756
+
757
+ function _unknown(reason, notes, skips) {
758
+ return guard.verdict.of({ status: "unknown", reason: _reason(reason, notes, skips) });
759
+ }
760
+
761
+ /** @internal A skip and a failure are carried separately into the one reason string, so an operator
762
+ * reading an undetermined verdict can tell a blocked port from a certificate that names nothing
763
+ * fetchable. The verdict itself is the same either way, which is why this is a reason and not a
764
+ * second error code. */
765
+ function _reason(head, notes, skips) {
766
+ var parts = [head];
767
+ if (skips.length) _append(parts, "skipped: " + skips.join("; "));
768
+ if (notes.length) _append(parts, "noted: " + notes.join("; "));
769
+ return parts.join(" | ");
770
+ }
771
+
772
+ module.exports = { fetchingChecker: fetchingChecker };