tr-fetch 0.9.1 → 0.9.2

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.
package/README.md CHANGED
@@ -28,6 +28,7 @@ const response = await trFetch(url, {
28
28
  trFetchDebug: false,
29
29
  trFetchCrlPolicy: {
30
30
  disabled: false,
31
+ maxCrlBytes: 16777216,
31
32
  missingCrlDistributionPoint: 'ignore',
32
33
  unreachableCrlDistributionPoint: 'reject',
33
34
  invalidCrl: 'reject',
@@ -65,6 +66,23 @@ continues. `reject` rejects the fetch promise before sending the HTTP request
65
66
  on that connection. These policies apply only to revocation checks; they cannot
66
67
  relax normal TLS verification.
67
68
 
69
+ ### CRL size limit
70
+
71
+ `trFetchCrlPolicy.maxCrlBytes` is the largest CRL accepted, in bytes. It
72
+ defaults to `16777216` (16 MiB) and must be a positive safe integer; there is
73
+ no value for unlimited. Some public CAs publish much larger CRLs, so raise it
74
+ explicitly when needed:
75
+
76
+ ```js
77
+ await trFetch('https://example.com/', { trFetchCrlPolicy: { maxCrlBytes: 64 * 1024 * 1024 } });
78
+ ```
79
+
80
+ A larger download is an unreachable distribution point; larger
81
+ `trFetchCrlOverride` data is an invalid CRL. A cached CRL above the caller's
82
+ limit is not used, even if an earlier caller with a higher limit cached it.
83
+ Revoked entries are scanned in place, so memory use stays close to the CRL's
84
+ size.
85
+
68
86
  ### Disabling a check
69
87
 
70
88
  Both policy objects accept `disabled`. It defaults to `false`; `undefined` and
@@ -305,12 +323,9 @@ CRL retrieval:
305
323
  for HTTPS downloads. Download failures use `unreachableCrlDistributionPoint`.
306
324
  - Does not copy application cookies, authorization headers or request bodies
307
325
  into CRL requests.
308
- - Allows at most five redirects, 16 MiB of decoded response data and ten seconds
326
+ - Allows at most five redirects, `maxCrlBytes` of decoded response data and ten seconds
309
327
  per download including redirects and body reading. Caller cancellation also
310
328
  cancels CRL retrieval. At most 32 distribution URIs are tried per certificate.
311
- - Parses at most 1,000,000 ASN.1 nodes per CRL, enough for several hundred
312
- thousand revoked entries. This bounds the memory that a hostile CRL can use
313
- (about 0.6 GB); larger CRLs are invalid.
314
329
  - Allows private-network HTTP(S) endpoints, including loopback, for private PKI.
315
330
  It does not perform filesystem or LDAP lookup.
316
331
  - Does not recursively check the CRL download server's CRLs or OCSP status.
package/check.js CHANGED
@@ -92,8 +92,14 @@ async function checkCertificate(peer, hostname, options, signal, override, debug
92
92
  const url = networkUrl(source);
93
93
  key = issuerId + ':' + url.href;
94
94
  crl = cache.get(key, options.cacheSize, options.cacheTTL, Date.now(), debug);
95
+ // A CRL cached under a higher limit must not bypass this one.
96
+ if (crl && (crl.size > options.policy.maxCrlBytes)) {
97
+ debug?.('CRL cache entry not used', { source: debugUrl(url), reason: 'exceeds maxCrlBytes',
98
+ bytes: crl.size, maxCrlBytes: options.policy.maxCrlBytes });
99
+ crl = undefined;
100
+ }
95
101
  if (! crl) {
96
- bytes = await downloadCrl(url, signal, debug);
102
+ bytes = await downloadCrl(url, signal, debug, options.policy.maxCrlBytes);
97
103
  }
98
104
  } catch (cause) {
99
105
  signal?.throwIfAborted();
@@ -105,9 +111,9 @@ async function checkCertificate(peer, hostname, options, signal, override, debug
105
111
  let result;
106
112
  try {
107
113
  if (! crl) {
108
- crl = parseCrl(bytes);
114
+ crl = parseCrl(bytes, options.policy.maxCrlBytes);
109
115
  debug?.('CRL parsed', { source: (source === undefined) ? 'trFetchCrlOverride' : debugUrl(source),
110
- revokedEntries: crl.revokedCertificates?.length ?? 0 });
116
+ revokedEntries: crl.revokedCount });
111
117
  }
112
118
  result = await validateCrl(crl, certificate, issuer, point.urls);
113
119
  } catch (cause) {
package/crl.js CHANGED
@@ -2,13 +2,11 @@
2
2
 
3
3
  const asn1 = require('asn1js');
4
4
  const pki = require('pkijs');
5
- const { cryptoEngine, parseDer, extensionsById, extensionValue, parseCertificate } = require('./pkiutils');
5
+ const { cryptoEngine, parseDer, derElement, derHeader, oidString, extensionsById, extensionValue,
6
+ parseCertificate } = require('./pkiutils');
7
+ const { DEFAULT_MAX_CRL_BYTES } = require('./options');
6
8
 
7
- const MAX_CRL_BYTES = 16 * 1024 * 1024;
8
- // A revoked entry takes three to seven ASN.1 nodes, so this allows CRLs of
9
- // several hundred thousand entries. Unlike the byte limit, it also bounds the
10
- // memory used by hostile input made of minimal nodes (about 0.6 GB).
11
- const MAX_CRL_NODES = 1000000;
9
+ const TIME_TAGS = [ 0x17, 0x18 ];
12
10
 
13
11
  function distributionPoints(certificate) {
14
12
  const extension = extensionsById(certificate.extensions).get('2.5.29.31');
@@ -28,10 +26,11 @@ function distributionPoints(certificate) {
28
26
  });
29
27
  }
30
28
 
31
- function parseCrl(bytes) {
32
- if (bytes.length > MAX_CRL_BYTES) {
33
- throw new Error('CRL exceeds the 16 MiB size limit');
29
+ function parseCrl(bytes, maxBytes = DEFAULT_MAX_CRL_BYTES) {
30
+ if (bytes.length > maxBytes) {
31
+ throw new Error(`CRL exceeds maxCrlBytes (${maxBytes} bytes)`);
34
32
  }
33
+ const size = bytes.length;
35
34
  if (bytes.toString('ascii', 0, 32).trimStart().startsWith('-----BEGIN')) {
36
35
  const match = /^\s*-----BEGIN X509 CRL-----\s*([A-Za-z0-9+/=\r\n\t ]+)\s*-----END X509 CRL-----\s*$/.exec(bytes.toString('ascii'));
37
36
  if (! match) {
@@ -43,7 +42,110 @@ function parseCrl(bytes) {
43
42
  throw new Error('Malformed base64 in PEM CRL');
44
43
  }
45
44
  }
46
- return parseDer(bytes, pki.CertificateRevocationList, { maxNodes: MAX_CRL_NODES, maxContentLength: MAX_CRL_BYTES });
45
+ const outer = derElement(bytes, 0);
46
+ if (outer.end !== bytes.length) {
47
+ throw new Error('Malformed ASN.1 data: trailing bytes');
48
+ }
49
+ const tbs = derElement(bytes, outer.start, outer.end);
50
+ if ((outer.tag !== 0x30) || (tbs.tag !== 0x30)) {
51
+ throw new Error('Malformed CRL structure');
52
+ }
53
+ const fields = [];
54
+ for (let offset = tbs.start; offset < tbs.end; offset = fields.at(-1).end) {
55
+ fields.push(derElement(bytes, offset, tbs.end));
56
+ }
57
+ // version, signature, issuer, thisUpdate, nextUpdate, revokedCertificates
58
+ let index = (fields[0]?.tag === 0x02) ? 3 : 2;
59
+ index += TIME_TAGS.includes(fields[index + 1]?.tag) ? 2 : 1;
60
+ const entries = (fields[index]?.tag === 0x30) ? fields[index] : undefined;
61
+ // Decoding every revoked entry into an ASN.1 object tree takes hundreds of
62
+ // bytes of memory per encoded byte, so large CRLs could exhaust the heap.
63
+ // PKI.js decodes the rest; the entries are scanned in place.
64
+ const headerFields = fields.filter(x => x !== entries).map(x => bytes.subarray(x.offset, x.end));
65
+ const headerLength = headerFields.reduce((sum, x) => sum + x.length, 0);
66
+ const trailer = bytes.subarray(tbs.end, outer.end);
67
+ const tbsHeader = derHeader(0x30, headerLength);
68
+ const crl = parseDer(Buffer.concat([ derHeader(0x30, tbsHeader.length + headerLength + trailer.length),
69
+ tbsHeader, ...headerFields, trailer ]), pki.CertificateRevocationList);
70
+ const scan = scanEntries(bytes, entries);
71
+ return { crl, size, bytes, tbs: bytes.subarray(tbs.offset, tbs.end), entries,
72
+ revokedCount: scan.count, entryProblem: scan.problem };
73
+ }
74
+
75
+ function checkEntryExtensions(bytes, list) {
76
+ const seen = new Set();
77
+ let problem;
78
+ for (let offset = list.start; offset < list.end;) {
79
+ const extension = derElement(bytes, offset, list.end);
80
+ offset = extension.end;
81
+ const id = derElement(bytes, extension.start, extension.end);
82
+ let value = derElement(bytes, id.end, extension.end);
83
+ let critical = false;
84
+ if (value.tag === 0x01) {
85
+ critical = (value.end > value.start) && (bytes[value.start] !== 0);
86
+ value = derElement(bytes, value.end, extension.end);
87
+ }
88
+ if ((extension.tag !== 0x30) || (id.tag !== 0x06) || (value.tag !== 0x04) || (value.end !== extension.end)) {
89
+ throw new Error('Malformed CRL entry extension');
90
+ }
91
+ const extnID = oidString(bytes.subarray(id.start, id.end));
92
+ if (seen.has(extnID)) {
93
+ problem ??= `Duplicate extension ${extnID}`;
94
+ }
95
+ seen.add(extnID);
96
+ if (extnID === '2.5.29.29') {
97
+ problem ??= 'Indirect CRL certificateIssuer entries are unsupported';
98
+ } else if (critical) {
99
+ problem ??= `Unsupported critical CRL entry extension ${extnID}`;
100
+ } else if (extnID === '2.5.29.21') {
101
+ const reason = derElement(bytes, value.start, value.end);
102
+ let code = 0;
103
+ for (let i = reason.start; i < reason.end; i++) {
104
+ code = (code * 256) + bytes[i];
105
+ }
106
+ if ((reason.tag !== 0x0a) || (reason.end !== value.end) || (reason.end === reason.start) || (code === 8)) {
107
+ problem ??= 'Invalid revocation reason in a complete CRL';
108
+ }
109
+ }
110
+ }
111
+ return problem;
112
+ }
113
+
114
+ // Walk the revoked entries without decoding them into objects. Structural
115
+ // errors throw; the first unsupported entry is reported for use after the CRL
116
+ // is authenticated. With a serial, also report whether it is listed.
117
+ function scanEntries(bytes, entries, serial) {
118
+ let count = 0;
119
+ let problem;
120
+ let revoked = false;
121
+ for (let offset = entries?.start; offset < entries?.end;) {
122
+ const entry = derElement(bytes, offset, entries.end);
123
+ offset = entry.end;
124
+ const number = derElement(bytes, entry.start, entry.end);
125
+ const date = derElement(bytes, number.end, entry.end);
126
+ let next = date.end;
127
+ if ((entry.tag !== 0x30) || (number.tag !== 0x02) || ! TIME_TAGS.includes(date.tag)) {
128
+ throw new Error('Malformed revoked certificate entry');
129
+ }
130
+ if (next < entry.end) {
131
+ const extensions = derElement(bytes, next, entry.end);
132
+ if (extensions.tag !== 0x30) {
133
+ throw new Error('Malformed revoked certificate entry');
134
+ }
135
+ if (serial === undefined) {
136
+ problem ??= checkEntryExtensions(bytes, extensions);
137
+ }
138
+ next = extensions.end;
139
+ }
140
+ if (next !== entry.end) {
141
+ throw new Error('Malformed revoked certificate entry');
142
+ }
143
+ count++;
144
+ if ((serial !== undefined) && bytes.subarray(number.start, number.end).equals(serial)) {
145
+ revoked = true;
146
+ }
147
+ }
148
+ return { count, problem, revoked };
47
149
  }
48
150
 
49
151
  function checkValidity(crl, now = Date.now()) {
@@ -95,7 +197,8 @@ function checkScope(crl, certificate, urls) {
95
197
  return extensions;
96
198
  }
97
199
 
98
- async function validateCrl(crl, certificate, issuer, urls, now = Date.now()) {
200
+ async function validateCrl(parsed, certificate, issuer, urls, now = Date.now()) {
201
+ const crl = parsed.crl;
99
202
  const nextUpdate = checkValidity(crl, now);
100
203
  const extensions = checkScope(crl, certificate, urls);
101
204
  if (! crl.issuer.isEqual(certificate.issuer) || ! crl.issuer.isEqual(issuer.subject)) {
@@ -138,31 +241,17 @@ async function validateCrl(crl, certificate, issuer, urls, now = Date.now()) {
138
241
  if (crlNumber && ! (extensionValue(crlNumber) instanceof asn1.Integer)) {
139
242
  throw new Error('Malformed CRL number');
140
243
  }
141
- if (! await crl.verify({ issuerCertificate: issuer }, cryptoEngine)) {
244
+ // The signature covers the original TBS bytes, including the entries.
245
+ if (! await cryptoEngine.verifyWithPublicKey(parsed.tbs, crl.signatureValue, issuer.subjectPublicKeyInfo, crl.signatureAlgorithm)) {
142
246
  throw new Error('CRL signature verification failed');
143
247
  }
144
- let revoked = false;
145
- for (const entry of crl.revokedCertificates ?? []) {
146
- for (const extension of extensionsById(entry.crlEntryExtensions?.extensions).values()) {
147
- if (extension.extnID === '2.5.29.29') {
148
- throw new Error('Indirect CRL certificateIssuer entries are unsupported');
149
- }
150
- if (extension.critical) {
151
- throw new Error(`Unsupported critical CRL entry extension ${extension.extnID}`);
152
- }
153
- if (extension.extnID === '2.5.29.21') {
154
- const reason = extensionValue(extension);
155
- if (! (reason instanceof asn1.Enumerated) || (reason.valueBlock.valueDec === 8)) {
156
- throw new Error('Invalid revocation reason in a complete CRL');
157
- }
158
- }
159
- }
160
- if (entry.userCertificate.isEqual(certificate.serialNumber)) {
161
- revoked = true;
162
- }
248
+ if (parsed.entryProblem) {
249
+ throw new Error(parsed.entryProblem);
163
250
  }
251
+ const serial = Buffer.from(certificate.serialNumber.valueBlock.valueHexView);
252
+ const { revoked } = scanEntries(parsed.bytes, parsed.entries, serial);
164
253
  checkValidity(crl);
165
254
  return { revoked, nextUpdate };
166
255
  }
167
256
 
168
- module.exports = { MAX_CRL_BYTES, MAX_CRL_NODES, parseCertificate, distributionPoints, parseCrl, validateCrl };
257
+ module.exports = { parseCertificate, distributionPoints, parseCrl, validateCrl };
package/download.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  const { assertVerifiedResponse, createAgent } = require('./transport');
4
- const { MAX_CRL_BYTES } = require('./crl');
5
4
  const { debugUrl } = require('./debug');
6
5
 
7
6
  function networkUrl(value, kind = 'CRL distribution point') {
@@ -21,9 +20,9 @@ function networkUrl(value, kind = 'CRL distribution point') {
21
20
  return url;
22
21
  }
23
22
 
24
- async function download(value, signal, body, debug) {
23
+ async function download(value, signal, body, debug, maxCrlBytes) {
25
24
  const kind = (body === undefined) ? 'CRL' : 'OCSP';
26
- const maxBytes = (body === undefined) ? MAX_CRL_BYTES : 1024 * 1024;
25
+ const maxBytes = (body === undefined) ? maxCrlBytes : 1024 * 1024;
27
26
  let url = networkUrl(value, kind);
28
27
  const timeout = AbortSignal.timeout(10000);
29
28
  const downloadSignal = signal ? AbortSignal.any([ signal, timeout ]) : timeout;
@@ -75,7 +74,8 @@ async function download(value, signal, body, debug) {
75
74
  for await (const chunk of response.body) {
76
75
  length += chunk.byteLength;
77
76
  if (length > maxBytes) {
78
- throw new Error(`${kind} download exceeds the ${maxBytes / (1024 * 1024)} MiB size limit`);
77
+ throw new Error((body === undefined) ? `CRL download exceeds maxCrlBytes (${maxBytes} bytes)` :
78
+ 'OCSP download exceeds the 1 MiB size limit');
79
79
  }
80
80
  chunks.push(chunk);
81
81
  }
@@ -89,8 +89,8 @@ async function download(value, signal, body, debug) {
89
89
  }
90
90
  }
91
91
 
92
- function downloadCrl(value, signal, debug) {
93
- return download(value, signal, undefined, debug);
92
+ function downloadCrl(value, signal, debug, maxBytes) {
93
+ return download(value, signal, undefined, debug, maxBytes);
94
94
  }
95
95
 
96
96
  function downloadOcsp(value, request, signal, debug) {
package/index.js CHANGED
@@ -18,6 +18,7 @@ async function trFetch(input, options) {
18
18
  url: debugUrl(request.url),
19
19
  crl: config.policy.disabled ? 'disabled' : 'enabled',
20
20
  crlDepth: (config.checkDepth === Infinity) ? 'full-chain' : config.checkDepth,
21
+ maxCrlBytes: config.policy.maxCrlBytes,
21
22
  ocsp: config.ocspPolicy.disabled ? 'disabled' : 'enabled',
22
23
  ocspDepth: (config.ocspCheckDepth === Infinity) ? 'full-chain' : config.ocspCheckDepth
23
24
  });
package/options.js CHANGED
@@ -1,7 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ const DEFAULT_MAX_CRL_BYTES = 16 * 1024 * 1024;
4
+
3
5
  const DEFAULT_POLICY = {
4
6
  disabled: false,
7
+ maxCrlBytes: DEFAULT_MAX_CRL_BYTES,
5
8
  missingCrlDistributionPoint: 'ignore',
6
9
  unreachableCrlDistributionPoint: 'reject',
7
10
  invalidCrl: 'reject',
@@ -54,6 +57,12 @@ function parsePolicy(value, defaults, name) {
54
57
  throw new TypeError(`${name}.disabled must be a boolean, null or undefined`);
55
58
  }
56
59
  policy.disabled = setting ?? false;
60
+ } else if (key === 'maxCrlBytes') {
61
+ // Deliberately no value for unlimited; any limit must be explicit.
62
+ if (! Number.isSafeInteger(setting) || (setting <= 0)) {
63
+ throw new TypeError(`${name}.maxCrlBytes must be a positive safe integer (bytes)`);
64
+ }
65
+ policy.maxCrlBytes = setting;
57
66
  } else {
58
67
  if (! [ 'ignore', 'warn', 'reject' ].includes(setting)) {
59
68
  throw new TypeError(`${name}.${key} must be ignore, warn or reject`);
@@ -143,4 +152,4 @@ function splitOptions(options) {
143
152
  ocspPolicy, ocspUri, ocspCheckDepth, debugEnabled };
144
153
  }
145
154
 
146
- module.exports = { splitOptions };
155
+ module.exports = { DEFAULT_MAX_CRL_BYTES, splitOptions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-fetch",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Node.js fetch with CRL and OCSP certificate revocation checking, plus tr-curl, a curl-like test tool",
5
5
  "main": "index.js",
6
6
  "exports": {
package/pkiutils.js CHANGED
@@ -6,9 +6,8 @@ const { webcrypto } = require('node:crypto');
6
6
 
7
7
  const cryptoEngine = new pki.CryptoEngine({ name: 'tr-fetch', crypto: webcrypto });
8
8
 
9
- // Limits default to asn1js's own; see its fromBER() resource limits.
10
- function parseDer(bytes, Type, limits) {
11
- const decoded = asn1.fromBER(bytes, limits);
9
+ function parseDer(bytes, Type) {
10
+ const decoded = asn1.fromBER(bytes);
12
11
  if (decoded.result.error) {
13
12
  throw new Error(`Malformed ASN.1 data: ${decoded.result.error}`);
14
13
  }
@@ -18,6 +17,61 @@ function parseDer(bytes, Type, limits) {
18
17
  return new Type({ schema: decoded.result });
19
18
  }
20
19
 
20
+ // A minimal DER element reader for data too large to decode into an object
21
+ // tree: single-byte tags and definite lengths only, always within bounds.
22
+ function derElement(bytes, offset, end = bytes.length) {
23
+ if ((offset + 2) > end) {
24
+ throw new Error('Malformed ASN.1 data: truncated element');
25
+ }
26
+ const tag = bytes[offset];
27
+ if ((tag & 0x1f) === 0x1f) {
28
+ throw new Error('Malformed ASN.1 data: unsupported tag');
29
+ }
30
+ let start = offset + 2;
31
+ let length = bytes[offset + 1];
32
+ if (length & 0x80) {
33
+ const count = length & 0x7f;
34
+ if ((count === 0) || (count > 4) || ((start + count) > end)) {
35
+ throw new Error('Malformed ASN.1 data: unsupported length encoding');
36
+ }
37
+ length = 0;
38
+ for (let i = 0; i < count; i++) {
39
+ length = (length * 256) + bytes[start + i];
40
+ }
41
+ start += count;
42
+ }
43
+ if ((start + length) > end) {
44
+ throw new Error('Malformed ASN.1 data: element exceeds its container');
45
+ }
46
+ return { tag, offset, start, end: start + length };
47
+ }
48
+
49
+ function derHeader(tag, length) {
50
+ if (length < 0x80) {
51
+ return Buffer.from([ tag, length ]);
52
+ }
53
+ const bytes = [];
54
+ for (let value = length; value > 0; value = Math.floor(value / 256)) {
55
+ bytes.unshift(value % 256);
56
+ }
57
+ return Buffer.from([ tag, 0x80 | bytes.length, ...bytes ]);
58
+ }
59
+
60
+ function oidString(bytes) {
61
+ const parts = [];
62
+ let value = 0;
63
+ for (const byte of bytes) {
64
+ value = (value * 128) + (byte & 0x7f);
65
+ if (! (byte & 0x80)) {
66
+ parts.push(value);
67
+ value = 0;
68
+ }
69
+ }
70
+ const first = parts.shift() ?? 0;
71
+ const top = Math.min(2, Math.floor(first / 40));
72
+ return [ top, first - (top * 40), ...parts ].join('.');
73
+ }
74
+
21
75
  function extensionsById(extensions = []) {
22
76
  const result = new Map();
23
77
  for (const extension of extensions) {
@@ -45,4 +99,4 @@ function parseCertificate(bytes) {
45
99
  return parseDer(bytes, pki.Certificate);
46
100
  }
47
101
 
48
- module.exports = { cryptoEngine, parseDer, extensionsById, extensionValue, parseCertificate };
102
+ module.exports = { cryptoEngine, parseDer, derElement, derHeader, oidString, extensionsById, extensionValue, parseCertificate };