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/check.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
const { parseCertificate, distributionPoints, parseCrl, validateCrl } = require('./crl');
|
|
5
|
+
const { networkUrl, downloadCrl } = require('./download');
|
|
6
|
+
const { TrFetchCrlError, TrFetchOcspError, applyPolicy } = require('./errors');
|
|
7
|
+
const { checkOcspCertificate } = require('./ocsp');
|
|
8
|
+
const CrlCache = require('./cache');
|
|
9
|
+
const { debugUrl } = require('./debug');
|
|
10
|
+
|
|
11
|
+
const cache = new CrlCache();
|
|
12
|
+
|
|
13
|
+
function sourceLabel(value) {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(value);
|
|
16
|
+
return JSON.stringify(url.origin + url.pathname);
|
|
17
|
+
} catch (_) {
|
|
18
|
+
return '(invalid URL)';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function checkCertificate(peer, hostname, options, signal, override, debug) {
|
|
23
|
+
const details = { hostname, serialNumber: peer.serialNumber, fingerprint256: peer.fingerprint256 };
|
|
24
|
+
const certificateType = override ? 'server' : 'intermediate CA';
|
|
25
|
+
const label = `${certificateType} certificate ${peer.serialNumber} for ${JSON.stringify(hostname)}`;
|
|
26
|
+
function issue(key, message, cause, distributionPoint) {
|
|
27
|
+
debug?.('CRL condition detected', { policy: key, configuredAction: options.policy[key],
|
|
28
|
+
reason: message, source: (distributionPoint === undefined) ? undefined : debugUrl(distributionPoint) });
|
|
29
|
+
return new TrFetchCrlError(key, `${message} (${label})`, { ...details, distributionPoint }, cause);
|
|
30
|
+
}
|
|
31
|
+
let certificate, points;
|
|
32
|
+
const forcedCrl = override ? options.crl : undefined;
|
|
33
|
+
const forcedPoint = override ? options.distributionPoint : undefined;
|
|
34
|
+
try {
|
|
35
|
+
certificate = parseCertificate(peer.raw);
|
|
36
|
+
if (forcedCrl !== undefined) {
|
|
37
|
+
debug?.('CRL data override selected', { bytes: forcedCrl.length });
|
|
38
|
+
points = [ { urls: [] } ];
|
|
39
|
+
} else if (forcedPoint !== undefined) {
|
|
40
|
+
debug?.('CRL distribution point override selected', { source: debugUrl(forcedPoint) });
|
|
41
|
+
points = [ { urls: [ forcedPoint ] } ];
|
|
42
|
+
} else {
|
|
43
|
+
points = distributionPoints(certificate);
|
|
44
|
+
for (const point of points ?? []) {
|
|
45
|
+
debug?.('CRL distribution point detected in certificate', {
|
|
46
|
+
sources: point.urls.map(debugUrl), unsupportedScope: point.unsupported
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
applyPolicy(options.policy, issue('invalidCrl', `Cannot read certificate CRL information: ${cause.message}`, cause), debug);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (points === undefined) {
|
|
55
|
+
applyPolicy(options.policy, issue('missingCrlDistributionPoint', 'Missing CRL distribution point'), debug);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let issuer;
|
|
59
|
+
try {
|
|
60
|
+
if (! peer.issuerCertificate?.raw) {
|
|
61
|
+
throw new Error('The verified TLS chain does not expose the issuer certificate');
|
|
62
|
+
}
|
|
63
|
+
issuer = parseCertificate(peer.issuerCertificate.raw);
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
applyPolicy(options.policy, issue('invalidCrl', `Cannot authenticate CRL: ${cause.message}`, cause), debug);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const issuerId = createHash('sha256').update(peer.issuerCertificate.raw).digest('hex');
|
|
69
|
+
const failures = [];
|
|
70
|
+
let attempts = 0;
|
|
71
|
+
for (const point of points) {
|
|
72
|
+
if (point.unsupported) {
|
|
73
|
+
failures.push(issue('invalidCrl', 'Reason-limited or indirect CRL distribution points are unsupported'));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const sources = (forcedCrl !== undefined) ? [ undefined ] : point.urls;
|
|
77
|
+
if (! sources.length) {
|
|
78
|
+
failures.push(issue('unreachableCrlDistributionPoint', 'CRL distribution point has no fetchable HTTP(S) URI'));
|
|
79
|
+
}
|
|
80
|
+
for (const source of sources) {
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
if (++attempts > 32) {
|
|
83
|
+
failures.push(issue('unreachableCrlDistributionPoint', 'CRL distribution point lookup limit (32) exceeded'));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
let crl, key, bytes;
|
|
87
|
+
const fetchedAt = Date.now();
|
|
88
|
+
if (forcedCrl !== undefined) {
|
|
89
|
+
bytes = forcedCrl;
|
|
90
|
+
} else {
|
|
91
|
+
try {
|
|
92
|
+
const url = networkUrl(source);
|
|
93
|
+
key = issuerId + ':' + url.href;
|
|
94
|
+
crl = cache.get(key, options.cacheSize, options.cacheTTL, Date.now(), debug);
|
|
95
|
+
if (! crl) {
|
|
96
|
+
bytes = await downloadCrl(url, signal, debug);
|
|
97
|
+
}
|
|
98
|
+
} catch (cause) {
|
|
99
|
+
signal?.throwIfAborted();
|
|
100
|
+
failures.push(issue('unreachableCrlDistributionPoint',
|
|
101
|
+
`Unreachable CRL distribution point ${sourceLabel(source)}: ${cause.message}`, cause, source));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let result;
|
|
106
|
+
try {
|
|
107
|
+
if (! crl) {
|
|
108
|
+
crl = parseCrl(bytes);
|
|
109
|
+
debug?.('CRL parsed', { source: (source === undefined) ? 'trFetchCrlOverride' : debugUrl(source),
|
|
110
|
+
revokedEntries: crl.revokedCertificates?.length ?? 0 });
|
|
111
|
+
}
|
|
112
|
+
result = await validateCrl(crl, certificate, issuer, point.urls);
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
failures.push(issue('invalidCrl',
|
|
115
|
+
`Invalid CRL from ${(source === undefined) ? 'trFetchCrlOverride' : sourceLabel(source)}: ${cause.message}`, cause, source));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if ((key !== undefined) && (bytes !== undefined)) {
|
|
119
|
+
cache.set(key, crl, result.nextUpdate, fetchedAt, options.cacheSize, options.cacheTTL, Date.now(), debug);
|
|
120
|
+
}
|
|
121
|
+
debug?.('CRL serial lookup completed', { source: (source === undefined) ? 'trFetchCrlOverride' : debugUrl(source),
|
|
122
|
+
result: result.revoked ? 'revoked' : 'not listed', authenticated: true,
|
|
123
|
+
policy: 'revokedCertificate', configuredAction: options.policy.revokedCertificate,
|
|
124
|
+
action: result.revoked ? options.policy.revokedCertificate : 'continue', nextUpdate: result.nextUpdate });
|
|
125
|
+
if (result.revoked) {
|
|
126
|
+
applyPolicy(options.policy, issue('revokedCertificate', `Revoked ${certificateType} certificate: serial number is listed in the authenticated CRL`, undefined, source), debug);
|
|
127
|
+
}
|
|
128
|
+
return result.nextUpdate;
|
|
129
|
+
}
|
|
130
|
+
if (attempts > 32) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const failure of failures) {
|
|
135
|
+
applyPolicy(options.policy, failure, debug);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function checkChain(peer, hostname, options, signal) {
|
|
140
|
+
const debug = options.debug;
|
|
141
|
+
debug?.('TLS verified; starting revocation checks', { hostname });
|
|
142
|
+
const crlDepth = options.policy.disabled ? -1 : options.checkDepth;
|
|
143
|
+
const ocspDepth = options.ocspPolicy.disabled ? -1 : options.ocspCheckDepth;
|
|
144
|
+
const maxDepth = Math.max(crlDepth, ocspDepth);
|
|
145
|
+
if (maxDepth < 0) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const chainError = (message) => (crlDepth >= 0) ?
|
|
149
|
+
new TrFetchCrlError('invalidCrl', message, { hostname }) :
|
|
150
|
+
new TrFetchOcspError('rejectedCertificate', message, { hostname, ocspStatus: 'invalid-response' });
|
|
151
|
+
if (! peer?.raw) {
|
|
152
|
+
throw chainError('TLS connection did not expose a peer certificate');
|
|
153
|
+
}
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
let nextUpdate = Infinity;
|
|
156
|
+
let ocspNextUpdate = Infinity;
|
|
157
|
+
for (let depth = 0; peer; depth++) {
|
|
158
|
+
if (depth > maxDepth) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
signal?.throwIfAborted();
|
|
162
|
+
const fingerprint = peer.raw.toString('base64');
|
|
163
|
+
if ((depth >= 32) || seen.has(fingerprint)) {
|
|
164
|
+
throw chainError('Invalid or excessively long TLS certificate chain');
|
|
165
|
+
}
|
|
166
|
+
seen.add(fingerprint);
|
|
167
|
+
const issuer = peer.issuerCertificate;
|
|
168
|
+
const terminal = ! issuer?.raw || peer.raw.equals(issuer.raw);
|
|
169
|
+
if ((depth > 0) && terminal) {
|
|
170
|
+
debug?.('Trust anchor excluded from revocation checks', { hostname, depth, serial: peer.serialNumber });
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
const certificateDebug = debug ? (event, details) => debug(event, {
|
|
174
|
+
hostname, certificate: (depth === 0) ? 'leaf' : 'intermediate CA', depth, serial: peer.serialNumber, ...details
|
|
175
|
+
}) : undefined;
|
|
176
|
+
if (depth <= crlDepth) {
|
|
177
|
+
certificateDebug?.('CRL check started');
|
|
178
|
+
const expires = await checkCertificate(peer, hostname, options, signal, depth === 0, certificateDebug);
|
|
179
|
+
nextUpdate = Math.min(nextUpdate, expires ?? Infinity);
|
|
180
|
+
} else {
|
|
181
|
+
certificateDebug?.('CRL check skipped', { reason: options.policy.disabled ? 'disabled' : 'beyond configured depth' });
|
|
182
|
+
}
|
|
183
|
+
if (depth <= ocspDepth) {
|
|
184
|
+
certificateDebug?.('OCSP check started');
|
|
185
|
+
const expires = await checkOcspCertificate(peer, hostname, options, signal, depth === 0, certificateDebug);
|
|
186
|
+
ocspNextUpdate = Math.min(ocspNextUpdate, expires ?? Infinity);
|
|
187
|
+
} else {
|
|
188
|
+
certificateDebug?.('OCSP check skipped', { reason: options.ocspPolicy.disabled ? 'disabled' : 'beyond configured depth' });
|
|
189
|
+
}
|
|
190
|
+
if (terminal) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
peer = issuer;
|
|
194
|
+
}
|
|
195
|
+
if (Date.now() >= nextUpdate) {
|
|
196
|
+
applyPolicy(options.policy, new TrFetchCrlError('invalidCrl', 'CRL expired while checking the certificate chain', { hostname }), debug);
|
|
197
|
+
}
|
|
198
|
+
if (Date.now() >= ocspNextUpdate) {
|
|
199
|
+
applyPolicy(options.ocspPolicy, new TrFetchOcspError('rejectedCertificate',
|
|
200
|
+
'OCSP response expired while checking the certificate chain', { hostname, ocspStatus: 'invalid-response' }), debug);
|
|
201
|
+
}
|
|
202
|
+
debug?.('Revocation checks completed; connection allowed', { hostname });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { checkChain };
|
package/crl.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const asn1 = require('asn1js');
|
|
4
|
+
const pki = require('pkijs');
|
|
5
|
+
const { cryptoEngine, parseDer, extensionsById, extensionValue, parseCertificate } = require('./pkiutils');
|
|
6
|
+
|
|
7
|
+
const MAX_CRL_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function distributionPoints(certificate) {
|
|
10
|
+
const extension = extensionsById(certificate.extensions).get('2.5.29.31');
|
|
11
|
+
if (! extension) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const points = extensionValue(extension, pki.CRLDistributionPoints).distributionPoints;
|
|
15
|
+
if (! points.length) {
|
|
16
|
+
throw new Error('Empty CRL distribution points extension');
|
|
17
|
+
}
|
|
18
|
+
return points.map(function(point) {
|
|
19
|
+
return {
|
|
20
|
+
urls: Array.isArray(point.distributionPoint) ?
|
|
21
|
+
point.distributionPoint.filter(x => x.type === 6).map(x => x.value) : [],
|
|
22
|
+
unsupported: (point.reasons !== undefined) || (point.cRLIssuer !== undefined)
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCrl(bytes) {
|
|
28
|
+
if (bytes.length > MAX_CRL_BYTES) {
|
|
29
|
+
throw new Error('CRL exceeds the 16 MiB size limit');
|
|
30
|
+
}
|
|
31
|
+
if (bytes.toString('ascii', 0, 32).trimStart().startsWith('-----BEGIN')) {
|
|
32
|
+
const match = /^\s*-----BEGIN X509 CRL-----\s*([A-Za-z0-9+/=\r\n\t ]+)\s*-----END X509 CRL-----\s*$/.exec(bytes.toString('ascii'));
|
|
33
|
+
if (! match) {
|
|
34
|
+
throw new Error('Malformed PEM CRL; expected one X509 CRL block');
|
|
35
|
+
}
|
|
36
|
+
const encoded = match[1].replace(/\s/g, '');
|
|
37
|
+
bytes = Buffer.from(encoded, 'base64');
|
|
38
|
+
if (bytes.toString('base64') !== encoded) {
|
|
39
|
+
throw new Error('Malformed base64 in PEM CRL');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return parseDer(bytes, pki.CertificateRevocationList);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function checkValidity(crl, now = Date.now()) {
|
|
46
|
+
const start = crl.thisUpdate.value.getTime();
|
|
47
|
+
const end = crl.nextUpdate?.value.getTime();
|
|
48
|
+
if (! Number.isFinite(start) || ! Number.isFinite(end) || (end <= start)) {
|
|
49
|
+
throw new Error('CRL must have a valid thisUpdate and a later nextUpdate');
|
|
50
|
+
}
|
|
51
|
+
if (start > now) {
|
|
52
|
+
throw new Error('CRL is not yet valid (thisUpdate is in the future)');
|
|
53
|
+
}
|
|
54
|
+
if (end <= now) {
|
|
55
|
+
throw new Error('CRL has expired (nextUpdate has passed)');
|
|
56
|
+
}
|
|
57
|
+
return end;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkScope(crl, certificate, urls) {
|
|
61
|
+
if (! [ 0, 1 ].includes(crl.version)) {
|
|
62
|
+
throw new Error('Unsupported CRL version');
|
|
63
|
+
}
|
|
64
|
+
const extensions = extensionsById(crl.crlExtensions?.extensions);
|
|
65
|
+
for (const extension of extensions.values()) {
|
|
66
|
+
if (extension.extnID === '2.5.29.27') {
|
|
67
|
+
throw new Error('Delta CRLs are unsupported; a complete CRL is required');
|
|
68
|
+
}
|
|
69
|
+
if (extension.critical && ! [ '2.5.29.28', '2.5.29.35', '2.5.29.20' ].includes(extension.extnID)) {
|
|
70
|
+
throw new Error(`Unsupported critical CRL extension ${extension.extnID}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const idpExtension = extensions.get('2.5.29.28');
|
|
74
|
+
if (idpExtension) {
|
|
75
|
+
const idp = extensionValue(idpExtension, pki.IssuingDistributionPoint);
|
|
76
|
+
if (idp.indirectCRL || (idp.onlySomeReasons !== undefined) || idp.onlyContainsAttributeCerts) {
|
|
77
|
+
throw new Error('Indirect, reason-limited and attribute-certificate CRLs are unsupported');
|
|
78
|
+
}
|
|
79
|
+
const basic = extensionsById(certificate.extensions).get('2.5.29.19');
|
|
80
|
+
const isCA = basic ? extensionValue(basic, pki.BasicConstraints).cA : false;
|
|
81
|
+
if ((idp.onlyContainsUserCerts && isCA) || (idp.onlyContainsCACerts && ! isCA)) {
|
|
82
|
+
throw new Error('CRL scope does not cover this certificate type');
|
|
83
|
+
}
|
|
84
|
+
if (idp.distributionPoint !== undefined) {
|
|
85
|
+
if (! Array.isArray(idp.distributionPoint) ||
|
|
86
|
+
! idp.distributionPoint.some(x => (x.type === 6) && urls.includes(x.value))) {
|
|
87
|
+
throw new Error('CRL issuing distribution point does not match the effective distribution point');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return extensions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function validateCrl(crl, certificate, issuer, urls, now = Date.now()) {
|
|
95
|
+
const nextUpdate = checkValidity(crl, now);
|
|
96
|
+
const extensions = checkScope(crl, certificate, urls);
|
|
97
|
+
if (! crl.issuer.isEqual(certificate.issuer) || ! crl.issuer.isEqual(issuer.subject)) {
|
|
98
|
+
throw new Error('CRL issuer does not match the certificate issuer');
|
|
99
|
+
}
|
|
100
|
+
if (! await certificate.verify(issuer, cryptoEngine)) {
|
|
101
|
+
throw new Error('CRL signing certificate did not issue the checked certificate');
|
|
102
|
+
}
|
|
103
|
+
if (! Buffer.from(crl.signature.toSchema().toBER()).equals(Buffer.from(crl.signatureAlgorithm.toSchema().toBER()))) {
|
|
104
|
+
throw new Error('CRL signature algorithm identifiers disagree');
|
|
105
|
+
}
|
|
106
|
+
const hash = await cryptoEngine.getHashAlgorithm(crl.signatureAlgorithm);
|
|
107
|
+
if (! [ 'SHA-256', 'SHA-384', 'SHA-512' ].includes(hash)) {
|
|
108
|
+
throw new Error(`Unsupported or weak CRL signature algorithm: ${crl.signatureAlgorithm.algorithmId}`);
|
|
109
|
+
}
|
|
110
|
+
const issuerExtensions = extensionsById(issuer.extensions);
|
|
111
|
+
const keyUsage = issuerExtensions.get('2.5.29.15');
|
|
112
|
+
if (keyUsage) {
|
|
113
|
+
const bits = extensionValue(keyUsage);
|
|
114
|
+
if (! (bits instanceof asn1.BitString) || ! (bits.valueBlock.valueHexView[0] & 0x02)) {
|
|
115
|
+
throw new Error('Issuer certificate key usage does not permit CRL signing');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const authority = extensions.get('2.5.29.35');
|
|
119
|
+
if (authority) {
|
|
120
|
+
const aki = extensionValue(authority, pki.AuthorityKeyIdentifier);
|
|
121
|
+
const ski = issuerExtensions.get('2.5.29.14');
|
|
122
|
+
if (aki.keyIdentifier && ski &&
|
|
123
|
+
! Buffer.from(aki.keyIdentifier.valueBlock.valueHexView).equals(Buffer.from(extensionValue(ski).valueBlock.valueHexView))) {
|
|
124
|
+
throw new Error('CRL authority key identifier does not match its issuer');
|
|
125
|
+
}
|
|
126
|
+
if (aki.authorityCertSerialNumber && ! aki.authorityCertSerialNumber.isEqual(issuer.serialNumber)) {
|
|
127
|
+
throw new Error('CRL authority certificate serial number does not match its issuer');
|
|
128
|
+
}
|
|
129
|
+
if (aki.authorityCertIssuer && ! aki.authorityCertIssuer.some(x => (x.type === 4) && x.value.isEqual(issuer.issuer))) {
|
|
130
|
+
throw new Error('CRL authority certificate issuer does not match its issuer certificate');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const crlNumber = extensions.get('2.5.29.20');
|
|
134
|
+
if (crlNumber && ! (extensionValue(crlNumber) instanceof asn1.Integer)) {
|
|
135
|
+
throw new Error('Malformed CRL number');
|
|
136
|
+
}
|
|
137
|
+
if (! await crl.verify({ issuerCertificate: issuer }, cryptoEngine)) {
|
|
138
|
+
throw new Error('CRL signature verification failed');
|
|
139
|
+
}
|
|
140
|
+
let revoked = false;
|
|
141
|
+
for (const entry of crl.revokedCertificates ?? []) {
|
|
142
|
+
for (const extension of extensionsById(entry.crlEntryExtensions?.extensions).values()) {
|
|
143
|
+
if (extension.extnID === '2.5.29.29') {
|
|
144
|
+
throw new Error('Indirect CRL certificateIssuer entries are unsupported');
|
|
145
|
+
}
|
|
146
|
+
if (extension.critical) {
|
|
147
|
+
throw new Error(`Unsupported critical CRL entry extension ${extension.extnID}`);
|
|
148
|
+
}
|
|
149
|
+
if (extension.extnID === '2.5.29.21') {
|
|
150
|
+
const reason = extensionValue(extension);
|
|
151
|
+
if (! (reason instanceof asn1.Enumerated) || (reason.valueBlock.valueDec === 8)) {
|
|
152
|
+
throw new Error('Invalid revocation reason in a complete CRL');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (entry.userCertificate.isEqual(certificate.serialNumber)) {
|
|
157
|
+
revoked = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
checkValidity(crl);
|
|
161
|
+
return { revoked, nextUpdate };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { MAX_CRL_BYTES, parseCertificate, distributionPoints, parseCrl, validateCrl };
|
package/debug.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let nextId = 0;
|
|
4
|
+
|
|
5
|
+
function debugUrl(value) {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (! [ 'http:', 'https:' ].includes(url.protocol)) {
|
|
9
|
+
return url.protocol + '[unsupported]';
|
|
10
|
+
}
|
|
11
|
+
return url.origin + url.pathname;
|
|
12
|
+
} catch (_) {
|
|
13
|
+
return '[invalid URL]';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createDebug(enabled) {
|
|
18
|
+
if (! enabled) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const id = ++nextId;
|
|
22
|
+
return function(event, details = {}) {
|
|
23
|
+
// One escaped line per event, with a per-fetch ID for concurrent calls.
|
|
24
|
+
// Never include request headers, bodies or raw certificate/CRL data.
|
|
25
|
+
try {
|
|
26
|
+
process.stderr.write(`[trFetch debug #${id}] ${event} ${JSON.stringify(details)}\n`);
|
|
27
|
+
} catch (_) {
|
|
28
|
+
// Diagnostics must not change the verification outcome.
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { createDebug, debugUrl };
|
package/download.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { assertVerifiedResponse, createAgent } = require('./transport');
|
|
4
|
+
const { MAX_CRL_BYTES } = require('./crl');
|
|
5
|
+
const { debugUrl } = require('./debug');
|
|
6
|
+
|
|
7
|
+
function networkUrl(value, kind = 'CRL distribution point') {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value);
|
|
11
|
+
} catch (_) {
|
|
12
|
+
throw new Error(`${kind} is not an absolute network URL`);
|
|
13
|
+
}
|
|
14
|
+
if (! [ 'http:', 'https:' ].includes(url.protocol)) {
|
|
15
|
+
throw new Error(`Unsupported ${kind} scheme ${url.protocol}; only HTTP and HTTPS are allowed`);
|
|
16
|
+
}
|
|
17
|
+
if (url.username || url.password) {
|
|
18
|
+
throw new Error(`${kind} URLs must not contain credentials`);
|
|
19
|
+
}
|
|
20
|
+
url.hash = '';
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function download(value, signal, body, debug) {
|
|
25
|
+
const kind = (body === undefined) ? 'CRL' : 'OCSP';
|
|
26
|
+
const maxBytes = (body === undefined) ? MAX_CRL_BYTES : 1024 * 1024;
|
|
27
|
+
let url = networkUrl(value, kind);
|
|
28
|
+
const timeout = AbortSignal.timeout(10000);
|
|
29
|
+
const downloadSignal = signal ? AbortSignal.any([ signal, timeout ]) : timeout;
|
|
30
|
+
const agent = createAgent(undefined, downloadSignal);
|
|
31
|
+
try {
|
|
32
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
33
|
+
debug?.(`${kind} download started`, { source: debugUrl(url), method: (body === undefined) ? 'GET' : 'POST' });
|
|
34
|
+
const response = await globalThis.fetch(url, {
|
|
35
|
+
dispatcher: agent,
|
|
36
|
+
signal: downloadSignal,
|
|
37
|
+
redirect: 'manual',
|
|
38
|
+
credentials: 'omit',
|
|
39
|
+
referrerPolicy: 'no-referrer',
|
|
40
|
+
method: (body === undefined) ? 'GET' : 'POST',
|
|
41
|
+
body,
|
|
42
|
+
headers: (body === undefined) ? undefined : {
|
|
43
|
+
'content-type': 'application/ocsp-request',
|
|
44
|
+
accept: 'application/ocsp-response'
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
assertVerifiedResponse(agent, url.href, response);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
void response.body?.cancel().catch(() => {});
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
debug?.(`${kind} HTTP response`, { source: debugUrl(url), status: response.status });
|
|
54
|
+
if ([ 301, 302, 303, 307, 308 ].includes(response.status)) {
|
|
55
|
+
await response.body?.cancel();
|
|
56
|
+
if ((body !== undefined) && ! [ 307, 308 ].includes(response.status)) {
|
|
57
|
+
throw new Error(`OCSP POST redirect HTTP ${response.status} is unsupported; expected 307 or 308`);
|
|
58
|
+
}
|
|
59
|
+
const location = response.headers.get('location');
|
|
60
|
+
if (! location) {
|
|
61
|
+
throw new Error(`${kind} redirect has no Location header`);
|
|
62
|
+
}
|
|
63
|
+
const target = networkUrl(new URL(location, url).href, kind);
|
|
64
|
+
debug?.(`${kind} redirect`, { source: debugUrl(url), target: debugUrl(target), status: response.status });
|
|
65
|
+
url = target;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (response.status !== 200) {
|
|
69
|
+
await response.body?.cancel();
|
|
70
|
+
throw new Error(`${kind} download returned HTTP ${response.status}`);
|
|
71
|
+
}
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let length = 0;
|
|
74
|
+
if (response.body) {
|
|
75
|
+
for await (const chunk of response.body) {
|
|
76
|
+
length += chunk.byteLength;
|
|
77
|
+
if (length > maxBytes) {
|
|
78
|
+
throw new Error(`${kind} download exceeds the ${maxBytes / (1024 * 1024)} 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);
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`${kind} download exceeded the five-redirect limit`);
|
|
87
|
+
} finally {
|
|
88
|
+
await agent.destroy();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function downloadCrl(value, signal, debug) {
|
|
93
|
+
return download(value, signal, undefined, debug);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function downloadOcsp(value, request, signal, debug) {
|
|
97
|
+
return download(value, signal, request, debug);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { networkUrl, downloadCrl, downloadOcsp };
|
package/errors.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const CODES = {
|
|
4
|
+
missingCrlDistributionPoint: 'TR_FETCH_CRL_MISSING_DISTRIBUTION_POINT',
|
|
5
|
+
unreachableCrlDistributionPoint: 'TR_FETCH_CRL_UNREACHABLE_DISTRIBUTION_POINT',
|
|
6
|
+
invalidCrl: 'TR_FETCH_CRL_INVALID',
|
|
7
|
+
revokedCertificate: 'TR_FETCH_CERTIFICATE_REVOKED',
|
|
8
|
+
missingOcspUri: 'TR_FETCH_OCSP_MISSING_URI',
|
|
9
|
+
unreachableOcspUri: 'TR_FETCH_OCSP_UNREACHABLE_URI',
|
|
10
|
+
rejectedCertificate: 'TR_FETCH_OCSP_CERTIFICATE_REJECTED'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class TrFetchRevocationError extends Error {
|
|
14
|
+
constructor(policyKey, message, details = {}, cause) {
|
|
15
|
+
super(`trFetch: ${message}`, (cause === undefined) ? undefined : { cause });
|
|
16
|
+
this.name = new.target.name;
|
|
17
|
+
this.code = CODES[policyKey];
|
|
18
|
+
this.policyKey = policyKey;
|
|
19
|
+
Object.assign(this, details);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class TrFetchCrlError extends TrFetchRevocationError {}
|
|
24
|
+
class TrFetchOcspError extends TrFetchRevocationError {}
|
|
25
|
+
|
|
26
|
+
function applyPolicy(policy, error, debug) {
|
|
27
|
+
const action = policy[error.policyKey];
|
|
28
|
+
debug?.((error instanceof TrFetchOcspError) ? 'OCSP policy applied' : 'CRL policy applied', {
|
|
29
|
+
policy: error.policyKey, action, code: error.code, reason: error.message
|
|
30
|
+
});
|
|
31
|
+
if (action === 'reject') {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
if (action === 'warn') {
|
|
35
|
+
const warning = new error.constructor(error.policyKey, error.message.slice(9), {}, error.cause);
|
|
36
|
+
Object.assign(warning, error);
|
|
37
|
+
warning.name = error.name.replace(/Error$/, 'Warning');
|
|
38
|
+
process.emitWarning(warning);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { TrFetchCrlError, TrFetchOcspError, applyPolicy };
|
package/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { splitOptions } = require('./options');
|
|
4
|
+
const { assertDefaultDispatcher, assertVerifiedResponse, createAgent } = require('./transport');
|
|
5
|
+
const { checkChain } = require('./check');
|
|
6
|
+
const { TrFetchCrlError, TrFetchOcspError } = require('./errors');
|
|
7
|
+
const { createDebug, debugUrl } = require('./debug');
|
|
8
|
+
|
|
9
|
+
async function trFetch(input, options) {
|
|
10
|
+
const config = splitOptions(options);
|
|
11
|
+
const debug = createDebug(config.debugEnabled);
|
|
12
|
+
config.debug = debug;
|
|
13
|
+
assertDefaultDispatcher();
|
|
14
|
+
const request = new Request(input, config.fetchOptions);
|
|
15
|
+
const signal = request.signal;
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
debug?.('Verification configured', {
|
|
18
|
+
url: debugUrl(request.url),
|
|
19
|
+
crl: config.policy.disabled ? 'disabled' : 'enabled',
|
|
20
|
+
crlDepth: (config.checkDepth === Infinity) ? 'full-chain' : config.checkDepth,
|
|
21
|
+
ocsp: config.ocspPolicy.disabled ? 'disabled' : 'enabled',
|
|
22
|
+
ocspDepth: (config.ocspCheckDepth === Infinity) ? 'full-chain' : config.ocspCheckDepth
|
|
23
|
+
});
|
|
24
|
+
const check = (config.policy.disabled && config.ocspPolicy.disabled) ? undefined :
|
|
25
|
+
(peer, hostname) => checkChain(peer, hostname, config, signal);
|
|
26
|
+
const agent = createAgent(check, signal);
|
|
27
|
+
try {
|
|
28
|
+
const response = await globalThis.fetch(request, { dispatcher: agent });
|
|
29
|
+
try {
|
|
30
|
+
assertVerifiedResponse(agent, request.url, response);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
void response.body?.cancel().catch(() => {});
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
debug?.('Fetch response received', { url: debugUrl(response.url || request.url), status: response.status });
|
|
36
|
+
// close() waits for body consumption; do not await it before returning
|
|
37
|
+
// the response, or streaming responses would deadlock.
|
|
38
|
+
void agent.close().catch(() => {});
|
|
39
|
+
return response;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
debug?.('Fetch failed', { aborted: signal.aborted, code: error.cause?.code ?? error.code ?? 'FETCH_FAILED' });
|
|
42
|
+
await agent.destroy();
|
|
43
|
+
if (signal.aborted) {
|
|
44
|
+
throw signal.reason;
|
|
45
|
+
}
|
|
46
|
+
for (let cause = error; cause; cause = cause.cause) {
|
|
47
|
+
if ((cause instanceof TrFetchCrlError) || (cause instanceof TrFetchOcspError)) {
|
|
48
|
+
throw cause;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = trFetch;
|
|
56
|
+
module.exports.TrFetchCrlError = TrFetchCrlError;
|
|
57
|
+
module.exports.TrFetchOcspError = TrFetchOcspError;
|