tr-fetch 0.9.1 → 0.9.3

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.
@@ -416,15 +431,27 @@ tr-curl --cacert private-ca.pem --crlfile current.crl https://internal.example/
416
431
  | Failure and limits | `-f/--fail`, `--fail-with-body`, `--fail-early`, `--max-filesize`, `-m/--max-time` |
417
432
  | Messages | `-v/--verbose`, `-s/--silent`, `-S/--show-error`, `--no-progress-meter`, `-#/--progress-bar`, `-h/--help`, `-V/--version` |
418
433
  | TLS | `-k/--insecure`, `--cacert`, `--crlfile`, `-1/--tlsv1`, `--tlsv1.0` … `--tlsv1.3`, `--tls-max`, `--ciphers`, `--tls13-ciphers` |
419
- | trFetch | `--trfetch-options <json>` |
434
+ | trFetch | `--tr-fetch-max-crl-bytes`, `--tr-fetch-crl-url`, `--tr-fetch-ocsp-url`, `--tr-fetch-options` |
420
435
 
421
436
  `--verbose` prints the request and response headers, prefixed with `>` and
422
437
  `<` like curl, and also enables `trFetchDebug`, so the revocation diagnostics
423
- appear on stderr. `--trfetch-options` takes a JSON object of trFetch options,
424
- for example `'{"trFetchCrlCheckDepth":"full-chain"}'`. The option can be
425
- repeated, and later objects override earlier keys. `--crlfile` sets
426
- `trFetchCrlOverride`. `--cacert` replaces Node's default CA certificates for
427
- the process.
438
+ appear on stderr. `--cacert` replaces Node's default CA certificates for the
439
+ process.
440
+
441
+ The `--tr-fetch-*` options set trFetch options directly:
442
+
443
+ | Option | trFetch option |
444
+ |---|---|
445
+ | `--tr-fetch-max-crl-bytes <bytes>` | `trFetchCrlPolicy.maxCrlBytes`, a positive integer |
446
+ | `--tr-fetch-crl-url <url>` | `trFetchCrlDistributionPointOverride` |
447
+ | `--tr-fetch-ocsp-url <url>` | `trFetchOcspUriOverride` |
448
+ | `--crlfile <file>` | `trFetchCrlOverride`, read from the file |
449
+
450
+ `--tr-fetch-options` takes a JSON object of any trFetch options, for example
451
+ `'{"trFetchCrlCheckDepth":"full-chain"}'`. It can be repeated; later objects
452
+ replace earlier keys. The options above take precedence over the same
453
+ settings in it, whatever their order, and `--tr-fetch-max-crl-bytes` is merged
454
+ into its `trFetchCrlPolicy` rather than replacing it.
428
455
 
429
456
  `--insecure` cannot be implemented through trFetch, which never relaxes TLS
430
457
  verification. With `-k`, tr-curl uses plain fetch with an unverified TLS
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.3",
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 };
@@ -26,6 +26,14 @@ function tlsMaxCb(value) {
26
26
  return (value === 'default') ? value : TLS_VERSIONS[value];
27
27
  }
28
28
 
29
+ function positiveBytesCb(value) {
30
+ return (/^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value))) ? Number(value) : undefined;
31
+ }
32
+
33
+ function networkUrlCb(value) {
34
+ return (URL.canParse(value) && [ 'http:', 'https:' ].includes(new URL(value).protocol)) ? value : undefined;
35
+ }
36
+
29
37
  function jsonObjectCb(value) {
30
38
  try {
31
39
  const parsed = JSON.parse(value);
@@ -96,7 +104,10 @@ function optionDefinitions() {
96
104
  flag(undefined, 'tlsv1.3', 'Use TLSv1.3 or greater'),
97
105
  arg(undefined, 'tls-max', '<version> Maximum TLS version: 1.0, 1.1, 1.2, 1.3 or default', tlsMaxCb),
98
106
  arg(undefined, 'tls13-ciphers', '<list> TLS 1.3 cipher suites to use'),
99
- arg(undefined, 'trfetch-options', '<json> Extra trFetch options as a JSON object', jsonObjectCb, true),
107
+ arg(undefined, 'tr-fetch-max-crl-bytes', '<bytes> Largest CRL accepted (default 16777216)', positiveBytesCb),
108
+ arg(undefined, 'tr-fetch-crl-url', '<url> Fetch the server certificate\'s CRL from this URL instead', networkUrlCb),
109
+ arg(undefined, 'tr-fetch-ocsp-url', '<url> Query this OCSP responder for the server certificate instead', networkUrlCb),
110
+ arg(undefined, 'tr-fetch-options', '<json> Extra trFetch options as a JSON object', jsonObjectCb, true),
100
111
  arg('u', 'user', '<user:password> Server user and password (basic authentication)'),
101
112
  arg(undefined, 'url', '<url> URL to work with', undefined, true),
102
113
  flag('v', 'verbose', 'Make the operation more talkative, including trFetch debug output', true),
@@ -123,12 +134,30 @@ function parseArguments(argv) {
123
134
  if (value('output').length && value('remote-name')) {
124
135
  throw new UsageError('--output and --remote-name cannot be used together');
125
136
  }
126
- const trFetchOptions = Object.assign({}, ...value('trfetch-options'));
137
+ const trFetchOptions = Object.assign({}, ...value('tr-fetch-options'));
127
138
  for (const key of Object.keys(trFetchOptions)) {
128
139
  if (! /^trFetch/.test(key)) {
129
- throw new UsageError(`--trfetch-options accepts only trFetch options, not ${JSON.stringify(key)}`);
140
+ throw new UsageError(`--tr-fetch-options accepts only trFetch options, not ${JSON.stringify(key)}`);
130
141
  }
131
142
  }
143
+ // Dedicated options take precedence over the same settings given in
144
+ // --tr-fetch-options, regardless of their order.
145
+ if (value('tr-fetch-max-crl-bytes') !== undefined) {
146
+ const policy = trFetchOptions.trFetchCrlPolicy;
147
+ // Leave a malformed policy for trFetch to reject.
148
+ if ((policy === undefined) || (policy && (typeof(policy) === 'object') && ! Array.isArray(policy))) {
149
+ trFetchOptions.trFetchCrlPolicy = { ...policy, maxCrlBytes: value('tr-fetch-max-crl-bytes') };
150
+ }
151
+ }
152
+ if (value('tr-fetch-crl-url') !== undefined) {
153
+ if (value('crlfile') !== undefined) {
154
+ throw new UsageError('--tr-fetch-crl-url and --crlfile cannot be used together');
155
+ }
156
+ trFetchOptions.trFetchCrlDistributionPointOverride = value('tr-fetch-crl-url');
157
+ }
158
+ if (value('tr-fetch-ocsp-url') !== undefined) {
159
+ trFetchOptions.trFetchOcspUriOverride = value('tr-fetch-ocsp-url');
160
+ }
132
161
  const data = [ 'data', 'data-ascii', 'data-binary', 'data-raw', 'data-urlencode', 'json' ]
133
162
  .flatMap(value).sort((a, b) => a.seq - b.seq);
134
163
  if (value('head') && data.length && ! value('get')) {