tr-fetch 0.9.3 → 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 +21 -5
- package/cache.js +3 -2
- package/check.js +16 -13
- package/crl.js +198 -64
- package/download.js +34 -14
- package/package.json +2 -1
- package/pkiutils.js +12 -2
- package/serials.js +145 -0
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
|
-
|
|
84
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
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
|
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
|
|
11
|
-
//
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
96
|
+
if (list && (list.size > options.policy.maxCrlBytes)) {
|
|
97
97
|
debug?.('CRL cache entry not used', { source: debugUrl(url), reason: 'exceeds maxCrlBytes',
|
|
98
|
-
bytes:
|
|
99
|
-
|
|
98
|
+
bytes: list.size, maxCrlBytes: options.policy.maxCrlBytes });
|
|
99
|
+
list = undefined;
|
|
100
100
|
}
|
|
101
|
-
if (!
|
|
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 (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
76
|
-
|
|
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 =
|
|
114
|
+
const extension = derRead(bytes, offset, list.end, s.extension);
|
|
80
115
|
offset = extension.end;
|
|
81
|
-
const id =
|
|
82
|
-
let value =
|
|
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 =
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
if (
|
|
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 (
|
|
101
|
-
const reason =
|
|
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
|
|
117
|
-
|
|
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 =
|
|
161
|
+
const entry = derRead(bytes, offset, entries.end, s.entry);
|
|
123
162
|
offset = entry.end;
|
|
124
|
-
const number =
|
|
125
|
-
const date =
|
|
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) ||
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
145
|
-
revoked = true;
|
|
146
|
-
}
|
|
183
|
+
visit?.(number.start, number.end);
|
|
147
184
|
}
|
|
148
|
-
return { count, problem
|
|
185
|
+
return { count, problem };
|
|
149
186
|
}
|
|
150
187
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
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,
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
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
|
-
|
|
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 };
|