tr-fetch 0.9.0
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/LICENSE +21 -0
- package/README.md +473 -0
- package/bin/tr-curl.js +11 -0
- package/cache.js +70 -0
- package/check.js +205 -0
- package/crl.js +164 -0
- package/debug.js +33 -0
- package/download.js +100 -0
- package/errors.js +42 -0
- package/index.js +57 -0
- package/ocsp.js +265 -0
- package/options.js +146 -0
- package/package.json +66 -0
- package/pkiutils.js +44 -0
- package/tr-curl/main.js +633 -0
- package/tr-curl/options.js +193 -0
- package/tr-curl/progress.js +136 -0
- package/transport.js +150 -0
package/ocsp.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const asn1 = require('asn1js');
|
|
4
|
+
const pki = require('pkijs');
|
|
5
|
+
const { randomBytes, X509Certificate } = require('node:crypto');
|
|
6
|
+
const { cryptoEngine, parseDer, parseCertificate, extensionsById, extensionValue } = require('./pkiutils');
|
|
7
|
+
const { downloadOcsp, networkUrl } = require('./download');
|
|
8
|
+
const { TrFetchOcspError, applyPolicy } = require('./errors');
|
|
9
|
+
const { debugUrl } = require('./debug');
|
|
10
|
+
|
|
11
|
+
const OCSP_ACCESS = '1.3.6.1.5.5.7.48.1';
|
|
12
|
+
const NONCE = '1.3.6.1.5.5.7.48.1.2';
|
|
13
|
+
const NO_CHECK = '1.3.6.1.5.5.7.48.1.5';
|
|
14
|
+
const OCSP_SIGNING = '1.3.6.1.5.5.7.3.9';
|
|
15
|
+
|
|
16
|
+
function ocspUris(certificate) {
|
|
17
|
+
const extension = extensionsById(certificate.extensions).get('1.3.6.1.5.5.7.1.1');
|
|
18
|
+
if (! extension) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const access = extensionValue(extension, pki.InfoAccess);
|
|
22
|
+
return access.accessDescriptions.filter(x => x.accessMethod === OCSP_ACCESS)
|
|
23
|
+
.map(x => (x.accessLocation.type === 6) ? x.accessLocation.value : '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function createOcspRequest(certificate, issuer) {
|
|
27
|
+
// SHA-1 here identifies the issuer; response signatures require SHA-2.
|
|
28
|
+
const certID = await pki.CertID.create(certificate, { issuerCertificate: issuer, hashAlgorithm: 'SHA-1' }, cryptoEngine);
|
|
29
|
+
const nonce = randomBytes(32);
|
|
30
|
+
const request = new pki.OCSPRequest();
|
|
31
|
+
request.tbsRequest.requestList = [ new pki.Request({ reqCert: certID }) ];
|
|
32
|
+
request.tbsRequest.requestExtensions = [ new pki.Extension({
|
|
33
|
+
extnID: NONCE,
|
|
34
|
+
extnValue: new asn1.OctetString({ valueHex: nonce }).toBER()
|
|
35
|
+
}) ];
|
|
36
|
+
return { certID, nonce, bytes: Buffer.from(request.toSchema(true).toBER()) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function checkExtensions(extensions, allowedCritical = []) {
|
|
40
|
+
const byId = extensionsById(extensions);
|
|
41
|
+
for (const extension of byId.values()) {
|
|
42
|
+
if (extension.critical && ! allowedCritical.includes(extension.extnID)) {
|
|
43
|
+
throw new Error(`Unsupported critical OCSP extension ${extension.extnID}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return byId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function authorizeSigner(signer, issuer, producedAt, now) {
|
|
50
|
+
const before = signer.notBefore.value.getTime();
|
|
51
|
+
const after = signer.notAfter.value.getTime();
|
|
52
|
+
if (! Number.isFinite(before) || ! Number.isFinite(after) ||
|
|
53
|
+
(before > now) || (after <= now) || (producedAt < before) || (producedAt > after)) {
|
|
54
|
+
throw new Error('OCSP signer certificate is expired or not yet valid');
|
|
55
|
+
}
|
|
56
|
+
if (Buffer.from(signer.toSchema().toBER()).equals(Buffer.from(issuer.toSchema().toBER()))) {
|
|
57
|
+
return after;
|
|
58
|
+
}
|
|
59
|
+
if (! [ 'SHA-256', 'SHA-384', 'SHA-512' ].includes(cryptoEngine.getHashAlgorithm(signer.signatureAlgorithm))) {
|
|
60
|
+
throw new Error('Unsupported or weak OCSP delegated signer certificate signature');
|
|
61
|
+
}
|
|
62
|
+
const key = new X509Certificate(Buffer.from(signer.toSchema().toBER())).publicKey;
|
|
63
|
+
if ([ 'rsa', 'rsa-pss' ].includes(key.asymmetricKeyType) && (key.asymmetricKeyDetails.modulusLength < 2048)) {
|
|
64
|
+
throw new Error('OCSP delegated signer RSA key must be at least 2048 bits');
|
|
65
|
+
}
|
|
66
|
+
if (! signer.issuer.isEqual(issuer.subject) || ! await signer.verify(issuer, cryptoEngine)) {
|
|
67
|
+
throw new Error('OCSP delegated signer was not issued directly by the checked certificate issuer');
|
|
68
|
+
}
|
|
69
|
+
const extensions = checkExtensions(signer.extensions, [ '2.5.29.15', '2.5.29.19', '2.5.29.37' ]);
|
|
70
|
+
const eku = extensions.get('2.5.29.37');
|
|
71
|
+
if (! eku || ! extensionValue(eku, pki.ExtKeyUsage).keyPurposes.includes(OCSP_SIGNING)) {
|
|
72
|
+
throw new Error('OCSP delegated signer lacks the OCSP signing extended key usage');
|
|
73
|
+
}
|
|
74
|
+
const usage = extensions.get('2.5.29.15');
|
|
75
|
+
if (usage) {
|
|
76
|
+
const bits = extensionValue(usage);
|
|
77
|
+
if (! (bits instanceof asn1.BitString) || ! (bits.valueBlock.valueHexView[0] & 0x80)) {
|
|
78
|
+
throw new Error('OCSP delegated signer key usage does not permit digital signatures');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const basic = extensions.get('2.5.29.19');
|
|
82
|
+
if (basic && extensionValue(basic, pki.BasicConstraints).cA) {
|
|
83
|
+
throw new Error('OCSP delegated signer must be an end-entity responder certificate');
|
|
84
|
+
}
|
|
85
|
+
// Without no-check, the responder certificate needs its own revocation
|
|
86
|
+
// checking. Do not silently trust it or recursively query the same responder.
|
|
87
|
+
const noCheck = extensions.get(NO_CHECK);
|
|
88
|
+
if (! noCheck || ! (extensionValue(noCheck) instanceof asn1.Null)) {
|
|
89
|
+
throw new Error('OCSP delegated signer without a valid id-pkix-ocsp-nocheck extension is unsupported');
|
|
90
|
+
}
|
|
91
|
+
return after;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function validateOcspResponse(bytes, request, certificate, issuer, now = Date.now(), debug) {
|
|
95
|
+
if (bytes.length > 1024 * 1024) {
|
|
96
|
+
throw new Error('OCSP response exceeds the 1 MiB size limit');
|
|
97
|
+
}
|
|
98
|
+
const envelope = parseDer(bytes, pki.OCSPResponse);
|
|
99
|
+
const status = envelope.responseStatus.valueBlock.valueDec;
|
|
100
|
+
if (status !== 0) {
|
|
101
|
+
const names = { 1: 'malformedRequest', 2: 'internalError', 3: 'tryLater', 5: 'sigRequired', 6: 'unauthorized' };
|
|
102
|
+
throw new Error(`OCSP responder returned ${names[status] ?? 'unrecognized status'} (${status})`);
|
|
103
|
+
}
|
|
104
|
+
if (envelope.responseBytes?.responseType !== '1.3.6.1.5.5.7.48.1.1') {
|
|
105
|
+
throw new Error('OCSP response has no supported BasicOCSPResponse');
|
|
106
|
+
}
|
|
107
|
+
const basic = parseDer(envelope.responseBytes.response.valueBlock.valueHexView, pki.BasicOCSPResponse);
|
|
108
|
+
const data = basic.tbsResponseData;
|
|
109
|
+
debug?.('OCSP response parsed', { responses: data.responses.length });
|
|
110
|
+
if ((data.version ?? 0) !== 0) {
|
|
111
|
+
throw new Error('Unsupported OCSP response version');
|
|
112
|
+
}
|
|
113
|
+
const producedAt = data.producedAt.getTime();
|
|
114
|
+
if (! Number.isFinite(producedAt) || (producedAt > now)) {
|
|
115
|
+
throw new Error('OCSP producedAt is invalid or in the future');
|
|
116
|
+
}
|
|
117
|
+
const extensions = checkExtensions(data.responseExtensions, [ NONCE ]);
|
|
118
|
+
const nonceExtension = extensions.get(NONCE);
|
|
119
|
+
if (nonceExtension) {
|
|
120
|
+
const nonce = extensionValue(nonceExtension);
|
|
121
|
+
if (! (nonce instanceof asn1.OctetString) ||
|
|
122
|
+
! Buffer.from(nonce.valueBlock.valueHexView).equals(request.nonce)) {
|
|
123
|
+
throw new Error('OCSP response nonce does not match the request');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const matches = data.responses.filter(x => x.certID.isEqual(request.certID));
|
|
127
|
+
if (matches.length !== 1) {
|
|
128
|
+
throw new Error('OCSP response must contain exactly one matching certificate ID (issuer and serial)');
|
|
129
|
+
}
|
|
130
|
+
const single = matches[0];
|
|
131
|
+
checkExtensions(single.singleExtensions);
|
|
132
|
+
const thisUpdate = single.thisUpdate.getTime();
|
|
133
|
+
// Some responders omit nextUpdate. Such responses have a maximum age of
|
|
134
|
+
// five minutes here, even if the transport just retrieved them successfully.
|
|
135
|
+
const nextUpdate = single.nextUpdate?.getTime() ?? thisUpdate + 300000;
|
|
136
|
+
if (! Number.isFinite(thisUpdate) || ! Number.isFinite(nextUpdate) ||
|
|
137
|
+
(thisUpdate > now) || (thisUpdate > producedAt) || (nextUpdate <= thisUpdate)) {
|
|
138
|
+
throw new Error('OCSP response has invalid or future validity times');
|
|
139
|
+
}
|
|
140
|
+
if (nextUpdate <= now) {
|
|
141
|
+
throw new Error('OCSP response has expired or is stale');
|
|
142
|
+
}
|
|
143
|
+
if (! certificate.issuer.isEqual(issuer.subject) || ! await certificate.verify(issuer, cryptoEngine)) {
|
|
144
|
+
throw new Error('OCSP issuer did not issue the checked certificate');
|
|
145
|
+
}
|
|
146
|
+
const hash = cryptoEngine.getHashAlgorithm(basic.signatureAlgorithm);
|
|
147
|
+
if (! [ 'SHA-256', 'SHA-384', 'SHA-512' ].includes(hash)) {
|
|
148
|
+
throw new Error(`Unsupported or weak OCSP signature algorithm: ${basic.signatureAlgorithm.algorithmId}`);
|
|
149
|
+
}
|
|
150
|
+
const signers = [ issuer, ...(basic.certs ?? []) ];
|
|
151
|
+
if (signers.length > 33) {
|
|
152
|
+
throw new Error('OCSP response includes too many signer certificates');
|
|
153
|
+
}
|
|
154
|
+
const candidates = await pki.BasicOCSPResponse.collectResponderCandidates(signers, data.responderID, cryptoEngine);
|
|
155
|
+
let authenticated = false;
|
|
156
|
+
let signerExpiry = Infinity;
|
|
157
|
+
let signerError;
|
|
158
|
+
for (const index of candidates) {
|
|
159
|
+
try {
|
|
160
|
+
signerExpiry = await authorizeSigner(signers[index], issuer, producedAt, now);
|
|
161
|
+
if (! await basic.verifyResponseSignature(signers[index], cryptoEngine)) {
|
|
162
|
+
throw new Error('OCSP response signature verification failed');
|
|
163
|
+
}
|
|
164
|
+
authenticated = true;
|
|
165
|
+
break;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
signerError = error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (! authenticated) {
|
|
171
|
+
throw signerError ?? new Error('No authorized OCSP signer matches the responder ID');
|
|
172
|
+
}
|
|
173
|
+
const certStatus = single.certStatus;
|
|
174
|
+
const tag = certStatus.idBlock.tagNumber;
|
|
175
|
+
if ((certStatus.idBlock.tagClass !== 3) || ! [ 0, 1, 2 ].includes(tag) ||
|
|
176
|
+
((tag !== 1) && (certStatus.idBlock.isConstructed || certStatus.valueBlock.valueHexView.length))) {
|
|
177
|
+
throw new Error('Malformed OCSP certificate status');
|
|
178
|
+
}
|
|
179
|
+
if (tag === 1) {
|
|
180
|
+
const time = certStatus.valueBlock.value?.[0];
|
|
181
|
+
if (! (time instanceof asn1.GeneralizedTime) || (time.toDate().getTime() > producedAt)) {
|
|
182
|
+
throw new Error('Malformed or future OCSP revocation time');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const expiresAt = Math.min(nextUpdate, signerExpiry);
|
|
186
|
+
if (Date.now() >= expiresAt) {
|
|
187
|
+
throw new Error('OCSP response or signer certificate expired during validation');
|
|
188
|
+
}
|
|
189
|
+
return { status: [ 'good', 'revoked', 'unknown' ][tag], nextUpdate: expiresAt };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function checkOcspCertificate(peer, hostname, options, signal, leaf, debug) {
|
|
193
|
+
const policy = options.ocspPolicy;
|
|
194
|
+
const label = `${leaf ? 'server' : 'intermediate CA'} certificate ${peer.serialNumber} for ${JSON.stringify(hostname)}`;
|
|
195
|
+
function issue(key, message, uri, cause, ocspStatus) {
|
|
196
|
+
debug?.('OCSP condition detected', { policy: key, configuredAction: policy[key], reason: message,
|
|
197
|
+
source: (uri === undefined) ? undefined : debugUrl(uri), result: ocspStatus });
|
|
198
|
+
return new TrFetchOcspError(key, `${message} (${label})`, {
|
|
199
|
+
hostname, serialNumber: peer.serialNumber, fingerprint256: peer.fingerprint256, ocspUri: uri, ocspStatus
|
|
200
|
+
}, cause);
|
|
201
|
+
}
|
|
202
|
+
let certificate, uris, issuer, request;
|
|
203
|
+
try {
|
|
204
|
+
certificate = parseCertificate(peer.raw);
|
|
205
|
+
uris = (leaf && (options.ocspUri !== undefined)) ? [ options.ocspUri ] : ocspUris(certificate);
|
|
206
|
+
for (const uri of uris) {
|
|
207
|
+
debug?.((leaf && (options.ocspUri !== undefined)) ? 'OCSP URI override selected' : 'OCSP URI detected in certificate', {
|
|
208
|
+
source: debugUrl(uri)
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
} catch (cause) {
|
|
212
|
+
applyPolicy(policy, issue('rejectedCertificate', `Cannot read OCSP certificate information: ${cause.message}`, undefined, cause, 'invalid-response'), debug);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (! uris.length) {
|
|
216
|
+
applyPolicy(policy, issue('missingOcspUri', 'Missing OCSP responder URI'), debug);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
if (! peer.issuerCertificate?.raw) {
|
|
221
|
+
throw new Error('The verified TLS chain does not expose the issuer certificate');
|
|
222
|
+
}
|
|
223
|
+
issuer = parseCertificate(peer.issuerCertificate.raw);
|
|
224
|
+
request = await createOcspRequest(certificate, issuer);
|
|
225
|
+
} catch (cause) {
|
|
226
|
+
applyPolicy(policy, issue('rejectedCertificate', `Cannot prepare OCSP verification: ${cause.message}`, undefined, cause, 'invalid-response'), debug);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const failures = [];
|
|
230
|
+
for (const uri of uris.slice(0, 32)) {
|
|
231
|
+
signal?.throwIfAborted();
|
|
232
|
+
const responderDebug = debug ? (event, details) => debug(event, { source: debugUrl(uri), ...details }) : undefined;
|
|
233
|
+
let bytes;
|
|
234
|
+
try {
|
|
235
|
+
bytes = await downloadOcsp(networkUrl(uri, 'OCSP responder'), request.bytes, signal, responderDebug);
|
|
236
|
+
} catch (cause) {
|
|
237
|
+
signal?.throwIfAborted();
|
|
238
|
+
failures.push(issue('unreachableOcspUri', `Unreachable OCSP responder: ${cause.message}`, uri, cause));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
let result;
|
|
242
|
+
try {
|
|
243
|
+
result = await validateOcspResponse(bytes, request, certificate, issuer, Date.now(), responderDebug);
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
failures.push(issue('rejectedCertificate', `Invalid OCSP response: ${cause.message}`, uri, cause, 'invalid-response'));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
responderDebug?.('OCSP check completed', { result: result.status, authenticated: true,
|
|
249
|
+
policy: 'rejectedCertificate', configuredAction: policy.rejectedCertificate,
|
|
250
|
+
action: (result.status === 'good') ? 'continue' : policy.rejectedCertificate, nextUpdate: result.nextUpdate });
|
|
251
|
+
if (result.status !== 'good') {
|
|
252
|
+
applyPolicy(policy, issue('rejectedCertificate',
|
|
253
|
+
`OCSP rejected certificate: responder reports ${result.status}`, uri, undefined, result.status), responderDebug);
|
|
254
|
+
}
|
|
255
|
+
return result.nextUpdate;
|
|
256
|
+
}
|
|
257
|
+
if (uris.length > 32) {
|
|
258
|
+
failures.push(issue('unreachableOcspUri', 'OCSP responder URI lookup limit (32) exceeded'));
|
|
259
|
+
}
|
|
260
|
+
for (const failure of failures) {
|
|
261
|
+
applyPolicy(policy, failure, debug);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = { ocspUris, createOcspRequest, validateOcspResponse, checkOcspCertificate };
|
package/options.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_POLICY = {
|
|
4
|
+
disabled: false,
|
|
5
|
+
missingCrlDistributionPoint: 'ignore',
|
|
6
|
+
unreachableCrlDistributionPoint: 'reject',
|
|
7
|
+
invalidCrl: 'reject',
|
|
8
|
+
revokedCertificate: 'reject'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DEFAULT_OCSP_POLICY = {
|
|
12
|
+
disabled: false,
|
|
13
|
+
missingOcspUri: 'ignore',
|
|
14
|
+
unreachableOcspUri: 'reject',
|
|
15
|
+
rejectedCertificate: 'reject'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const CUSTOM_OPTIONS = [
|
|
19
|
+
'trFetchCrlPolicy', 'trFetchCrlCacheSize', 'trFetchCrlCacheTTL',
|
|
20
|
+
'trFetchCrlDistributionPointOverride', 'trFetchCrlOverride', 'trFetchCrlCheckDepth',
|
|
21
|
+
'trFetchOcspPolicy', 'trFetchOcspUriOverride', 'trFetchOcspCheckDepth', 'trFetchDebug'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const FETCH_OPTIONS = [
|
|
25
|
+
'method', 'headers', 'body', 'referrer', 'referrerPolicy', 'mode',
|
|
26
|
+
'credentials', 'cache', 'redirect', 'integrity', 'keepalive', 'signal',
|
|
27
|
+
'window', 'duplex', 'priority', 'dispatcher'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// Web IDL dictionaries also read inherited and non-enumerable properties.
|
|
31
|
+
// In particular, never discard an inherited dispatcher or rejection policy.
|
|
32
|
+
function copyOptions(value, keys) {
|
|
33
|
+
const copy = { ...value };
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
if ((value != null) && ! Object.hasOwn(copy, key) && (key in value)) {
|
|
36
|
+
copy[key] = value[key];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return copy;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parsePolicy(value, defaults, name) {
|
|
43
|
+
if ((value !== undefined) &&
|
|
44
|
+
((value === null) || (typeof(value) !== 'object') || Array.isArray(value))) {
|
|
45
|
+
throw new TypeError(`${name} must be an object`);
|
|
46
|
+
}
|
|
47
|
+
const policy = { ...defaults };
|
|
48
|
+
for (const [key, setting] of Object.entries(copyOptions(value, Object.keys(defaults)))) {
|
|
49
|
+
if (! Object.hasOwn(defaults, key)) {
|
|
50
|
+
throw new TypeError(`Unknown ${name} property: ${key}`);
|
|
51
|
+
}
|
|
52
|
+
if (key === 'disabled') {
|
|
53
|
+
if ((setting != null) && (typeof(setting) !== 'boolean')) {
|
|
54
|
+
throw new TypeError(`${name}.disabled must be a boolean, null or undefined`);
|
|
55
|
+
}
|
|
56
|
+
policy.disabled = setting ?? false;
|
|
57
|
+
} else {
|
|
58
|
+
if (! [ 'ignore', 'warn', 'reject' ].includes(setting)) {
|
|
59
|
+
throw new TypeError(`${name}.${key} must be ignore, warn or reject`);
|
|
60
|
+
}
|
|
61
|
+
policy[key] = setting;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return policy;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseDepth(value, name) {
|
|
68
|
+
if ((value == null) || (value === 'leaf')) {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
if (value === 'full-chain') {
|
|
72
|
+
return Infinity;
|
|
73
|
+
}
|
|
74
|
+
if (! Number.isSafeInteger(value) || (value < 0)) {
|
|
75
|
+
throw new TypeError(`${name} must be a nonnegative safe integer, leaf or full-chain`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function splitOptions(options) {
|
|
81
|
+
if ((options !== undefined) && (options !== null) &&
|
|
82
|
+
((typeof(options) !== 'object') || Array.isArray(options))) {
|
|
83
|
+
throw new TypeError('fetch options must be an object');
|
|
84
|
+
}
|
|
85
|
+
const fetchOptions = copyOptions(options, [ ...FETCH_OPTIONS, ...CUSTOM_OPTIONS ]);
|
|
86
|
+
for (const key in options) {
|
|
87
|
+
if (key.startsWith('trFetch') && ! CUSTOM_OPTIONS.includes(key)) {
|
|
88
|
+
throw new TypeError(`Unknown trFetch option: ${key}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const debugEnabled = (fetchOptions.trFetchDebug === undefined) ? false : fetchOptions.trFetchDebug;
|
|
92
|
+
if (typeof(debugEnabled) !== 'boolean') {
|
|
93
|
+
throw new TypeError('trFetchDebug must be a boolean');
|
|
94
|
+
}
|
|
95
|
+
const policy = parsePolicy(fetchOptions.trFetchCrlPolicy, DEFAULT_POLICY, 'trFetchCrlPolicy');
|
|
96
|
+
const ocspPolicy = parsePolicy(fetchOptions.trFetchOcspPolicy, DEFAULT_OCSP_POLICY, 'trFetchOcspPolicy');
|
|
97
|
+
const cacheSize = fetchOptions.trFetchCrlCacheSize ?? 32;
|
|
98
|
+
const cacheTTL = fetchOptions.trFetchCrlCacheTTL ?? 1800;
|
|
99
|
+
const checkDepth = parseDepth(fetchOptions.trFetchCrlCheckDepth, 'trFetchCrlCheckDepth');
|
|
100
|
+
const ocspCheckDepth = parseDepth(fetchOptions.trFetchOcspCheckDepth, 'trFetchOcspCheckDepth');
|
|
101
|
+
let ocspUri = fetchOptions.trFetchOcspUriOverride;
|
|
102
|
+
if (ocspUri instanceof URL) {
|
|
103
|
+
ocspUri = ocspUri.href;
|
|
104
|
+
}
|
|
105
|
+
if ((ocspUri !== undefined) && (typeof(ocspUri) !== 'string')) {
|
|
106
|
+
throw new TypeError('trFetchOcspUriOverride must be a URL string or URL');
|
|
107
|
+
}
|
|
108
|
+
if (! Number.isSafeInteger(cacheSize)) {
|
|
109
|
+
throw new TypeError('trFetchCrlCacheSize must be a safe integer');
|
|
110
|
+
}
|
|
111
|
+
if (! Number.isSafeInteger(cacheTTL) || (cacheTTL < -1)) {
|
|
112
|
+
throw new TypeError('trFetchCrlCacheTTL must be -1 or a nonnegative safe integer (seconds)');
|
|
113
|
+
}
|
|
114
|
+
let distributionPoint = fetchOptions.trFetchCrlDistributionPointOverride;
|
|
115
|
+
let crl = fetchOptions.trFetchCrlOverride;
|
|
116
|
+
if ((distributionPoint !== undefined) && (crl !== undefined)) {
|
|
117
|
+
throw new TypeError('trFetchCrlDistributionPointOverride and trFetchCrlOverride are mutually exclusive');
|
|
118
|
+
}
|
|
119
|
+
if (distributionPoint instanceof URL) {
|
|
120
|
+
distributionPoint = distributionPoint.href;
|
|
121
|
+
}
|
|
122
|
+
if ((distributionPoint !== undefined) && (typeof(distributionPoint) !== 'string')) {
|
|
123
|
+
throw new TypeError('trFetchCrlDistributionPointOverride must be a URL string or URL');
|
|
124
|
+
}
|
|
125
|
+
if (crl !== undefined) {
|
|
126
|
+
if (typeof(crl) === 'string') {
|
|
127
|
+
crl = Buffer.from(crl);
|
|
128
|
+
} else if (crl instanceof Uint8Array) {
|
|
129
|
+
crl = Buffer.from(crl);
|
|
130
|
+
} else if (crl instanceof ArrayBuffer) {
|
|
131
|
+
crl = Buffer.from(new Uint8Array(crl));
|
|
132
|
+
} else {
|
|
133
|
+
throw new TypeError('trFetchCrlOverride must be PEM text or DER/PEM bytes');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const key of CUSTOM_OPTIONS) {
|
|
137
|
+
delete fetchOptions[key];
|
|
138
|
+
}
|
|
139
|
+
if (fetchOptions.dispatcher !== undefined) {
|
|
140
|
+
throw new TypeError('trFetch cannot safely combine CRL checking with a custom dispatcher');
|
|
141
|
+
}
|
|
142
|
+
return { fetchOptions, policy, cacheSize, cacheTTL, checkDepth, distributionPoint, crl,
|
|
143
|
+
ocspPolicy, ocspUri, ocspCheckDepth, debugEnabled };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { splitOptions };
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tr-fetch",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Node.js fetch with CRL and OCSP certificate revocation checking, plus tr-curl, a curl-like test tool",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"tr-curl": "bin/tr-curl.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"cache.js",
|
|
16
|
+
"check.js",
|
|
17
|
+
"crl.js",
|
|
18
|
+
"debug.js",
|
|
19
|
+
"download.js",
|
|
20
|
+
"errors.js",
|
|
21
|
+
"ocsp.js",
|
|
22
|
+
"options.js",
|
|
23
|
+
"pkiutils.js",
|
|
24
|
+
"transport.js",
|
|
25
|
+
"bin/",
|
|
26
|
+
"tr-curl/"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/*.test.js"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=26.0.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"asn1js": "^3.0.10",
|
|
36
|
+
"optist": "^2.0.1",
|
|
37
|
+
"pkijs": "^3.4.1",
|
|
38
|
+
"undici": "^8.11.2"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/rinne/node-tr-fetch.git"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"fetch",
|
|
46
|
+
"https",
|
|
47
|
+
"tls",
|
|
48
|
+
"x509",
|
|
49
|
+
"certificate",
|
|
50
|
+
"revocation",
|
|
51
|
+
"crl",
|
|
52
|
+
"ocsp",
|
|
53
|
+
"curl",
|
|
54
|
+
"security"
|
|
55
|
+
],
|
|
56
|
+
"author": {
|
|
57
|
+
"name": "Timo J. Rinne",
|
|
58
|
+
"email": "tri@iki.fi",
|
|
59
|
+
"url": "https://github.com/rinne/"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/rinne/node-tr-fetch/issues"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://github.com/rinne/node-tr-fetch#readme"
|
|
66
|
+
}
|
package/pkiutils.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const asn1 = require('asn1js');
|
|
4
|
+
const pki = require('pkijs');
|
|
5
|
+
const { webcrypto } = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const cryptoEngine = new pki.CryptoEngine({ name: 'tr-fetch', crypto: webcrypto });
|
|
8
|
+
|
|
9
|
+
function parseDer(bytes, Type) {
|
|
10
|
+
const decoded = asn1.fromBER(bytes);
|
|
11
|
+
if ((decoded.offset !== bytes.length) || decoded.result.error) {
|
|
12
|
+
throw new Error('Malformed ASN.1 data or trailing bytes');
|
|
13
|
+
}
|
|
14
|
+
return new Type({ schema: decoded.result });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extensionsById(extensions = []) {
|
|
18
|
+
const result = new Map();
|
|
19
|
+
for (const extension of extensions) {
|
|
20
|
+
if (result.has(extension.extnID)) {
|
|
21
|
+
throw new Error(`Duplicate extension ${extension.extnID}`);
|
|
22
|
+
}
|
|
23
|
+
result.set(extension.extnID, extension);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extensionValue(extension, Type) {
|
|
29
|
+
const data = extension.extnValue.valueBlock.valueHexView;
|
|
30
|
+
const decoded = asn1.fromBER(data);
|
|
31
|
+
if ((decoded.offset !== data.length) || decoded.result.error) {
|
|
32
|
+
throw new Error(`Malformed extension ${extension.extnID}`);
|
|
33
|
+
}
|
|
34
|
+
if (Type) {
|
|
35
|
+
return new Type({ schema: decoded.result });
|
|
36
|
+
}
|
|
37
|
+
return decoded.result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseCertificate(bytes) {
|
|
41
|
+
return parseDer(bytes, pki.Certificate);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { cryptoEngine, parseDer, extensionsById, extensionValue, parseCertificate };
|