tr-fetch 0.9.2 → 0.9.4

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
@@ -80,8 +80,10 @@ await trFetch('https://example.com/', { trFetchCrlPolicy: { maxCrlBytes: 64 * 10
80
80
  A larger download is an unreachable distribution point; larger
81
81
  `trFetchCrlOverride` data is an invalid CRL. A cached CRL above the caller's
82
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.
83
+ The CRL is downloaded into a single buffer, verified and scanned in place,
84
+ and only its serial numbers are kept. Peak memory while fetching a CRL is
85
+ roughly three to four times its size, most of it in Node's HTTP stream
86
+ buffering, and is released afterwards.
85
87
 
86
88
  ### Disabling a check
87
89
 
@@ -210,8 +212,21 @@ and a matching certificate identifier. Intermediates use their own OCSP URIs.
210
212
 
211
213
  Fetched and validated CRLs share an in-memory LRU cache within the loaded
212
214
  module. Cache keys include the issuer certificate and distribution URL.
213
- Certificate decisions and failed lookups are never cached. A cached CRL is
214
- validated against the current certificate and current time on every use.
215
+ Certificate decisions, failed lookups and `trFetchCrlOverride` data are never
216
+ cached.
217
+
218
+ The cache does not keep the CRL itself. After a CRL is authenticated, its
219
+ revoked serial numbers are stored as sorted fixed-width records, one array per
220
+ serial length, together with the CRL's validity period and scope. Memory is
221
+ about the size of the serial numbers (14 MB for a 43 MB CRL of 875,000
222
+ entries), outside the JavaScript heap, and a lookup is a binary search.
223
+ Serials match only by identical DER bytes.
224
+
225
+ Checks that depend only on the CRL and its issuer, including the signature,
226
+ run once per download. Checks that depend on the certificate or the time run
227
+ on every use: validity dates, scope and distribution point, the certificate's
228
+ issuer name and signature, and the serial lookup. An entry serves only the
229
+ issuer certificate that authenticated it.
215
230
 
216
231
  - `trFetchCrlCacheSize` defaults to `32` entries. Any integer **0 or less**
217
232
  disables caching for that call.
@@ -338,7 +353,8 @@ A successfully authenticated CRL listing the certificate immediately invokes
338
353
  the revocation policy; it does not trigger a search for a different answer.
339
354
 
340
355
  Supported CRLs are direct, complete CRLs with SHA-256, SHA-384 or SHA-512
341
- signatures supported by PKIjs/Web Crypto, including RSA and ECDSA. Validation
356
+ RSA (PKCS #1 v1.5 or PSS) or ECDSA signatures, verified with Node's crypto
357
+ module. RSA-PSS must use MGF1 with the signature hash. Validation
342
358
  checks issuer binding, signature algorithm consistency, `cRLSign` key usage
343
359
  when present, authority key identifiers when available, dates and scope.
344
360
  `nextUpdate` is required. Matching named issuing distribution points and
@@ -431,15 +447,27 @@ tr-curl --cacert private-ca.pem --crlfile current.crl https://internal.example/
431
447
  | Failure and limits | `-f/--fail`, `--fail-with-body`, `--fail-early`, `--max-filesize`, `-m/--max-time` |
432
448
  | Messages | `-v/--verbose`, `-s/--silent`, `-S/--show-error`, `--no-progress-meter`, `-#/--progress-bar`, `-h/--help`, `-V/--version` |
433
449
  | TLS | `-k/--insecure`, `--cacert`, `--crlfile`, `-1/--tlsv1`, `--tlsv1.0` … `--tlsv1.3`, `--tls-max`, `--ciphers`, `--tls13-ciphers` |
434
- | trFetch | `--trfetch-options <json>` |
450
+ | trFetch | `--tr-fetch-max-crl-bytes`, `--tr-fetch-crl-url`, `--tr-fetch-ocsp-url`, `--tr-fetch-options` |
435
451
 
436
452
  `--verbose` prints the request and response headers, prefixed with `>` and
437
453
  `<` like curl, and also enables `trFetchDebug`, so the revocation diagnostics
438
- appear on stderr. `--trfetch-options` takes a JSON object of trFetch options,
439
- for example `'{"trFetchCrlCheckDepth":"full-chain"}'`. The option can be
440
- repeated, and later objects override earlier keys. `--crlfile` sets
441
- `trFetchCrlOverride`. `--cacert` replaces Node's default CA certificates for
442
- the process.
454
+ appear on stderr. `--cacert` replaces Node's default CA certificates for the
455
+ process.
456
+
457
+ The `--tr-fetch-*` options set trFetch options directly:
458
+
459
+ | Option | trFetch option |
460
+ |---|---|
461
+ | `--tr-fetch-max-crl-bytes <bytes>` | `trFetchCrlPolicy.maxCrlBytes`, a positive integer |
462
+ | `--tr-fetch-crl-url <url>` | `trFetchCrlDistributionPointOverride` |
463
+ | `--tr-fetch-ocsp-url <url>` | `trFetchOcspUriOverride` |
464
+ | `--crlfile <file>` | `trFetchCrlOverride`, read from the file |
465
+
466
+ `--tr-fetch-options` takes a JSON object of any trFetch options, for example
467
+ `'{"trFetchCrlCheckDepth":"full-chain"}'`. It can be repeated; later objects
468
+ replace earlier keys. The options above take precedence over the same
469
+ settings in it, whatever their order, and `--tr-fetch-max-crl-bytes` is merged
470
+ into its `trFetchCrlPolicy` rather than replacing it.
443
471
 
444
472
  `--insecure` cannot be implemented through trFetch, which never relaxes TLS
445
473
  verification. With `-k`, tr-curl uses plain fetch with an unverified TLS
package/cache.js CHANGED
@@ -7,8 +7,9 @@ function cacheEntry(key) {
7
7
  return { issuer: key.slice(0, separator), source: debugUrl(key.slice(separator + 1)) };
8
8
  }
9
9
 
10
- // Cache verified CRLs, never a policy decision or a failed lookup. A caller's
11
- // shorter TTL also applies to entries populated by an earlier caller.
10
+ // Cache authenticated revocation lists (see RevocationList in crl.js), never
11
+ // a policy decision, a failed lookup or the CRL bytes. A caller's shorter TTL
12
+ // also applies to entries populated by an earlier caller.
12
13
  class CrlCache {
13
14
  #entries = new Map();
14
15
 
package/check.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { createHash } = require('node:crypto');
4
- const { parseCertificate, distributionPoints, parseCrl, validateCrl } = require('./crl');
4
+ const { parseCertificate, distributionPoints, parseCrl, authenticateCrl, checkRevocationList } = require('./crl');
5
5
  const { networkUrl, downloadCrl } = require('./download');
6
6
  const { TrFetchCrlError, TrFetchOcspError, applyPolicy } = require('./errors');
7
7
  const { checkOcspCertificate } = require('./ocsp');
@@ -83,7 +83,7 @@ async function checkCertificate(peer, hostname, options, signal, override, debug
83
83
  failures.push(issue('unreachableCrlDistributionPoint', 'CRL distribution point lookup limit (32) exceeded'));
84
84
  break;
85
85
  }
86
- let crl, key, bytes;
86
+ let list, key, bytes;
87
87
  const fetchedAt = Date.now();
88
88
  if (forcedCrl !== undefined) {
89
89
  bytes = forcedCrl;
@@ -91,14 +91,14 @@ async function checkCertificate(peer, hostname, options, signal, override, debug
91
91
  try {
92
92
  const url = networkUrl(source);
93
93
  key = issuerId + ':' + url.href;
94
- crl = cache.get(key, options.cacheSize, options.cacheTTL, Date.now(), debug);
94
+ list = cache.get(key, options.cacheSize, options.cacheTTL, Date.now(), debug);
95
95
  // A CRL cached under a higher limit must not bypass this one.
96
- if (crl && (crl.size > options.policy.maxCrlBytes)) {
96
+ if (list && (list.size > options.policy.maxCrlBytes)) {
97
97
  debug?.('CRL cache entry not used', { source: debugUrl(url), reason: 'exceeds maxCrlBytes',
98
- bytes: crl.size, maxCrlBytes: options.policy.maxCrlBytes });
99
- crl = undefined;
98
+ bytes: list.size, maxCrlBytes: options.policy.maxCrlBytes });
99
+ list = undefined;
100
100
  }
101
- if (! crl) {
101
+ if (! list) {
102
102
  bytes = await downloadCrl(url, signal, debug, options.policy.maxCrlBytes);
103
103
  }
104
104
  } catch (cause) {
@@ -110,19 +110,22 @@ async function checkCertificate(peer, hostname, options, signal, override, debug
110
110
  }
111
111
  let result;
112
112
  try {
113
- if (! crl) {
114
- crl = parseCrl(bytes, options.policy.maxCrlBytes);
115
- debug?.('CRL parsed', { source: (source === undefined) ? 'trFetchCrlOverride' : debugUrl(source),
116
- revokedEntries: crl.revokedCount });
113
+ if (! list) {
114
+ const label = (source === undefined) ? 'trFetchCrlOverride' : debugUrl(source);
115
+ const parsed = parseCrl(bytes, options.policy.maxCrlBytes);
116
+ debug?.('CRL parsed', { source: label, revokedEntries: parsed.revokedCount });
117
+ list = await authenticateCrl(parsed, issuer);
118
+ debug?.('CRL authenticated and indexed', { source: label, serials: list.serials.count,
119
+ indexBytes: list.serials.bytes });
117
120
  }
118
- result = await validateCrl(crl, certificate, issuer, point.urls);
121
+ result = await checkRevocationList(list, certificate, issuer, point.urls);
119
122
  } catch (cause) {
120
123
  failures.push(issue('invalidCrl',
121
124
  `Invalid CRL from ${(source === undefined) ? 'trFetchCrlOverride' : sourceLabel(source)}: ${cause.message}`, cause, source));
122
125
  continue;
123
126
  }
124
127
  if ((key !== undefined) && (bytes !== undefined)) {
125
- cache.set(key, crl, result.nextUpdate, fetchedAt, options.cacheSize, options.cacheTTL, Date.now(), debug);
128
+ cache.set(key, list, result.nextUpdate, fetchedAt, options.cacheSize, options.cacheTTL, Date.now(), debug);
126
129
  }
127
130
  debug?.('CRL serial lookup completed', { source: (source === undefined) ? 'trFetchCrlOverride' : debugUrl(source),
128
131
  result: result.revoked ? 'revoked' : 'not listed', authenticated: true,
package/crl.js CHANGED
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const { X509Certificate, constants, verify } = require('node:crypto');
3
4
  const asn1 = require('asn1js');
4
5
  const pki = require('pkijs');
5
- const { cryptoEngine, parseDer, derElement, derHeader, oidString, extensionsById, extensionValue,
6
+ const { cryptoEngine, parseDer, derElement, derRead, derHeader, oidString, extensionsById, extensionValue,
6
7
  parseCertificate } = require('./pkiutils');
7
8
  const { DEFAULT_MAX_CRL_BYTES } = require('./options');
9
+ const { buildSerialIndex } = require('./serials');
8
10
 
9
11
  const TIME_TAGS = [ 0x17, 0x18 ];
10
12
 
@@ -72,33 +74,69 @@ function parseCrl(bytes, maxBytes = DEFAULT_MAX_CRL_BYTES) {
72
74
  revokedCount: scan.count, entryProblem: scan.problem };
73
75
  }
74
76
 
75
- function checkEntryExtensions(bytes, list) {
76
- const seen = new Set();
77
+ const OID_CERTIFICATE_ISSUER = Buffer.from([ 0x55, 0x1d, 0x1d ]);
78
+ const OID_REASON_CODE = Buffer.from([ 0x55, 0x1d, 0x15 ]);
79
+
80
+ function sameBytes(bytes, start, end, expected) {
81
+ if ((end - start) !== expected.length) {
82
+ return false;
83
+ }
84
+ for (let i = 0; i < expected.length; i++) {
85
+ if (bytes[start + i] !== expected[i]) {
86
+ return false;
87
+ }
88
+ }
89
+ return true;
90
+ }
91
+
92
+ function sameRange(bytes, a, b, c, d) {
93
+ if ((b - a) !== (d - c)) {
94
+ return false;
95
+ }
96
+ for (let i = 0; i < (b - a); i++) {
97
+ if (bytes[a + i] !== bytes[c + i]) {
98
+ return false;
99
+ }
100
+ }
101
+ return true;
102
+ }
103
+
104
+ // Reusable elements for walking entries: hundreds of thousands of entries
105
+ // must not create an object each. Walks are synchronous, so one set per walk.
106
+ function scratch() {
107
+ return { entry: {}, number: {}, date: {}, extensions: {}, extension: {}, id: {}, value: {}, reason: {}, ids: [] };
108
+ }
109
+
110
+ function checkEntryExtensions(bytes, list, s) {
77
111
  let problem;
112
+ s.ids.length = 0;
78
113
  for (let offset = list.start; offset < list.end;) {
79
- const extension = derElement(bytes, offset, list.end);
114
+ const extension = derRead(bytes, offset, list.end, s.extension);
80
115
  offset = extension.end;
81
- const id = derElement(bytes, extension.start, extension.end);
82
- let value = derElement(bytes, id.end, extension.end);
116
+ const id = derRead(bytes, extension.start, extension.end, s.id);
117
+ let value = derRead(bytes, id.end, extension.end, s.value);
83
118
  let critical = false;
84
119
  if (value.tag === 0x01) {
85
120
  critical = (value.end > value.start) && (bytes[value.start] !== 0);
86
- value = derElement(bytes, value.end, extension.end);
121
+ value = derRead(bytes, value.end, extension.end, s.value);
87
122
  }
88
123
  if ((extension.tag !== 0x30) || (id.tag !== 0x06) || (value.tag !== 0x04) || (value.end !== extension.end)) {
89
124
  throw new Error('Malformed CRL entry extension');
90
125
  }
91
- const extnID = oidString(bytes.subarray(id.start, id.end));
92
- if (seen.has(extnID)) {
93
- problem ??= `Duplicate extension ${extnID}`;
126
+ // Object identifiers compare as bytes; strings only for messages.
127
+ const extnID = () => oidString(bytes.subarray(id.start, id.end));
128
+ for (let i = 0; i < s.ids.length; i += 2) {
129
+ if (sameRange(bytes, s.ids[i], s.ids[i + 1], id.start, id.end)) {
130
+ problem ??= `Duplicate extension ${extnID()}`;
131
+ }
94
132
  }
95
- seen.add(extnID);
96
- if (extnID === '2.5.29.29') {
133
+ s.ids.push(id.start, id.end);
134
+ if (sameBytes(bytes, id.start, id.end, OID_CERTIFICATE_ISSUER)) {
97
135
  problem ??= 'Indirect CRL certificateIssuer entries are unsupported';
98
136
  } 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);
137
+ problem ??= `Unsupported critical CRL entry extension ${extnID()}`;
138
+ } else if (sameBytes(bytes, id.start, id.end, OID_REASON_CODE)) {
139
+ const reason = derRead(bytes, value.start, value.end, s.reason);
102
140
  let code = 0;
103
141
  for (let i = reason.start; i < reason.end; i++) {
104
142
  code = (code * 256) + bytes[i];
@@ -113,27 +151,28 @@ function checkEntryExtensions(bytes, list) {
113
151
 
114
152
  // Walk the revoked entries without decoding them into objects. Structural
115
153
  // 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) {
154
+ // is authenticated. With visit, call visit(start, end) for each serial in
155
+ // bytes instead of checking entry extensions again.
156
+ function scanEntries(bytes, entries, visit) {
157
+ const s = scratch();
118
158
  let count = 0;
119
159
  let problem;
120
- let revoked = false;
121
160
  for (let offset = entries?.start; offset < entries?.end;) {
122
- const entry = derElement(bytes, offset, entries.end);
161
+ const entry = derRead(bytes, offset, entries.end, s.entry);
123
162
  offset = entry.end;
124
- const number = derElement(bytes, entry.start, entry.end);
125
- const date = derElement(bytes, number.end, entry.end);
163
+ const number = derRead(bytes, entry.start, entry.end, s.number);
164
+ const date = derRead(bytes, number.end, entry.end, s.date);
126
165
  let next = date.end;
127
- if ((entry.tag !== 0x30) || (number.tag !== 0x02) || ! TIME_TAGS.includes(date.tag)) {
166
+ if ((entry.tag !== 0x30) || (number.tag !== 0x02) || ((date.tag !== 0x17) && (date.tag !== 0x18))) {
128
167
  throw new Error('Malformed revoked certificate entry');
129
168
  }
130
169
  if (next < entry.end) {
131
- const extensions = derElement(bytes, next, entry.end);
170
+ const extensions = derRead(bytes, next, entry.end, s.extensions);
132
171
  if (extensions.tag !== 0x30) {
133
172
  throw new Error('Malformed revoked certificate entry');
134
173
  }
135
- if (serial === undefined) {
136
- problem ??= checkEntryExtensions(bytes, extensions);
174
+ if (visit === undefined) {
175
+ problem ??= checkEntryExtensions(bytes, extensions, s);
137
176
  }
138
177
  next = extensions.end;
139
178
  }
@@ -141,29 +180,103 @@ function scanEntries(bytes, entries, serial) {
141
180
  throw new Error('Malformed revoked certificate entry');
142
181
  }
143
182
  count++;
144
- if ((serial !== undefined) && bytes.subarray(number.start, number.end).equals(serial)) {
145
- revoked = true;
146
- }
183
+ visit?.(number.start, number.end);
147
184
  }
148
- return { count, problem, revoked };
185
+ return { count, problem };
149
186
  }
150
187
 
151
- function checkValidity(crl, now = Date.now()) {
152
- const start = crl.thisUpdate.value.getTime();
153
- const end = crl.nextUpdate?.value.getTime();
154
- if (! Number.isFinite(start) || ! Number.isFinite(end) || (end <= start)) {
155
- throw new Error('CRL must have a valid thisUpdate and a later nextUpdate');
188
+ const RSA_PSS = '1.2.840.113549.1.1.10';
189
+ const MGF1 = '1.2.840.113549.1.1.8';
190
+ // Signature algorithms and the issuer key types they need. The hash is
191
+ // checked separately and must be SHA-256, SHA-384 or SHA-512.
192
+ const SIGNATURE_KEY_TYPES = {
193
+ '1.2.840.113549.1.1.11': [ 'rsa' ],
194
+ '1.2.840.113549.1.1.12': [ 'rsa' ],
195
+ '1.2.840.113549.1.1.13': [ 'rsa' ],
196
+ [RSA_PSS]: [ 'rsa', 'rsa-pss' ],
197
+ '1.2.840.10045.4.3.2': [ 'ec' ],
198
+ '1.2.840.10045.4.3.3': [ 'ec' ],
199
+ '1.2.840.10045.4.3.4': [ 'ec' ]
200
+ };
201
+
202
+ // Node's synchronous verify hashes the TBS bytes where they are. Web Crypto
203
+ // and asynchronous verification copy them first, which for a CRL of tens of
204
+ // megabytes doubles its memory. X.509 ECDSA signatures are already DER, the
205
+ // form Node expects.
206
+ function crlSignatureValid(tbs, crl, issuerDer, hash) {
207
+ const algorithm = crl.signatureAlgorithm.algorithmId;
208
+ const key = new X509Certificate(issuerDer).publicKey;
209
+ if (! SIGNATURE_KEY_TYPES[algorithm]?.includes(key.asymmetricKeyType)) {
210
+ return false;
211
+ }
212
+ const signature = crl.signatureValue.valueBlock;
213
+ if (signature.unusedBits) {
214
+ return false;
215
+ }
216
+ let options = key;
217
+ if (algorithm === RSA_PSS) {
218
+ const params = new pki.RSASSAPSSParams({ schema: crl.signatureAlgorithm.algorithmParams });
219
+ const mgfHash = (params.maskGenAlgorithm.algorithmId === MGF1) ?
220
+ new pki.AlgorithmIdentifier({ schema: params.maskGenAlgorithm.algorithmParams }).algorithmId : undefined;
221
+ // Node applies MGF1 with the signature hash; nothing else is accepted.
222
+ if ((mgfHash !== params.hashAlgorithm.algorithmId) || (params.trailerField !== 1)) {
223
+ throw new Error('Unsupported RSA-PSS parameters in CRL signature');
224
+ }
225
+ options = { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: params.saltLength };
226
+ }
227
+ try {
228
+ return verify(hash.replace('-', '').toLowerCase(), tbs, options, signature.valueHexView);
229
+ } catch (_) {
230
+ return false;
156
231
  }
157
- if (start > now) {
232
+ }
233
+
234
+ function checkDates(thisUpdate, nextUpdate, now) {
235
+ if (thisUpdate > now) {
158
236
  throw new Error('CRL is not yet valid (thisUpdate is in the future)');
159
237
  }
160
- if (end <= now) {
238
+ if (nextUpdate <= now) {
161
239
  throw new Error('CRL has expired (nextUpdate has passed)');
162
240
  }
163
- return end;
164
241
  }
165
242
 
166
- function checkScope(crl, certificate, urls) {
243
+ function certificateDer(certificate) {
244
+ return Buffer.from(certificate.toSchema().toBER());
245
+ }
246
+
247
+ // An authenticated CRL reduced to what checking a certificate needs: the
248
+ // revoked serials, the validity period and the scope. It does not retain the
249
+ // CRL itself, and serves only certificates of the issuer that signed it.
250
+ class RevocationList {
251
+ #issuerCertificate;
252
+
253
+ constructor(fields) {
254
+ this.#issuerCertificate = fields.issuerCertificate;
255
+ this.issuer = fields.issuer;
256
+ this.thisUpdate = fields.thisUpdate;
257
+ this.nextUpdate = fields.nextUpdate;
258
+ this.scope = fields.scope;
259
+ this.serials = fields.serials;
260
+ this.revokedCount = fields.revokedCount;
261
+ this.size = fields.size;
262
+ Object.freeze(this);
263
+ }
264
+
265
+ signedBy(issuer) {
266
+ return certificateDer(issuer).equals(this.#issuerCertificate);
267
+ }
268
+ }
269
+
270
+ // Everything that depends only on the CRL and its issuer: structure, scope
271
+ // support, issuer binding, signing permission, algorithms and the signature.
272
+ // Runs once per downloaded CRL; the result can be cached for the issuer.
273
+ async function authenticateCrl(parsed, issuer) {
274
+ const crl = parsed.crl;
275
+ const thisUpdate = crl.thisUpdate.value.getTime();
276
+ const nextUpdate = crl.nextUpdate?.value.getTime();
277
+ if (! Number.isFinite(thisUpdate) || ! Number.isFinite(nextUpdate) || (nextUpdate <= thisUpdate)) {
278
+ throw new Error('CRL must have a valid thisUpdate and a later nextUpdate');
279
+ }
167
280
  if (! [ 0, 1 ].includes(crl.version)) {
168
281
  throw new Error('Unsupported CRL version');
169
282
  }
@@ -176,37 +289,25 @@ function checkScope(crl, certificate, urls) {
176
289
  throw new Error(`Unsupported critical CRL extension ${extension.extnID}`);
177
290
  }
178
291
  }
292
+ const scope = { onlyUserCertificates: false, onlyCaCertificates: false, distributionPoints: undefined };
179
293
  const idpExtension = extensions.get('2.5.29.28');
180
294
  if (idpExtension) {
181
295
  const idp = extensionValue(idpExtension, pki.IssuingDistributionPoint);
182
296
  if (idp.indirectCRL || (idp.onlySomeReasons !== undefined) || idp.onlyContainsAttributeCerts) {
183
297
  throw new Error('Indirect, reason-limited and attribute-certificate CRLs are unsupported');
184
298
  }
185
- const basic = extensionsById(certificate.extensions).get('2.5.29.19');
186
- const isCA = basic ? extensionValue(basic, pki.BasicConstraints).cA : false;
187
- if ((idp.onlyContainsUserCerts && isCA) || (idp.onlyContainsCACerts && ! isCA)) {
188
- throw new Error('CRL scope does not cover this certificate type');
189
- }
299
+ scope.onlyUserCertificates = !! idp.onlyContainsUserCerts;
300
+ scope.onlyCaCertificates = !! idp.onlyContainsCACerts;
190
301
  if (idp.distributionPoint !== undefined) {
191
- if (! Array.isArray(idp.distributionPoint) ||
192
- ! idp.distributionPoint.some(x => (x.type === 6) && urls.includes(x.value))) {
193
- throw new Error('CRL issuing distribution point does not match the effective distribution point');
194
- }
302
+ // Only URI names can match; other name forms never do.
303
+ scope.distributionPoints = Array.isArray(idp.distributionPoint) ?
304
+ idp.distributionPoint.filter(x => x.type === 6).map(x => x.value) : [];
195
305
  }
196
306
  }
197
- return extensions;
198
- }
199
-
200
- async function validateCrl(parsed, certificate, issuer, urls, now = Date.now()) {
201
- const crl = parsed.crl;
202
- const nextUpdate = checkValidity(crl, now);
203
- const extensions = checkScope(crl, certificate, urls);
204
- if (! crl.issuer.isEqual(certificate.issuer) || ! crl.issuer.isEqual(issuer.subject)) {
307
+ Object.freeze(scope.distributionPoints);
308
+ if (! crl.issuer.isEqual(issuer.subject)) {
205
309
  throw new Error('CRL issuer does not match the certificate issuer');
206
310
  }
207
- if (! await certificate.verify(issuer, cryptoEngine)) {
208
- throw new Error('CRL signing certificate did not issue the checked certificate');
209
- }
210
311
  if (! Buffer.from(crl.signature.toSchema().toBER()).equals(Buffer.from(crl.signatureAlgorithm.toSchema().toBER()))) {
211
312
  throw new Error('CRL signature algorithm identifiers disagree');
212
313
  }
@@ -242,16 +343,49 @@ async function validateCrl(parsed, certificate, issuer, urls, now = Date.now())
242
343
  throw new Error('Malformed CRL number');
243
344
  }
244
345
  // The signature covers the original TBS bytes, including the entries.
245
- if (! await cryptoEngine.verifyWithPublicKey(parsed.tbs, crl.signatureValue, issuer.subjectPublicKeyInfo, crl.signatureAlgorithm)) {
346
+ const issuerDer = certificateDer(issuer);
347
+ if (! crlSignatureValid(parsed.tbs, crl, issuerDer, hash)) {
246
348
  throw new Error('CRL signature verification failed');
247
349
  }
248
350
  if (parsed.entryProblem) {
249
351
  throw new Error(parsed.entryProblem);
250
352
  }
251
- const serial = Buffer.from(certificate.serialNumber.valueBlock.valueHexView);
252
- const { revoked } = scanEntries(parsed.bytes, parsed.entries, serial);
253
- checkValidity(crl);
254
- return { revoked, nextUpdate };
353
+ // Only an authenticated CRL is worth indexing.
354
+ const serials = buildSerialIndex(parsed.bytes, visit => scanEntries(parsed.bytes, parsed.entries, visit));
355
+ return new RevocationList({ issuerCertificate: issuerDer, issuer: crl.issuer, thisUpdate, nextUpdate,
356
+ scope: Object.freeze(scope), serials, revokedCount: parsed.revokedCount, size: parsed.size });
357
+ }
358
+
359
+ // Everything that depends on the checked certificate or the current time.
360
+ // Runs on every use, including for cached lists.
361
+ async function checkRevocationList(list, certificate, issuer, urls, now = Date.now()) {
362
+ if (! list.signedBy(issuer)) {
363
+ throw new Error('CRL was authenticated for a different issuer certificate');
364
+ }
365
+ checkDates(list.thisUpdate, list.nextUpdate, now);
366
+ const basic = extensionsById(certificate.extensions).get('2.5.29.19');
367
+ const isCA = basic ? extensionValue(basic, pki.BasicConstraints).cA : false;
368
+ if ((list.scope.onlyUserCertificates && isCA) || (list.scope.onlyCaCertificates && ! isCA)) {
369
+ throw new Error('CRL scope does not cover this certificate type');
370
+ }
371
+ if ((list.scope.distributionPoints !== undefined) && ! list.scope.distributionPoints.some(x => urls.includes(x))) {
372
+ throw new Error('CRL issuing distribution point does not match the effective distribution point');
373
+ }
374
+ if (! list.issuer.isEqual(certificate.issuer)) {
375
+ throw new Error('CRL issuer does not match the certificate issuer');
376
+ }
377
+ if (! await certificate.verify(issuer, cryptoEngine)) {
378
+ throw new Error('CRL signing certificate did not issue the checked certificate');
379
+ }
380
+ const revoked = list.serials.has(Buffer.from(certificate.serialNumber.valueBlock.valueHexView));
381
+ // Checks above can take time; the list must still be valid now.
382
+ checkDates(list.thisUpdate, list.nextUpdate, Date.now());
383
+ return { revoked, nextUpdate: list.nextUpdate };
384
+ }
385
+
386
+ async function validateCrl(parsed, certificate, issuer, urls, now = Date.now()) {
387
+ return checkRevocationList(await authenticateCrl(parsed, issuer), certificate, issuer, urls, now);
255
388
  }
256
389
 
257
- module.exports = { parseCertificate, distributionPoints, parseCrl, validateCrl };
390
+ module.exports = { RevocationList, parseCertificate, distributionPoints, parseCrl, authenticateCrl, checkRevocationList,
391
+ validateCrl };
package/download.js CHANGED
@@ -20,6 +20,35 @@ function networkUrl(value, kind = 'CRL distribution point') {
20
20
  return url;
21
21
  }
22
22
 
23
+ // Read the body into one buffer as it arrives, instead of collecting chunks
24
+ // and concatenating them, which needs twice the memory at the end. An
25
+ // uncompressed body's Content-Length sizes the buffer up front and lets an
26
+ // oversized download fail before it is read; otherwise the buffer grows.
27
+ // Only the bytes received are ever exposed.
28
+ async function readBody(response, maxBytes, tooLarge) {
29
+ const declared = response.headers.has('content-encoding') ? NaN : Number(response.headers.get('content-length') ?? NaN);
30
+ const known = Number.isSafeInteger(declared) && (declared >= 0);
31
+ if (known && (declared > maxBytes)) {
32
+ await response.body?.cancel();
33
+ throw new Error(tooLarge);
34
+ }
35
+ let buffer = Buffer.allocUnsafeSlow(known ? declared : Math.min(64 * 1024, maxBytes));
36
+ let length = 0;
37
+ for await (const chunk of response.body ?? []) {
38
+ if ((length + chunk.byteLength) > maxBytes) {
39
+ throw new Error(tooLarge);
40
+ }
41
+ if ((length + chunk.byteLength) > buffer.length) {
42
+ const grown = Buffer.allocUnsafeSlow(Math.min(maxBytes, Math.max(length + chunk.byteLength, buffer.length * 2)));
43
+ buffer.copy(grown, 0, 0, length);
44
+ buffer = grown;
45
+ }
46
+ buffer.set(chunk, length);
47
+ length += chunk.byteLength;
48
+ }
49
+ return (length === buffer.length) ? buffer : buffer.subarray(0, length);
50
+ }
51
+
23
52
  async function download(value, signal, body, debug, maxCrlBytes) {
24
53
  const kind = (body === undefined) ? 'CRL' : 'OCSP';
25
54
  const maxBytes = (body === undefined) ? maxCrlBytes : 1024 * 1024;
@@ -68,20 +97,11 @@ async function download(value, signal, body, debug, maxCrlBytes) {
68
97
  await response.body?.cancel();
69
98
  throw new Error(`${kind} download returned HTTP ${response.status}`);
70
99
  }
71
- const chunks = [];
72
- let length = 0;
73
- if (response.body) {
74
- for await (const chunk of response.body) {
75
- length += chunk.byteLength;
76
- if (length > maxBytes) {
77
- throw new Error((body === undefined) ? `CRL download exceeds maxCrlBytes (${maxBytes} bytes)` :
78
- 'OCSP download exceeds the 1 MiB size limit');
79
- }
80
- chunks.push(chunk);
81
- }
82
- }
83
- debug?.((kind === 'CRL') ? 'CRL fetched' : 'OCSP response fetched', { source: debugUrl(url), bytes: length });
84
- return Buffer.concat(chunks, length);
100
+ const tooLarge = (body === undefined) ? `CRL download exceeds maxCrlBytes (${maxBytes} bytes)` :
101
+ 'OCSP download exceeds the 1 MiB size limit';
102
+ const result = await readBody(response, maxBytes, tooLarge);
103
+ debug?.((kind === 'CRL') ? 'CRL fetched' : 'OCSP response fetched', { source: debugUrl(url), bytes: result.length });
104
+ return result;
85
105
  }
86
106
  throw new Error(`${kind} download exceeded the five-redirect limit`);
87
107
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-fetch",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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": {
@@ -21,6 +21,7 @@
21
21
  "ocsp.js",
22
22
  "options.js",
23
23
  "pkiutils.js",
24
+ "serials.js",
24
25
  "transport.js",
25
26
  "bin/",
26
27
  "tr-curl/"
package/pkiutils.js CHANGED
@@ -19,7 +19,13 @@ function parseDer(bytes, Type) {
19
19
 
20
20
  // A minimal DER element reader for data too large to decode into an object
21
21
  // tree: single-byte tags and definite lengths only, always within bounds.
22
+ // derRead fills and returns a caller-supplied object, so loops over large
23
+ // CRLs create no garbage; derElement returns a new one.
22
24
  function derElement(bytes, offset, end = bytes.length) {
25
+ return derRead(bytes, offset, end, {});
26
+ }
27
+
28
+ function derRead(bytes, offset, end, element) {
23
29
  if ((offset + 2) > end) {
24
30
  throw new Error('Malformed ASN.1 data: truncated element');
25
31
  }
@@ -43,7 +49,11 @@ function derElement(bytes, offset, end = bytes.length) {
43
49
  if ((start + length) > end) {
44
50
  throw new Error('Malformed ASN.1 data: element exceeds its container');
45
51
  }
46
- return { tag, offset, start, end: start + length };
52
+ element.tag = tag;
53
+ element.offset = offset;
54
+ element.start = start;
55
+ element.end = start + length;
56
+ return element;
47
57
  }
48
58
 
49
59
  function derHeader(tag, length) {
@@ -99,4 +109,4 @@ function parseCertificate(bytes) {
99
109
  return parseDer(bytes, pki.Certificate);
100
110
  }
101
111
 
102
- module.exports = { cryptoEngine, parseDer, derElement, derHeader, oidString, extensionsById, extensionValue, parseCertificate };
112
+ module.exports = { cryptoEngine, parseDer, derElement, derRead, derHeader, oidString, extensionsById, extensionValue, parseCertificate };
package/serials.js ADDED
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // Revoked serial numbers as sorted, de-duplicated fixed-width records, one
4
+ // Buffer per serial length. Memory is the serial bytes themselves, outside
5
+ // the JS heap, and a lookup is a binary search among serials of the probe's
6
+ // length. Serials match only when their bytes are identical, as in DER.
7
+ class SerialIndex {
8
+ #groups;
9
+ #count;
10
+ #bytes;
11
+
12
+ constructor(groups) {
13
+ this.#groups = groups;
14
+ this.#count = 0;
15
+ this.#bytes = 0;
16
+ for (const group of groups.values()) {
17
+ this.#count += group.count;
18
+ this.#bytes += group.data.length;
19
+ }
20
+ }
21
+
22
+ // Number of distinct serials.
23
+ get count() {
24
+ return this.#count;
25
+ }
26
+
27
+ // Bytes of serial data held.
28
+ get bytes() {
29
+ return this.#bytes;
30
+ }
31
+
32
+ has(serial) {
33
+ const group = this.#groups.get(serial.length);
34
+ if (! group) {
35
+ return false;
36
+ }
37
+ const { data, width } = group;
38
+ let low = 0;
39
+ let high = group.count - 1;
40
+ while (low <= high) {
41
+ const middle = (low + high) >>> 1;
42
+ const base = middle * width;
43
+ let difference = 0;
44
+ for (let i = 0; (i < width) && ! difference; i++) {
45
+ difference = data[base + i] - serial[i];
46
+ }
47
+ if (! difference) {
48
+ return true;
49
+ }
50
+ if (difference < 0) {
51
+ low = middle + 1;
52
+ } else {
53
+ high = middle - 1;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function sameRecord(a, x, b, y, width) {
61
+ for (let i = 0; i < width; i++) {
62
+ if (a[x + i] !== b[y + i]) {
63
+ return false;
64
+ }
65
+ }
66
+ return true;
67
+ }
68
+
69
+ // LSD radix sort of serial positions in the source buffer, one stable
70
+ // counting pass per byte position, then a copy of each distinct serial into
71
+ // its own buffer. Sorting the positions avoids an unsorted copy of the
72
+ // serials. The work is proportional to the total serial bytes, so no input
73
+ // can make it degrade. Positions where all serials agree are skipped.
74
+ function sortRecords(bytes, positions, width) {
75
+ const count = positions.length;
76
+ let order = positions;
77
+ let next = new positions.constructor(count);
78
+ const buckets = new Uint32Array(257);
79
+ for (let position = width - 1; (position >= 0) && (count > 1); position--) {
80
+ buckets.fill(0);
81
+ for (let i = 0; i < count; i++) {
82
+ buckets[bytes[order[i] + position] + 1]++;
83
+ }
84
+ if (buckets.includes(count)) {
85
+ continue;
86
+ }
87
+ for (let i = 1; i < 257; i++) {
88
+ buckets[i] += buckets[i - 1];
89
+ }
90
+ for (let i = 0; i < count; i++) {
91
+ next[buckets[bytes[order[i] + position]]++] = order[i];
92
+ }
93
+ [ order, next ] = [ next, order ];
94
+ }
95
+ next = undefined;
96
+ let unique = 0;
97
+ for (let i = 0; i < count; i++) {
98
+ if ((i === 0) || ! sameRecord(bytes, order[i - 1], bytes, order[i], width)) {
99
+ unique++;
100
+ }
101
+ }
102
+ // Own memory for a long-lived cache entry, not a slice of a shared pool
103
+ // or of the CRL.
104
+ const sorted = Buffer.allocUnsafeSlow(width * unique);
105
+ for (let i = 0, filled = 0; i < count; i++) {
106
+ if ((i === 0) || ! sameRecord(bytes, order[i - 1], bytes, order[i], width)) {
107
+ bytes.copy(sorted, filled * width, order[i], order[i] + width);
108
+ filled++;
109
+ }
110
+ }
111
+ return { width, count: unique, data: sorted };
112
+ }
113
+
114
+ // Build an index of serials in bytes. forEachSerial(visit) must call
115
+ // visit(start, end) for each serial; it is called twice, once to count the
116
+ // serials of each length and once to record where they are.
117
+ function buildSerialIndex(bytes, forEachSerial) {
118
+ const counts = new Map();
119
+ forEachSerial(function(start, end) {
120
+ counts.set(end - start, (counts.get(end - start) ?? 0) + 1);
121
+ });
122
+ const Positions = (bytes.length <= 0xffffffff) ? Uint32Array : Float64Array;
123
+ const located = new Map();
124
+ for (const [width, count] of counts) {
125
+ located.set(width, { positions: new Positions(count), filled: 0 });
126
+ }
127
+ forEachSerial(function(start, end) {
128
+ const group = located.get(end - start);
129
+ if ((group === undefined) || (group.filled >= group.positions.length)) {
130
+ throw new Error('Serial enumeration changed between passes');
131
+ }
132
+ group.positions[group.filled++] = start;
133
+ });
134
+ const groups = new Map();
135
+ for (const [width, group] of located) {
136
+ if (group.filled !== group.positions.length) {
137
+ throw new Error('Serial enumeration changed between passes');
138
+ }
139
+ groups.set(width, sortRecords(bytes, group.positions, width));
140
+ located.set(width, undefined);
141
+ }
142
+ return new SerialIndex(groups);
143
+ }
144
+
145
+ module.exports = { SerialIndex, buildSerialIndex };
@@ -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')) {