tunnelfetch 1.0.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 +28 -0
- package/README.md +617 -0
- package/README.zh-CN.md +470 -0
- package/package.json +74 -0
- package/src/client/cookies.js +429 -0
- package/src/client/decode.js +346 -0
- package/src/client/redirect.js +249 -0
- package/src/client.js +704 -0
- package/src/errors.js +181 -0
- package/src/http1/chunked.js +289 -0
- package/src/http1/index.js +10 -0
- package/src/http1/request.js +143 -0
- package/src/http1/response.js +493 -0
- package/src/http2/connection.js +1170 -0
- package/src/http2/constants.js +129 -0
- package/src/http2/frames.js +291 -0
- package/src/http2/hpack.js +420 -0
- package/src/http2/huffman.js +203 -0
- package/src/http2/index.js +21 -0
- package/src/index.js +46 -0
- package/src/pool.js +256 -0
- package/src/proxy/direct.js +62 -0
- package/src/proxy/http-connect.js +206 -0
- package/src/proxy/index.js +197 -0
- package/src/proxy/socks5.js +344 -0
- package/src/tls/aead.js +263 -0
- package/src/tls/connect.js +407 -0
- package/src/tls/constants.js +334 -0
- package/src/tls/extensions.js +376 -0
- package/src/tls/handshake-messages.js +901 -0
- package/src/tls/handshake.js +568 -0
- package/src/tls/handshake12.js +507 -0
- package/src/tls/index.js +44 -0
- package/src/tls/keyschedule.js +473 -0
- package/src/tls/record.js +872 -0
- package/src/tls/tickets.js +145 -0
- package/src/tls/transcript.js +101 -0
- package/src/tls/wire.js +224 -0
- package/src/transport.js +296 -0
- package/src/trust/der.js +551 -0
- package/src/trust/index.js +375 -0
- package/src/trust/name.js +235 -0
- package/src/trust/ocsp.js +759 -0
- package/src/trust/path.js +595 -0
- package/src/trust/roots.js +454 -0
- package/src/trust/x509.js +902 -0
- package/src/util/bytes.js +470 -0
- package/src/util/deadline.js +266 -0
- package/src/warmup-fixture.js +85 -0
- package/src/warmup.js +243 -0
- package/types/client/cookies.d.ts +159 -0
- package/types/client/decode.d.ts +54 -0
- package/types/client/redirect.d.ts +96 -0
- package/types/client.d.ts +323 -0
- package/types/errors.d.ts +141 -0
- package/types/http1/chunked.d.ts +48 -0
- package/types/http1/index.d.ts +3 -0
- package/types/http1/request.d.ts +44 -0
- package/types/http1/response.d.ts +183 -0
- package/types/http2/connection.d.ts +282 -0
- package/types/http2/constants.d.ts +95 -0
- package/types/http2/frames.d.ts +116 -0
- package/types/http2/hpack.d.ts +99 -0
- package/types/http2/huffman.d.ts +21 -0
- package/types/http2/index.d.ts +5 -0
- package/types/index.d.ts +17 -0
- package/types/pool.d.ts +135 -0
- package/types/proxy/direct.d.ts +26 -0
- package/types/proxy/http-connect.d.ts +37 -0
- package/types/proxy/index.d.ts +62 -0
- package/types/proxy/socks5.d.ts +47 -0
- package/types/tls/aead.d.ts +67 -0
- package/types/tls/connect.d.ts +280 -0
- package/types/tls/constants.d.ts +275 -0
- package/types/tls/extensions.d.ts +195 -0
- package/types/tls/handshake-messages.d.ts +430 -0
- package/types/tls/handshake.d.ts +90 -0
- package/types/tls/handshake12.d.ts +35 -0
- package/types/tls/index.d.ts +9 -0
- package/types/tls/keyschedule.d.ts +272 -0
- package/types/tls/record.d.ts +361 -0
- package/types/tls/tickets.d.ts +66 -0
- package/types/tls/transcript.d.ts +52 -0
- package/types/tls/wire.d.ts +106 -0
- package/types/transport.d.ts +222 -0
- package/types/trust/der.d.ts +239 -0
- package/types/trust/index.d.ts +194 -0
- package/types/trust/name.d.ts +33 -0
- package/types/trust/ocsp.d.ts +138 -0
- package/types/trust/path.d.ts +139 -0
- package/types/trust/roots.d.ts +36 -0
- package/types/trust/x509.d.ts +401 -0
- package/types/util/bytes.d.ts +183 -0
- package/types/util/deadline.d.ts +133 -0
- package/types/warmup-fixture.d.ts +11 -0
- package/types/warmup.d.ts +45 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
// Stapled OCSP response verification (RFC 6960), on the strict DER reader.
|
|
2
|
+
//
|
|
3
|
+
// Why stapling, and only stapling. Revocation information can also be fetched from the CA's own
|
|
4
|
+
// responder or pulled as CRLs, and both are wrong for this package: a responder fetch is an
|
|
5
|
+
// extra network round trip in the middle of a metered handshake, made through the caller's
|
|
6
|
+
// proxy, and it tells the CA which origins the caller talks to — an unacceptable default twice
|
|
7
|
+
// over; CRLs are megabytes of list to download and cache on a runtime with no cross-request
|
|
8
|
+
// storage. A stapled response costs nothing extra: the server includes a CA-signed, time-stamped
|
|
9
|
+
// OCSP response in the handshake it was already sending (RFC 6066 s8 for TLS 1.2, RFC 8446
|
|
10
|
+
// s4.4.2.1 for TLS 1.3), and this module decides whether that response actually proves anything.
|
|
11
|
+
//
|
|
12
|
+
// The threat model, which dictates every check below: the staple arrives from THE SAME PEER we
|
|
13
|
+
// are trying to authenticate. Until its signature is verified against a key the certification
|
|
14
|
+
// path already vouches for, it is attacker-controlled bytes shaped like good news. So a staple
|
|
15
|
+
// is believed only when ALL of the following hold, and every other outcome is a typed error:
|
|
16
|
+
//
|
|
17
|
+
// 1. It parses as strict DER (this is a hostile-input parser exactly like x509.js, and is held
|
|
18
|
+
// to the same standard: reject ambiguity, reject trailing bytes, reject encoded DEFAULTs).
|
|
19
|
+
// 2. Its CertID matches the certificate in hand — issuer name hash, issuer key hash, and
|
|
20
|
+
// serial — computed from OUR copy of the validated path, not from anything the response
|
|
21
|
+
// says about itself.
|
|
22
|
+
// 3. Its signature verifies against the issuing CA's key, or against a delegated responder
|
|
23
|
+
// certificate that the issuing CA signed DIRECTLY and marked with the id-kp-OCSPSigning
|
|
24
|
+
// extended key usage (RFC 6960 s4.2.2.2). Without the EKU requirement, any TLS certificate
|
|
25
|
+
// the CA ever issued could mint "good" responses for its siblings.
|
|
26
|
+
// 4. Its validity window (thisUpdate / nextUpdate) covers the caller-injected `now` — never a
|
|
27
|
+
// wall clock, which on the target runtime is frozen per-slice and lies.
|
|
28
|
+
//
|
|
29
|
+
// Only then is the verdict read, and the verdict is not negotiable: `good` continues, `revoked`
|
|
30
|
+
// and `unknown` both refuse the connection. Treating `unknown` as anything but a failure would
|
|
31
|
+
// let a responder shrug a revoked serial back to life.
|
|
32
|
+
//
|
|
33
|
+
// What stapling deliberately cannot do — an attacker who can strip the staple entirely — is a
|
|
34
|
+
// policy question, not a verification one, and lives with the trust configuration in index.js.
|
|
35
|
+
|
|
36
|
+
import { CertificateError, codes } from '../errors.js';
|
|
37
|
+
import { equal, toHex } from '../util/bytes.js';
|
|
38
|
+
import {
|
|
39
|
+
TAG,
|
|
40
|
+
CLS,
|
|
41
|
+
readTlv,
|
|
42
|
+
readAll,
|
|
43
|
+
children,
|
|
44
|
+
content,
|
|
45
|
+
element,
|
|
46
|
+
expectTlv,
|
|
47
|
+
readInteger,
|
|
48
|
+
readOid,
|
|
49
|
+
readBitString,
|
|
50
|
+
readBoolean,
|
|
51
|
+
readGeneralizedTime,
|
|
52
|
+
parseError,
|
|
53
|
+
tagName,
|
|
54
|
+
} from './der.js';
|
|
55
|
+
import {
|
|
56
|
+
OID,
|
|
57
|
+
parseAlgorithmIdentifier,
|
|
58
|
+
parseCertificate,
|
|
59
|
+
parseSubjectPublicKeyInfo,
|
|
60
|
+
} from './x509.js';
|
|
61
|
+
import { verifySignedObject } from './path.js';
|
|
62
|
+
|
|
63
|
+
// Clock skew tolerated when judging the validity window. The responder's clock and the caller's
|
|
64
|
+
// injected `now` are different machines; a response produced seconds ago must not read as "from
|
|
65
|
+
// the future" on a slightly slow clock. Five minutes is the ballpark every deployed validator
|
|
66
|
+
// uses, and it is far below the days-long windows real responders publish.
|
|
67
|
+
const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
68
|
+
|
|
69
|
+
// RFC 6960 s2.4 reads an absent nextUpdate as "newer revocation information is available all the
|
|
70
|
+
// time", which taken literally makes every such response stale the moment it is stapled. Real
|
|
71
|
+
// CAs that omit nextUpdate expect polling clients; for a staple the fail-closed reading with a
|
|
72
|
+
// tolerance is a hard age cap from thisUpdate. Ten days matches mozilla::pkix's
|
|
73
|
+
// maxOCSPLifetimeInDays, the most-reviewed fail-closed figure in deployment.
|
|
74
|
+
const MAX_AGE_WITHOUT_NEXT_UPDATE_MS = 10 * 24 * 3600 * 1000;
|
|
75
|
+
|
|
76
|
+
const RESPONSE_STATUS_NAME = {
|
|
77
|
+
0: 'successful',
|
|
78
|
+
1: 'malformedRequest',
|
|
79
|
+
2: 'internalError',
|
|
80
|
+
3: 'tryLater',
|
|
81
|
+
5: 'sigRequired',
|
|
82
|
+
6: 'unauthorized',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** RFC 5280 s5.3.1 CRLReason values, for naming why a certificate was revoked. */
|
|
86
|
+
const CRL_REASON_NAME = {
|
|
87
|
+
0: 'unspecified',
|
|
88
|
+
1: 'keyCompromise',
|
|
89
|
+
2: 'cACompromise',
|
|
90
|
+
3: 'affiliationChanged',
|
|
91
|
+
4: 'superseded',
|
|
92
|
+
5: 'cessationOfOperation',
|
|
93
|
+
6: 'certificateHold',
|
|
94
|
+
8: 'removeFromCRL',
|
|
95
|
+
9: 'privilegeWithdrawn',
|
|
96
|
+
10: 'aACompromise',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* CertID hash algorithms understood, with the digest length each must produce.
|
|
101
|
+
*
|
|
102
|
+
* SHA-1 is accepted HERE and nowhere else in this package, deliberately: the CertID hash is a
|
|
103
|
+
* lookup key, not a trust decision. What binds the response to the certificate is the CA's
|
|
104
|
+
* signature over the whole ResponseData; the hashes only say which issuer/serial the signed
|
|
105
|
+
* statement is about, the serial itself travels unhashed, and both hash inputs (issuer name,
|
|
106
|
+
* issuer key) are fixed by the CA rather than attacker-chosen — so a SHA-1 collision buys
|
|
107
|
+
* nothing. Meanwhile RFC 5019, the profile production responders actually implement, mandates
|
|
108
|
+
* SHA-1 CertIDs; refusing them would refuse essentially every real staple.
|
|
109
|
+
*/
|
|
110
|
+
const CERTID_HASH = {
|
|
111
|
+
[OID.sha1]: { name: 'SHA-1', length: 20 },
|
|
112
|
+
[OID.sha256]: { name: 'SHA-256', length: 32 },
|
|
113
|
+
[OID.sha384]: { name: 'SHA-384', length: 48 },
|
|
114
|
+
[OID.sha512]: { name: 'SHA-512', length: 64 },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
118
|
+
|
|
119
|
+
const ocspError = (code, message, detail) => new CertificateError(code, message, detail);
|
|
120
|
+
|
|
121
|
+
/** ENUMERATED with the small non-negative range OCSP uses. Anything else is out of protocol. */
|
|
122
|
+
function readEnumerated(bytes, tlv, what) {
|
|
123
|
+
expectTlv(tlv, { tag: TAG.ENUMERATED, constructed: false }, what);
|
|
124
|
+
const c = content(bytes, tlv);
|
|
125
|
+
// INTEGER encoding rules apply; every value the protocol defines fits one non-negative byte,
|
|
126
|
+
// so a longer (or high-bit) encoding is either non-minimal or out of range. Both are refused.
|
|
127
|
+
if (c.byteLength !== 1 || c[0] > 0x7f) {
|
|
128
|
+
throw parseError(tlv.start, `${what} must be a single byte in 0..127`);
|
|
129
|
+
}
|
|
130
|
+
return c[0];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** NULL parameters (or absent) — the only AlgorithmIdentifier parameters a digest OID takes. */
|
|
134
|
+
function requireNullOrAbsentParams(algo, what) {
|
|
135
|
+
const p = algo.paramsTlv;
|
|
136
|
+
if (p === null) return;
|
|
137
|
+
if (p.cls === CLS.UNIVERSAL && p.tag === TAG.NULL && !p.constructed &&
|
|
138
|
+
p.contentStart === p.contentEnd) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
throw parseError(p.start, `${what} parameters must be NULL or absent`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extension walk shared by responseExtensions and singleExtensions. `recognized` names the OIDs
|
|
146
|
+
* whose semantics this module has judged and may ignore; an unrecognized CRITICAL extension
|
|
147
|
+
* rejects the response (RFC 6960 s4.4, same fail-closed stance as RFC 5280 s6.1) because an
|
|
148
|
+
* extension we cannot read may change the meaning of everything we can.
|
|
149
|
+
*/
|
|
150
|
+
function checkExtensions(bytes, wrapper, recognized, what) {
|
|
151
|
+
const inner = explicitInner(bytes, wrapper, what);
|
|
152
|
+
expectTlv(inner, { tag: TAG.SEQUENCE, constructed: true }, what);
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
for (const extTlv of children(bytes, inner, what)) {
|
|
155
|
+
expectTlv(extTlv, { tag: TAG.SEQUENCE, constructed: true }, 'Extension');
|
|
156
|
+
const parts = children(bytes, extTlv, 'Extension');
|
|
157
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
158
|
+
throw parseError(extTlv.start, 'Extension must be { extnID, critical?, extnValue }');
|
|
159
|
+
}
|
|
160
|
+
const extnId = readOid(bytes, parts[0], 'extnID');
|
|
161
|
+
let critical = false;
|
|
162
|
+
if (parts.length === 3) {
|
|
163
|
+
critical = readBoolean(bytes, parts[1], 'critical');
|
|
164
|
+
if (!critical) throw parseError(parts[1].start, 'critical FALSE must be omitted in DER');
|
|
165
|
+
}
|
|
166
|
+
expectTlv(parts[parts.length - 1], { tag: TAG.OCTET_STRING, constructed: false }, 'extnValue');
|
|
167
|
+
if (seen.has(extnId)) throw parseError(extTlv.start, `duplicate extension ${extnId} in ${what}`);
|
|
168
|
+
seen.add(extnId);
|
|
169
|
+
if (critical && !recognized.has(extnId)) {
|
|
170
|
+
throw ocspError(codes.OCSP_PARSE,
|
|
171
|
+
`stapled OCSP response carries unrecognized critical extension ${extnId} in ${what}; ` +
|
|
172
|
+
'RFC 6960 s4.4 requires rejection rather than a guess at its meaning',
|
|
173
|
+
{ oid: extnId, where: what });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A nonce (RFC 6960 s4.4.1) binds a response to the request that carried the nonce. A staple
|
|
179
|
+
// answers no request of ours, so there is nothing to match and the extension imposes nothing —
|
|
180
|
+
// recognized, and deliberately inert.
|
|
181
|
+
const RECOGNIZED_RESPONSE_EXTENSIONS = new Set([OID.ocspNonce]);
|
|
182
|
+
const RECOGNIZED_SINGLE_EXTENSIONS = new Set();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* CertID (RFC 6960 s4.1.1): which certificate a SingleResponse is talking about.
|
|
186
|
+
* @typedef {object} OcspCertId
|
|
187
|
+
* @property {string} hashOid
|
|
188
|
+
* @property {Uint8Array} issuerNameHash
|
|
189
|
+
* @property {Uint8Array} issuerKeyHash
|
|
190
|
+
* @property {Uint8Array} serialBytes INTEGER content bytes, minimal DER
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One SingleResponse, parsed. Times are epoch ms.
|
|
195
|
+
* @typedef {object} OcspSingleResponse
|
|
196
|
+
* @property {OcspCertId} certId
|
|
197
|
+
* @property {{ kind: 'good' } | { kind: 'unknown' }
|
|
198
|
+
* | { kind: 'revoked', revocationTime: number, reason: number | null }} status
|
|
199
|
+
* @property {number} thisUpdate
|
|
200
|
+
* @property {number | null} nextUpdate
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A parsed BasicOCSPResponse. `tbsBytes` is the exact original ResponseData element — the bytes
|
|
205
|
+
* the signature covers, never re-encoded.
|
|
206
|
+
* @typedef {object} OcspBasicResponse
|
|
207
|
+
* @property {Uint8Array} tbsBytes
|
|
208
|
+
* @property {import('./x509.js').AlgorithmId} signatureAlgorithm
|
|
209
|
+
* @property {Uint8Array} signature
|
|
210
|
+
* @property {{ kind: 'name', nameBytes: Uint8Array } | { kind: 'key', keyHash: Uint8Array }} responderId
|
|
211
|
+
* @property {number} producedAt epoch ms
|
|
212
|
+
* @property {OcspSingleResponse[]} singles
|
|
213
|
+
* @property {import('./x509.js').Certificate[]} certs attached responder certificates, parsed
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @typedef {object} OcspResponse
|
|
218
|
+
* @property {number} responseStatus
|
|
219
|
+
* @property {string} responseStatusName
|
|
220
|
+
* @property {OcspBasicResponse | null} basic null exactly when responseStatus != successful
|
|
221
|
+
*/
|
|
222
|
+
|
|
223
|
+
function parseCertId(bytes, tlv) {
|
|
224
|
+
expectTlv(tlv, { tag: TAG.SEQUENCE, constructed: true }, 'CertID');
|
|
225
|
+
const kids = children(bytes, tlv, 'CertID');
|
|
226
|
+
if (kids.length !== 4) {
|
|
227
|
+
throw parseError(tlv.start,
|
|
228
|
+
'CertID must be { hashAlgorithm, issuerNameHash, issuerKeyHash, serialNumber }');
|
|
229
|
+
}
|
|
230
|
+
const algo = parseAlgorithmIdentifier(bytes, kids[0], 'CertID hashAlgorithm');
|
|
231
|
+
requireNullOrAbsentParams(algo, 'CertID hashAlgorithm');
|
|
232
|
+
expectTlv(kids[1], { tag: TAG.OCTET_STRING, constructed: false }, 'issuerNameHash');
|
|
233
|
+
expectTlv(kids[2], { tag: TAG.OCTET_STRING, constructed: false }, 'issuerKeyHash');
|
|
234
|
+
const serial = readInteger(bytes, kids[3], 'CertID serialNumber');
|
|
235
|
+
return {
|
|
236
|
+
hashOid: algo.oid,
|
|
237
|
+
issuerNameHash: content(bytes, kids[1]),
|
|
238
|
+
issuerKeyHash: content(bytes, kids[2]),
|
|
239
|
+
serialBytes: serial.bytes,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseCertStatus(bytes, tlv) {
|
|
244
|
+
if (tlv.cls !== CLS.CONTEXT) {
|
|
245
|
+
throw parseError(tlv.start,
|
|
246
|
+
`expected a context-tagged CertStatus, got ${tagName(tlv.cls, tlv.tag)}`);
|
|
247
|
+
}
|
|
248
|
+
switch (tlv.tag) {
|
|
249
|
+
case 0: // good [0] IMPLICIT NULL
|
|
250
|
+
case 2: { // unknown [2] IMPLICIT UnknownInfo (NULL)
|
|
251
|
+
if (tlv.constructed || tlv.contentStart !== tlv.contentEnd) {
|
|
252
|
+
throw parseError(tlv.start, `CertStatus [${tlv.tag}] must be an empty primitive (NULL)`);
|
|
253
|
+
}
|
|
254
|
+
return tlv.tag === 0 ? { kind: 'good' } : { kind: 'unknown' };
|
|
255
|
+
}
|
|
256
|
+
case 1: { // revoked [1] IMPLICIT RevokedInfo
|
|
257
|
+
if (!tlv.constructed) throw parseError(tlv.start, 'RevokedInfo must be constructed');
|
|
258
|
+
const kids = children(bytes, tlv, 'RevokedInfo');
|
|
259
|
+
if (kids.length < 1 || kids.length > 2) {
|
|
260
|
+
throw parseError(tlv.start, 'RevokedInfo must be { revocationTime, revocationReason? }');
|
|
261
|
+
}
|
|
262
|
+
const revocationTime = readGeneralizedTime(bytes, kids[0], 'revocationTime');
|
|
263
|
+
let reason = null;
|
|
264
|
+
if (kids.length === 2) {
|
|
265
|
+
const wrap = kids[1];
|
|
266
|
+
if (wrap.cls !== CLS.CONTEXT || wrap.tag !== 0 || !wrap.constructed) {
|
|
267
|
+
throw parseError(wrap.start, 'revocationReason must be [0] EXPLICIT CRLReason');
|
|
268
|
+
}
|
|
269
|
+
reason = readEnumerated(bytes, explicitInner(bytes, wrap, 'revocationReason'), 'CRLReason');
|
|
270
|
+
}
|
|
271
|
+
return { kind: 'revoked', revocationTime, reason };
|
|
272
|
+
}
|
|
273
|
+
default:
|
|
274
|
+
throw parseError(tlv.start, `unknown CertStatus tag [${tlv.tag}]`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function parseSingleResponse(bytes, tlv) {
|
|
279
|
+
expectTlv(tlv, { tag: TAG.SEQUENCE, constructed: true }, 'SingleResponse');
|
|
280
|
+
const kids = children(bytes, tlv, 'SingleResponse');
|
|
281
|
+
let i = 0;
|
|
282
|
+
const next = (what) => {
|
|
283
|
+
if (i >= kids.length) throw parseError(tlv.end, `SingleResponse ends before ${what}`);
|
|
284
|
+
return kids[i++];
|
|
285
|
+
};
|
|
286
|
+
const certId = parseCertId(bytes, next('certID'));
|
|
287
|
+
const status = parseCertStatus(bytes, next('certStatus'));
|
|
288
|
+
const thisUpdate = readGeneralizedTime(bytes, next('thisUpdate'), 'thisUpdate');
|
|
289
|
+
let nextUpdate = null;
|
|
290
|
+
if (i < kids.length && kids[i].cls === CLS.CONTEXT && kids[i].tag === 0) {
|
|
291
|
+
const wrap = kids[i++];
|
|
292
|
+
if (!wrap.constructed) throw parseError(wrap.start, 'nextUpdate must be [0] EXPLICIT');
|
|
293
|
+
nextUpdate = readGeneralizedTime(bytes, explicitInner(bytes, wrap, 'nextUpdate'), 'nextUpdate');
|
|
294
|
+
if (nextUpdate < thisUpdate) {
|
|
295
|
+
// A window that ends before it begins is self-contradictory; there is no correct half to
|
|
296
|
+
// believe, so neither is believed.
|
|
297
|
+
throw parseError(wrap.start,
|
|
298
|
+
`nextUpdate ${iso(nextUpdate)} precedes thisUpdate ${iso(thisUpdate)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (i < kids.length && kids[i].cls === CLS.CONTEXT && kids[i].tag === 1) {
|
|
302
|
+
checkExtensions(bytes, kids[i++], RECOGNIZED_SINGLE_EXTENSIONS, 'singleExtensions');
|
|
303
|
+
}
|
|
304
|
+
if (i !== kids.length) throw parseError(kids[i].start, 'unexpected trailing SingleResponse field');
|
|
305
|
+
return { certId, status, thisUpdate, nextUpdate };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** [tag] EXPLICIT wrapper: exactly one inner element filling the wrapper's content. */
|
|
309
|
+
function explicitInner(bytes, wrap, what) {
|
|
310
|
+
const inner = readTlv(bytes, wrap.contentStart);
|
|
311
|
+
if (inner.end !== wrap.contentEnd) throw parseError(inner.end, `trailing bytes in ${what}`);
|
|
312
|
+
return inner;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function parseResponseData(bytes, tlv) {
|
|
316
|
+
expectTlv(tlv, { tag: TAG.SEQUENCE, constructed: true }, 'ResponseData');
|
|
317
|
+
const kids = children(bytes, tlv, 'ResponseData');
|
|
318
|
+
let i = 0;
|
|
319
|
+
const next = (what) => {
|
|
320
|
+
if (i >= kids.length) throw parseError(tlv.end, `ResponseData ends before ${what}`);
|
|
321
|
+
return kids[i++];
|
|
322
|
+
};
|
|
323
|
+
if (i < kids.length && kids[i].cls === CLS.CONTEXT && kids[i].tag === 0 && kids[i].constructed) {
|
|
324
|
+
// version [0] EXPLICIT Version DEFAULT v1. Only v1 exists, so an encoded version is either
|
|
325
|
+
// the forbidden DEFAULT (DER: must be omitted) or a version this parser cannot promise to
|
|
326
|
+
// understand. Both die, each with its own accurate story.
|
|
327
|
+
const inner = explicitInner(bytes, next('version'), 'version');
|
|
328
|
+
const { value, negative } = readInteger(bytes, inner, 'ResponseData version');
|
|
329
|
+
if (!negative && value === 0) {
|
|
330
|
+
throw parseError(inner.start, 'ResponseData version v1 must be omitted (DEFAULT) in DER');
|
|
331
|
+
}
|
|
332
|
+
throw parseError(inner.start,
|
|
333
|
+
`unsupported ResponseData version ${negative ? 'negative' : value}; only v1 is defined`);
|
|
334
|
+
}
|
|
335
|
+
const ridTlv = next('responderID');
|
|
336
|
+
let responderId;
|
|
337
|
+
if (ridTlv.cls === CLS.CONTEXT && ridTlv.tag === 1 && ridTlv.constructed) {
|
|
338
|
+
const inner = explicitInner(bytes, ridTlv, 'responderID');
|
|
339
|
+
expectTlv(inner, { tag: TAG.SEQUENCE, constructed: true }, 'ResponderID byName');
|
|
340
|
+
responderId = { kind: 'name', nameBytes: element(bytes, inner) };
|
|
341
|
+
} else if (ridTlv.cls === CLS.CONTEXT && ridTlv.tag === 2 && ridTlv.constructed) {
|
|
342
|
+
const inner = explicitInner(bytes, ridTlv, 'responderID');
|
|
343
|
+
expectTlv(inner, { tag: TAG.OCTET_STRING, constructed: false }, 'ResponderID byKey');
|
|
344
|
+
const keyHash = content(bytes, inner);
|
|
345
|
+
// KeyHash is defined as the SHA-1 of the responder's public key; any other length cannot be
|
|
346
|
+
// one and could never match anything, so it is refused at parse rather than mismatched later.
|
|
347
|
+
if (keyHash.byteLength !== 20) {
|
|
348
|
+
throw parseError(inner.start, `ResponderID byKey must be a 20-byte SHA-1 KeyHash, got ${keyHash.byteLength}`);
|
|
349
|
+
}
|
|
350
|
+
responderId = { kind: 'key', keyHash };
|
|
351
|
+
} else {
|
|
352
|
+
throw parseError(ridTlv.start,
|
|
353
|
+
`responderID must be [1] byName or [2] byKey, got ${tagName(ridTlv.cls, ridTlv.tag)}`);
|
|
354
|
+
}
|
|
355
|
+
const producedAt = readGeneralizedTime(bytes, next('producedAt'), 'producedAt');
|
|
356
|
+
const listTlv = next('responses');
|
|
357
|
+
expectTlv(listTlv, { tag: TAG.SEQUENCE, constructed: true }, 'responses');
|
|
358
|
+
const singles = children(bytes, listTlv, 'responses').map((s) => parseSingleResponse(bytes, s));
|
|
359
|
+
if (i < kids.length && kids[i].cls === CLS.CONTEXT && kids[i].tag === 1) {
|
|
360
|
+
checkExtensions(bytes, kids[i++], RECOGNIZED_RESPONSE_EXTENSIONS, 'responseExtensions');
|
|
361
|
+
}
|
|
362
|
+
if (i !== kids.length) throw parseError(kids[i].start, 'unexpected trailing ResponseData field');
|
|
363
|
+
return { responderId, producedAt, singles };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function parseBasicResponse(bytes) {
|
|
367
|
+
const outer = readAll(bytes, 'BasicOCSPResponse');
|
|
368
|
+
expectTlv(outer, { tag: TAG.SEQUENCE, constructed: true }, 'BasicOCSPResponse');
|
|
369
|
+
const kids = children(bytes, outer, 'BasicOCSPResponse');
|
|
370
|
+
if (kids.length < 3 || kids.length > 4) {
|
|
371
|
+
throw parseError(outer.start,
|
|
372
|
+
'BasicOCSPResponse must be { tbsResponseData, signatureAlgorithm, signature, certs? }');
|
|
373
|
+
}
|
|
374
|
+
const tbsTlv = expectTlv(kids[0], { tag: TAG.SEQUENCE, constructed: true }, 'tbsResponseData');
|
|
375
|
+
const { responderId, producedAt, singles } = parseResponseData(bytes, tbsTlv);
|
|
376
|
+
const signatureAlgorithm = parseAlgorithmIdentifier(bytes, kids[1], 'OCSP signatureAlgorithm');
|
|
377
|
+
const { bytes: signature } = readBitString(bytes, kids[2], 'OCSP signature');
|
|
378
|
+
const certs = [];
|
|
379
|
+
if (kids.length === 4) {
|
|
380
|
+
const wrap = kids[3];
|
|
381
|
+
if (wrap.cls !== CLS.CONTEXT || wrap.tag !== 0 || !wrap.constructed) {
|
|
382
|
+
throw parseError(wrap.start, 'BasicOCSPResponse certs must be [0] EXPLICIT');
|
|
383
|
+
}
|
|
384
|
+
const inner = explicitInner(bytes, wrap, 'certs');
|
|
385
|
+
expectTlv(inner, { tag: TAG.SEQUENCE, constructed: true }, 'certs');
|
|
386
|
+
for (const [index, certTlv] of children(bytes, inner, 'certs').entries()) {
|
|
387
|
+
try {
|
|
388
|
+
certs.push(parseCertificate(element(bytes, certTlv)));
|
|
389
|
+
} catch (e) {
|
|
390
|
+
// An attached blob that is not a certificate cannot vouch for anything, and tolerating
|
|
391
|
+
// it would mean carrying unparseable bytes into a trust decision.
|
|
392
|
+
throw new CertificateError(codes.CERT_PARSE,
|
|
393
|
+
`attached certificate ${index} in BasicOCSPResponse.certs does not parse: ` +
|
|
394
|
+
`${e?.message ?? e}`,
|
|
395
|
+
{ index });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
tbsBytes: element(bytes, tbsTlv),
|
|
401
|
+
signatureAlgorithm,
|
|
402
|
+
signature,
|
|
403
|
+
responderId,
|
|
404
|
+
producedAt,
|
|
405
|
+
singles,
|
|
406
|
+
certs,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Parse a DER OCSPResponse (RFC 6960 s4.2.1) into a fully-walked structure.
|
|
412
|
+
*
|
|
413
|
+
* A parser, not a judge, exactly like parseCertificate: it throws OCSP_PARSE on malformed or
|
|
414
|
+
* self-contradictory bytes and on constructs this package refuses to interpret (unknown
|
|
415
|
+
* responseType, unrecognized critical extensions); whether the parsed response is TRUE is
|
|
416
|
+
* verifyOcspStaple's problem.
|
|
417
|
+
*
|
|
418
|
+
* @param {Uint8Array} der
|
|
419
|
+
* @returns {OcspResponse}
|
|
420
|
+
*/
|
|
421
|
+
export function parseOcspResponse(der) {
|
|
422
|
+
try {
|
|
423
|
+
if (!(der instanceof Uint8Array)) {
|
|
424
|
+
throw parseError(0, 'OCSP response must be a Uint8Array of DER');
|
|
425
|
+
}
|
|
426
|
+
const outer = readAll(der, 'OCSPResponse');
|
|
427
|
+
expectTlv(outer, { tag: TAG.SEQUENCE, constructed: true }, 'OCSPResponse');
|
|
428
|
+
const kids = children(der, outer, 'OCSPResponse');
|
|
429
|
+
if (kids.length < 1 || kids.length > 2) {
|
|
430
|
+
throw parseError(outer.start, 'OCSPResponse must be { responseStatus, responseBytes? }');
|
|
431
|
+
}
|
|
432
|
+
const responseStatus = readEnumerated(der, kids[0], 'responseStatus');
|
|
433
|
+
const responseStatusName = RESPONSE_STATUS_NAME[responseStatus] ?? 'unknown status';
|
|
434
|
+
if (responseStatus !== 0) {
|
|
435
|
+
if (kids.length !== 1) {
|
|
436
|
+
// RFC 6960 s4.2.1: on error the responseBytes field is not set. A non-success status
|
|
437
|
+
// dragging a response body along is two stories in one message.
|
|
438
|
+
throw parseError(kids[1].start,
|
|
439
|
+
`OCSPResponse with responseStatus ${responseStatusName}(${responseStatus}) must not carry responseBytes`);
|
|
440
|
+
}
|
|
441
|
+
return { responseStatus, responseStatusName, basic: null };
|
|
442
|
+
}
|
|
443
|
+
if (kids.length !== 2) {
|
|
444
|
+
throw parseError(outer.start, 'successful OCSPResponse is missing responseBytes');
|
|
445
|
+
}
|
|
446
|
+
const wrap = kids[1];
|
|
447
|
+
if (wrap.cls !== CLS.CONTEXT || wrap.tag !== 0 || !wrap.constructed) {
|
|
448
|
+
throw parseError(wrap.start, 'responseBytes must be [0] EXPLICIT');
|
|
449
|
+
}
|
|
450
|
+
const inner = explicitInner(der, wrap, 'responseBytes');
|
|
451
|
+
expectTlv(inner, { tag: TAG.SEQUENCE, constructed: true }, 'ResponseBytes');
|
|
452
|
+
const [typeTlv, respTlv, ...extra] = children(der, inner, 'ResponseBytes');
|
|
453
|
+
if (!typeTlv || !respTlv || extra.length) {
|
|
454
|
+
throw parseError(inner.start, 'ResponseBytes must be { responseType, response }');
|
|
455
|
+
}
|
|
456
|
+
const responseType = readOid(der, typeTlv, 'responseType');
|
|
457
|
+
if (responseType !== OID.ocspBasic) {
|
|
458
|
+
throw parseError(typeTlv.start,
|
|
459
|
+
`responseType ${responseType} is not id-pkix-ocsp-basic (${OID.ocspBasic}); no other ` +
|
|
460
|
+
'response type is defined for stapling');
|
|
461
|
+
}
|
|
462
|
+
expectTlv(respTlv, { tag: TAG.OCTET_STRING, constructed: false }, 'response');
|
|
463
|
+
const basic = parseBasicResponse(content(der, respTlv));
|
|
464
|
+
return { responseStatus, responseStatusName, basic };
|
|
465
|
+
} catch (e) {
|
|
466
|
+
// Low-level DER failures arrive as CERT_PARSE from der.js/x509.js; re-badge them so a caller
|
|
467
|
+
// switching on codes sees one story for "the staple did not parse", with the byte-precise
|
|
468
|
+
// message preserved.
|
|
469
|
+
if (e instanceof CertificateError && e.code === codes.CERT_PARSE) {
|
|
470
|
+
throw ocspError(codes.OCSP_PARSE,
|
|
471
|
+
`stapled OCSP response is malformed: ${e.message}`, e.detail);
|
|
472
|
+
}
|
|
473
|
+
throw e;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The issuer of the certificate under check, reduced to what OCSP verification needs. Built by
|
|
479
|
+
* the caller from the VALIDATED path — the certificate that actually signed the leaf, or the
|
|
480
|
+
* trust anchor when the leaf sits directly under one — never from the unverified wire chain.
|
|
481
|
+
* @typedef {object} OcspIssuer
|
|
482
|
+
* @property {Uint8Array} subjectBytes exact subject Name DER
|
|
483
|
+
* @property {Uint8Array} spkiDer SubjectPublicKeyInfo DER
|
|
484
|
+
* @property {string} subjectText for error messages
|
|
485
|
+
*/
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* What a verified `good` staple reports. Every other outcome throws; there is no boolean.
|
|
489
|
+
* @typedef {object} OcspVerdict
|
|
490
|
+
* @property {'good'} status
|
|
491
|
+
* @property {number} producedAt epoch ms
|
|
492
|
+
* @property {number} thisUpdate epoch ms
|
|
493
|
+
* @property {number | null} nextUpdate epoch ms
|
|
494
|
+
* @property {boolean} delegated whether a delegated responder certificate signed, rather than
|
|
495
|
+
* the CA key itself
|
|
496
|
+
*/
|
|
497
|
+
|
|
498
|
+
const describeResponderId = (rid) =>
|
|
499
|
+
rid.kind === 'name'
|
|
500
|
+
? `responder name ${toHex(rid.nameBytes).slice(0, 40)}…`
|
|
501
|
+
: `responder key hash ${toHex(rid.keyHash)}`;
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Verify a stapled OCSP response against the certificate it must vouch for.
|
|
505
|
+
*
|
|
506
|
+
* Every check the module comment promises happens here, in this order: parse, match the CertID
|
|
507
|
+
* to `leaf`/`issuer`, establish the signer (the CA itself or an RFC 6960 s4.2.2.2 delegated
|
|
508
|
+
* responder), verify the signature over the original tbsResponseData bytes, and only then read
|
|
509
|
+
* the verdict and its freshness window. `revoked` and `unknown` always throw; `good` throws
|
|
510
|
+
* unless the window covers `now`.
|
|
511
|
+
*
|
|
512
|
+
* @param {object} args
|
|
513
|
+
* @param {Uint8Array} args.staple DER OCSPResponse, exactly as the peer stapled it
|
|
514
|
+
* @param {import('./x509.js').Certificate} args.leaf the validated leaf the staple must cover
|
|
515
|
+
* @param {OcspIssuer} args.issuer the leaf's issuer, from the validated path
|
|
516
|
+
* @param {number} args.now epoch ms, injected — never a wall clock read here
|
|
517
|
+
* @returns {Promise<OcspVerdict>} every failure throws a typed CertificateError (OCSP_* codes)
|
|
518
|
+
*/
|
|
519
|
+
export async function verifyOcspStaple({ staple, leaf, issuer, now }) {
|
|
520
|
+
const parsed = parseOcspResponse(staple);
|
|
521
|
+
const serialHex = leaf.serialNumber;
|
|
522
|
+
if (parsed.responseStatus !== 0) {
|
|
523
|
+
// tryLater and friends are unsigned refusals. As a staple they prove nothing about the
|
|
524
|
+
// certificate — and an attacker holding a revoked certificate would love them to count as
|
|
525
|
+
// "checked". A server that staples one has stapled nothing of value, and a staple that IS
|
|
526
|
+
// present must be valid (see the policy in trust/index.js).
|
|
527
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
528
|
+
`stapled OCSPResponse has responseStatus ${parsed.responseStatusName}` +
|
|
529
|
+
`(${parsed.responseStatus}); only successful(0) carries a signed certificate status, so ` +
|
|
530
|
+
`it cannot vouch for serial 0x${serialHex}`,
|
|
531
|
+
{ responseStatus: parsed.responseStatus, serial: serialHex });
|
|
532
|
+
}
|
|
533
|
+
const basic = /** @type {OcspBasicResponse} */ (parsed.basic);
|
|
534
|
+
|
|
535
|
+
// --- 1. CertID: is this response about the certificate in hand? -----------------------------
|
|
536
|
+
// The hashes are recomputed from OUR validated material: the leaf's issuer Name exactly as the
|
|
537
|
+
// leaf encodes it (RFC 6960 s4.1.1 hashes the issuer field of the certificate being checked)
|
|
538
|
+
// and the issuer's public key bit-string content from the validated path. Nothing the response
|
|
539
|
+
// asserts about identity is taken at its word.
|
|
540
|
+
const issuerKeyBytes = parseSubjectPublicKeyInfo(issuer.spkiDer).keyBytes;
|
|
541
|
+
/** @type {Map<string, { nameHash: Uint8Array, keyHash: Uint8Array }>} */
|
|
542
|
+
const digestCache = new Map();
|
|
543
|
+
const hashesFor = async (name) => {
|
|
544
|
+
let entry = digestCache.get(name);
|
|
545
|
+
if (!entry) {
|
|
546
|
+
entry = {
|
|
547
|
+
nameHash: new Uint8Array(await crypto.subtle.digest(name, leaf.issuer.bytes)),
|
|
548
|
+
keyHash: new Uint8Array(await crypto.subtle.digest(name, issuerKeyBytes)),
|
|
549
|
+
};
|
|
550
|
+
digestCache.set(name, entry);
|
|
551
|
+
}
|
|
552
|
+
return entry;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
let single = null;
|
|
556
|
+
const covered = [];
|
|
557
|
+
for (const s of basic.singles) {
|
|
558
|
+
const alg = CERTID_HASH[s.certId.hashOid];
|
|
559
|
+
if (!alg) {
|
|
560
|
+
throw ocspError(codes.OCSP_PARSE,
|
|
561
|
+
`stapled OCSP response uses CertID hash algorithm ${s.certId.hashOid}, which is not ` +
|
|
562
|
+
'supported (SHA-1, SHA-256, SHA-384 and SHA-512 are)',
|
|
563
|
+
{ oid: s.certId.hashOid });
|
|
564
|
+
}
|
|
565
|
+
if (s.certId.issuerNameHash.byteLength !== alg.length ||
|
|
566
|
+
s.certId.issuerKeyHash.byteLength !== alg.length) {
|
|
567
|
+
throw ocspError(codes.OCSP_PARSE,
|
|
568
|
+
`stapled OCSP response CertID declares ${alg.name} but carries ` +
|
|
569
|
+
`${s.certId.issuerNameHash.byteLength}/${s.certId.issuerKeyHash.byteLength}-byte hashes`,
|
|
570
|
+
{ oid: s.certId.hashOid });
|
|
571
|
+
}
|
|
572
|
+
covered.push(`serial 0x${toHex(s.certId.serialBytes)} (${alg.name})`);
|
|
573
|
+
if (toHex(s.certId.serialBytes) !== serialHex) continue;
|
|
574
|
+
const { nameHash, keyHash } = await hashesFor(alg.name);
|
|
575
|
+
if (equal(nameHash, s.certId.issuerNameHash) && equal(keyHash, s.certId.issuerKeyHash)) {
|
|
576
|
+
single = s;
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (single === null) {
|
|
581
|
+
throw ocspError(codes.OCSP_MISMATCH,
|
|
582
|
+
`stapled OCSP response does not cover certificate serial 0x${serialHex} issued by ` +
|
|
583
|
+
`"${issuer.subjectText}"; it covers ${covered.length ? covered.join(', ') : 'no certificates'}. ` +
|
|
584
|
+
'A response for a different certificate proves nothing about this one',
|
|
585
|
+
{ serial: serialHex, covered });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- 2. Who signed, and are they allowed to? ------------------------------------------------
|
|
589
|
+
const sha1 = async (b) => new Uint8Array(await crypto.subtle.digest('SHA-1', b));
|
|
590
|
+
const rid = basic.responderId;
|
|
591
|
+
const signedByIssuer = rid.kind === 'name'
|
|
592
|
+
? equal(rid.nameBytes, issuer.subjectBytes)
|
|
593
|
+
: equal(rid.keyHash, await sha1(issuerKeyBytes));
|
|
594
|
+
|
|
595
|
+
let signerSpkiDer;
|
|
596
|
+
let signerText;
|
|
597
|
+
let delegated = false;
|
|
598
|
+
if (signedByIssuer) {
|
|
599
|
+
signerSpkiDer = issuer.spkiDer;
|
|
600
|
+
signerText = issuer.subjectText;
|
|
601
|
+
} else {
|
|
602
|
+
// RFC 6960 s4.2.2.2 (Authorized Responders): a responder other than the CA must present a
|
|
603
|
+
// certificate that the CA that issued the leaf issued DIRECTLY, carrying id-kp-OCSPSigning.
|
|
604
|
+
// Each requirement below closes a specific hole, named where it is enforced.
|
|
605
|
+
let responder = null;
|
|
606
|
+
for (const cert of basic.certs) {
|
|
607
|
+
const match = rid.kind === 'name'
|
|
608
|
+
? equal(cert.subject.bytes, rid.nameBytes)
|
|
609
|
+
: equal(await sha1(cert.spki.keyBytes), rid.keyHash);
|
|
610
|
+
if (match) {
|
|
611
|
+
responder = cert;
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (responder === null) {
|
|
616
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
617
|
+
`stapled OCSP response is signed by ${describeResponderId(rid)}, which is neither the ` +
|
|
618
|
+
`issuing CA "${issuer.subjectText}" nor any certificate attached to the response; ` +
|
|
619
|
+
'there is no key to verify it against',
|
|
620
|
+
{ serial: serialHex });
|
|
621
|
+
}
|
|
622
|
+
const subject = responder.subject.text;
|
|
623
|
+
if (!equal(responder.issuer.bytes, issuer.subjectBytes)) {
|
|
624
|
+
// Without direct issuance, any CA anywhere could bless a "responder" for this CA's
|
|
625
|
+
// certificates. The trust in a delegated responder flows from exactly one place: the CA
|
|
626
|
+
// whose certificate is being checked.
|
|
627
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
628
|
+
`OCSP responder certificate "${subject}" was issued by "${responder.issuer.text}", not ` +
|
|
629
|
+
`by "${issuer.subjectText}" which issued the certificate being checked; RFC 6960 ` +
|
|
630
|
+
's4.2.2.2 requires direct issuance',
|
|
631
|
+
{ responder: subject });
|
|
632
|
+
}
|
|
633
|
+
// The EKU must name id-kp-OCSPSigning explicitly; anyExtendedKeyUsage does not count here.
|
|
634
|
+
// Without this check, every ordinary TLS certificate the CA issued could sign "good"
|
|
635
|
+
// responses for every other — key compromise of any customer would defeat revocation itself.
|
|
636
|
+
if (!responder.extendedKeyUsage || !responder.extendedKeyUsage.includes(OID.ocspSigning)) {
|
|
637
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
638
|
+
`OCSP responder certificate "${subject}" does not carry the id-kp-OCSPSigning extended ` +
|
|
639
|
+
`key usage (has: ${responder.extendedKeyUsage?.join(', ') ?? 'no EKU'}); a certificate ` +
|
|
640
|
+
'the CA did not designate for OCSP signing cannot answer for its revocations',
|
|
641
|
+
{ responder: subject, eku: responder.extendedKeyUsage ? [...responder.extendedKeyUsage] : null });
|
|
642
|
+
}
|
|
643
|
+
if (now < responder.notBefore || now > responder.notAfter) {
|
|
644
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
645
|
+
`OCSP responder certificate "${subject}" is not valid at ${iso(now)} ` +
|
|
646
|
+
`(validity ${iso(responder.notBefore)} .. ${iso(responder.notAfter)})`,
|
|
647
|
+
{ responder: subject, notBefore: responder.notBefore, notAfter: responder.notAfter });
|
|
648
|
+
}
|
|
649
|
+
if (responder.keyUsage && !responder.keyUsage.digitalSignature) {
|
|
650
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
651
|
+
`OCSP responder certificate "${subject}" has a keyUsage without digitalSignature, ` +
|
|
652
|
+
'which signing a response requires',
|
|
653
|
+
{ responder: subject });
|
|
654
|
+
}
|
|
655
|
+
// id-pkix-ocsp-nocheck asks relying parties not to check the responder certificate's own
|
|
656
|
+
// revocation status — which is already this module's behaviour (there is no recursion to
|
|
657
|
+
// suppress), so the extension is recognized rather than fatal even when marked critical.
|
|
658
|
+
const unknownCritical = responder.unknownCriticalExtensions.filter((o) => o !== OID.ocspNocheck);
|
|
659
|
+
if (unknownCritical.length > 0) {
|
|
660
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
661
|
+
`OCSP responder certificate "${subject}" carries unrecognized critical extension(s) ` +
|
|
662
|
+
`${unknownCritical.join(', ')}`,
|
|
663
|
+
{ responder: subject, oids: unknownCritical });
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
await verifySignedObject(responder, issuer.spkiDer, issuer.subjectText);
|
|
667
|
+
} catch (e) {
|
|
668
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
669
|
+
`OCSP responder certificate "${subject}" does not verify under the CA key: ` +
|
|
670
|
+
`${e?.message ?? e}`,
|
|
671
|
+
{ responder: subject, cause: e?.code });
|
|
672
|
+
}
|
|
673
|
+
signerSpkiDer = responder.spki.spkiDer;
|
|
674
|
+
signerText = `${subject} (delegated OCSP responder)`;
|
|
675
|
+
delegated = true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// --- 3. The signature itself, over the peer's exact tbsResponseData bytes -------------------
|
|
679
|
+
// This is the moment the staple stops being attacker-controlled bytes: everything matched and
|
|
680
|
+
// selected above is only believed because this signature covers it. verifySignedObject is the
|
|
681
|
+
// same verifier certificates go through, so weak algorithms die by OID before any cryptography
|
|
682
|
+
// runs, and ECDSA/PSS handling cannot drift from the path validator's.
|
|
683
|
+
try {
|
|
684
|
+
await verifySignedObject(
|
|
685
|
+
{
|
|
686
|
+
tbsBytes: basic.tbsBytes,
|
|
687
|
+
signature: basic.signature,
|
|
688
|
+
signatureAlgorithm: basic.signatureAlgorithm,
|
|
689
|
+
subject: { text: 'the stapled OCSP response' },
|
|
690
|
+
},
|
|
691
|
+
signerSpkiDer,
|
|
692
|
+
signerText,
|
|
693
|
+
);
|
|
694
|
+
} catch (e) {
|
|
695
|
+
throw ocspError(codes.OCSP_UNVERIFIED,
|
|
696
|
+
`stapled OCSP response for serial 0x${serialHex} cannot be trusted: ${e?.message ?? e}`,
|
|
697
|
+
{ serial: serialHex, cause: e?.code });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// --- 4. The verdict — revoked and unknown first, so neither can hide behind staleness -------
|
|
701
|
+
if (single.status.kind === 'revoked') {
|
|
702
|
+
const reason = single.status.reason;
|
|
703
|
+
const reasonText = reason === null
|
|
704
|
+
? 'no reason given'
|
|
705
|
+
: `reason ${CRL_REASON_NAME[reason] ?? 'unrecognized'}(${reason})`;
|
|
706
|
+
// Age does not soften this one: a CA once said "revoked" about this serial, and revocations
|
|
707
|
+
// effectively never un-happen (certificateHold aside, and failing closed on a hold is the
|
|
708
|
+
// right side to be wrong on).
|
|
709
|
+
throw ocspError(codes.OCSP_REVOKED,
|
|
710
|
+
`OCSP: certificate serial 0x${serialHex} ("${leaf.subject.text}") is revoked, ` +
|
|
711
|
+
`${reasonText}, since ${iso(single.status.revocationTime)}`,
|
|
712
|
+
{
|
|
713
|
+
serial: serialHex,
|
|
714
|
+
subject: leaf.subject.text,
|
|
715
|
+
revocationTime: single.status.revocationTime,
|
|
716
|
+
reason,
|
|
717
|
+
reasonName: reason === null ? null : CRL_REASON_NAME[reason] ?? null,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
if (single.status.kind === 'unknown') {
|
|
721
|
+
throw ocspError(codes.OCSP_UNKNOWN,
|
|
722
|
+
`OCSP: the responder for "${issuer.subjectText}" does not know certificate serial ` +
|
|
723
|
+
`0x${serialHex}; a serial the CA's own responder cannot vouch for is not treated as good`,
|
|
724
|
+
{ serial: serialHex });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// --- 5. Freshness of the `good`, against the injected clock ---------------------------------
|
|
728
|
+
if (single.thisUpdate > now + CLOCK_SKEW_MS) {
|
|
729
|
+
throw ocspError(codes.OCSP_STALE,
|
|
730
|
+
`stapled OCSP response for serial 0x${serialHex} is from the future: thisUpdate ` +
|
|
731
|
+
`${iso(single.thisUpdate)} vs now ${iso(now)} (skew allowance ${CLOCK_SKEW_MS / 1000}s)`,
|
|
732
|
+
{ serial: serialHex, thisUpdate: single.thisUpdate, now });
|
|
733
|
+
}
|
|
734
|
+
if (single.nextUpdate !== null) {
|
|
735
|
+
if (now > single.nextUpdate + CLOCK_SKEW_MS) {
|
|
736
|
+
// An expired "good" is a replayable one: without this check, one captured response would
|
|
737
|
+
// vouch for a certificate forever, which is precisely what revocation exists to end.
|
|
738
|
+
throw ocspError(codes.OCSP_STALE,
|
|
739
|
+
`stapled OCSP response for serial 0x${serialHex} expired: nextUpdate ` +
|
|
740
|
+
`${iso(single.nextUpdate)} vs now ${iso(now)} (window ${iso(single.thisUpdate)} .. ` +
|
|
741
|
+
`${iso(single.nextUpdate)})`,
|
|
742
|
+
{ serial: serialHex, thisUpdate: single.thisUpdate, nextUpdate: single.nextUpdate, now });
|
|
743
|
+
}
|
|
744
|
+
} else if (now - single.thisUpdate > MAX_AGE_WITHOUT_NEXT_UPDATE_MS) {
|
|
745
|
+
throw ocspError(codes.OCSP_STALE,
|
|
746
|
+
`stapled OCSP response for serial 0x${serialHex} has no nextUpdate and its thisUpdate ` +
|
|
747
|
+
`${iso(single.thisUpdate)} is older than the ${MAX_AGE_WITHOUT_NEXT_UPDATE_MS / 86400000}-day ` +
|
|
748
|
+
`cap at now ${iso(now)}`,
|
|
749
|
+
{ serial: serialHex, thisUpdate: single.thisUpdate, now });
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
status: 'good',
|
|
754
|
+
producedAt: basic.producedAt,
|
|
755
|
+
thisUpdate: single.thisUpdate,
|
|
756
|
+
nextUpdate: single.nextUpdate,
|
|
757
|
+
delegated,
|
|
758
|
+
};
|
|
759
|
+
}
|