@blamejs/pki 0.1.10 → 0.1.12
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/CHANGELOG.md +18 -0
- package/README.md +59 -1
- package/lib/asn1-der.js +15 -0
- package/lib/framework-error.js +5 -0
- package/lib/oid.js +27 -2
- package/lib/schema-all.js +23 -1
- package/lib/schema-engine.js +6 -0
- package/lib/schema-ocsp.js +571 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v0.1.12 — 2026-07-05
|
|
8
|
+
|
|
9
|
+
SLH-DSA object identifiers corrected and completed to all twelve FIPS 205 parameter sets.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- SLH-DSA OID resolution — id-slh-dsa-shake-128s and id-slh-dsa-shake-256s were mapped to the arcs of id-slh-dsa-sha2-256s (.24) and id-slh-dsa-shake-128f (.27), so pki.oid.name / pki.oid.byName resolved them incorrectly. All twelve Pure SLH-DSA parameter sets (sha2-128s/128f/192s/192f/256s/256f and shake-128s/128f/192s/192f/256s/256f) are now registered at their correct arcs .20 through .31 per RFC 9909 §3; the previously-absent nine sets now resolve as well. WebCrypto SLH-DSA sign/verify was unaffected — it selects by algorithm name, not through the OID registry.
|
|
14
|
+
|
|
15
|
+
## v0.1.11 — 2026-07-05
|
|
16
|
+
|
|
17
|
+
An OCSP request and response parser joins the pki.schema family.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- pki.schema.ocsp.parseRequest and pki.schema.ocsp.parseResponse — an OCSP request / response parser per RFC 6960 (§4.1 OCSPRequest, §4.2 OCSPResponse). parseRequest turns a DER Buffer or an 'OCSP REQUEST' PEM string into { tbsRequestBytes, version, requestorName, requestList, requestExtensions, optionalSignature }, each requestList entry carrying its CertID with the two issuer hashes raw. parseResponse turns a DER Buffer or an 'OCSP RESPONSE' PEM string into { responseStatus, responseBytes, basicResponse }; for a successful basic response, basicResponse carries { tbsResponseDataBytes, responderID, producedAt, responses, signatureAlgorithm, signature, certs } and each responses[i].certStatus is { type: 'good' | 'revoked' | 'unknown' } (a revoked entry adds its revocationTime and revocationReason). A malformed structure throws a typed OcspError (ocsp/*), an unsupported responseType throws ocsp/unsupported-response-type, and a leaf-level codec fault surfaces as asn1/*. pki.schema.ocsp.pemDecode handles the PEM envelope, and pki.schema.parse detect-and-routes both OCSP shapes.
|
|
22
|
+
- pki.asn1.read.nullImplicit and the pki.schema.engine.implicitNull(tag) leaf — read a context-tagged IMPLICIT NULL (the shape the OCSP CertStatus good [0] and unknown [2] arms take), the primitive-leaf sibling of implicitBitString and implicitOctetString. It rejects a constructed or non-empty context node fail-closed.
|
|
23
|
+
- An ocsp-parse coverage-guided fuzz harness joins the CI fuzzing matrix (jazzer.js + libFuzzer, per pull request and nightly), driving both OCSP entry points on mutated input.
|
|
24
|
+
|
|
7
25
|
## v0.1.10 — 2026-07-05
|
|
8
26
|
|
|
9
27
|
A CMS SignedData parser joins the pki.schema family; coverage-guided fuzzing now runs in CI.
|
package/README.md
CHANGED
|
@@ -196,9 +196,10 @@ is callable today; nothing below is a stub.
|
|
|
196
196
|
| `pki.schema.csr` | Parse DER / PEM PKCS#10 certification requests per RFC 2986 — subject DN, public key, requested attributes, signature, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
197
197
|
| `pki.schema.pkcs8` | Parse DER / PEM PKCS#8 private keys per RFC 5208 / 5958 — algorithm, raw key bytes, attributes, optional public key, fail-closed; encrypted keys recognized (not decrypted) — `parse`, `parseEncrypted`, `pemDecode`, `pemEncode` |
|
|
198
198
|
| `pki.schema.cms` | Parse DER / PEM CMS SignedData per RFC 5652 — encapsulated content, signer infos, raw signed-attribute bytes for external verification, certificates / CRLs kept raw, fail-closed; non-SignedData content types recognized (not decoded) — `parse`, `pemDecode`, `pemEncode` |
|
|
199
|
+
| `pki.schema.ocsp` | Parse DER / PEM OCSP requests and responses per RFC 6960 — per-certificate status (good / revoked / unknown), responder identity, raw tbs bytes for external verification, certificates kept raw, fail-closed; non-basic response types recognized (not decoded) — `parseRequest`, `parseResponse`, `pemDecode` |
|
|
199
200
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` plus the schema combinators |
|
|
200
201
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
201
|
-
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError`, each carrying a stable `code` in `domain/reason` form |
|
|
202
|
+
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError`, each carrying a stable `code` in `domain/reason` form |
|
|
202
203
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
|
|
203
204
|
|
|
204
205
|
### CLI
|
|
@@ -220,6 +221,63 @@ composites) are on the roadmap and ride this same core. See
|
|
|
220
221
|
[ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
|
|
221
222
|
[CHANGELOG.md](CHANGELOG.md) for what has landed.
|
|
222
223
|
|
|
224
|
+
## Architecture
|
|
225
|
+
|
|
226
|
+
Every PKI format is a thin, declarative schema over one shared engine. A parser
|
|
227
|
+
declares the ASN.1 structure as data and hands it to `walk`; it never advances a
|
|
228
|
+
child cursor, re-checks a tag, or re-rolls PEM handling by hand. So each
|
|
229
|
+
structural rule — bounds-checked positional reads, optional / context-tagged
|
|
230
|
+
field ordering, SET-OF ascending-order and uniqueness, arity, and fail-closed
|
|
231
|
+
typed errors — is written **once** in the engine, and no new format can
|
|
232
|
+
reintroduce the bug class it prevents. Adding a format is a schema declaration
|
|
233
|
+
plus a documentation comment block, not new parse logic.
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
Detect + route pki.schema.parse ── detects the format, routes to a sibling
|
|
237
|
+
│
|
|
238
|
+
Format parsers x509 crl csr pkcs8 cms ocsp (siblings)
|
|
239
|
+
(one schema each) └──────┴─────┴──────┴──────┴─────┘
|
|
240
|
+
│ every sibling COMPOSES ↓
|
|
241
|
+
Shared structure pki.schema.engine PKIX sub-schemas
|
|
242
|
+
walk + combinators · AlgorithmIdentifier · Name ·
|
|
243
|
+
(positional reads, Extension · version reader ·
|
|
244
|
+
tag ordering, SET-OF the one coerce → decode → walk
|
|
245
|
+
uniqueness, arity, parse-entry (PEM cap + DER
|
|
246
|
+
fail-closed errors) wrapping shared, never copied)
|
|
247
|
+
│ built on ↓
|
|
248
|
+
Foundation pki.asn1 pki.oid pki.errors pki.C
|
|
249
|
+
DER codec · OID registry · PkiError · constants
|
|
250
|
+
(strict, (two-way, taxonomy (LIMITS,
|
|
251
|
+
bounded) PQC-seeded) (domain/reason) scale)
|
|
252
|
+
|
|
253
|
+
Crypto pki.webcrypto ──▶ node:crypto
|
|
254
|
+
W3C SubtleCrypto over the platform: the classical set +
|
|
255
|
+
post-quantum ML-DSA / SLH-DSA signatures + ML-KEM keygen
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Foundation.** The strict, bounded DER codec (`pki.asn1`), the two-way OID
|
|
259
|
+
registry (`pki.oid`), the `PkiError` taxonomy (`pki.errors`), and the
|
|
260
|
+
version-stable constants (`pki.C`). These have no PKI knowledge; they are the
|
|
261
|
+
bytes-and-names layer everything else stands on.
|
|
262
|
+
|
|
263
|
+
**Shared structure.** The declarative schema engine (`pki.schema.engine`) and the
|
|
264
|
+
PKIX sub-schemas it is fed — `AlgorithmIdentifier`, `Name`, `Extension`, the
|
|
265
|
+
bounded version reader, and the single coerce → decode → walk parse-entry that
|
|
266
|
+
every format's `parse` is bound to. Because input coercion, the PEM size cap, and
|
|
267
|
+
the DER-decode wrapping live here once, a format cannot diverge on a guard.
|
|
268
|
+
|
|
269
|
+
**Format parsers.** `x509`, `crl`, `csr`, `pkcs8`, `cms`, and `ocsp` are siblings:
|
|
270
|
+
each is a schema declaration composed from the shared pieces, emitting its own
|
|
271
|
+
typed `domain/reason` error codes. `pki.schema.parse` inspects a decoded root and
|
|
272
|
+
detect-and-routes to the matching sibling; the detectors are mutually exclusive by
|
|
273
|
+
construction, so routing is unambiguous regardless of registration order.
|
|
274
|
+
|
|
275
|
+
**Crypto.** `pki.webcrypto` is a W3C `SubtleCrypto` engine over `node:crypto`,
|
|
276
|
+
carrying the classical suite plus post-quantum ML-DSA / SLH-DSA signatures and
|
|
277
|
+
ML-KEM key generation. Sign/verify resolves algorithms through the same OID
|
|
278
|
+
registry the parsers read, so the signing surface and the parsing surface share
|
|
279
|
+
one algorithm vocabulary.
|
|
280
|
+
|
|
223
281
|
## Security posture
|
|
224
282
|
|
|
225
283
|
- **Zero npm runtime dependencies, nothing vendored.** The cryptography runs on
|
package/lib/asn1-der.js
CHANGED
|
@@ -381,6 +381,20 @@ function readNull(node) {
|
|
|
381
381
|
return null;
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
// Read a [tag] IMPLICIT NULL — a context-class PRIMITIVE node with empty content.
|
|
385
|
+
// The IMPLICIT tag replaces the universal NULL tag, so there is no inner universal
|
|
386
|
+
// node. The empty-content and primitive-form rules of a universal NULL still hold.
|
|
387
|
+
// Used for the OCSP CertStatus good [0] / unknown [2] arms (RFC 6960 §4.2.1).
|
|
388
|
+
function readNullImplicit(node, tag) {
|
|
389
|
+
if (node.tagClass !== "context" || node.tagNumber !== tag) {
|
|
390
|
+
throw new Asn1Error("asn1/unexpected-tag", "readNullImplicit: expected context tag [" + tag +
|
|
391
|
+
"], got " + node.tagClass + "/" + node.tagNumber);
|
|
392
|
+
}
|
|
393
|
+
_expectPrimitive(node, "readNullImplicit");
|
|
394
|
+
if (node.content.length !== 0) throw new Asn1Error("asn1/bad-null", "IMPLICIT NULL must have empty content");
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
384
398
|
/**
|
|
385
399
|
* @primitive pki.asn1.readOid
|
|
386
400
|
* @signature pki.asn1.read.oid(node) -> "1.2.840.113549.1.1.11"
|
|
@@ -810,6 +824,7 @@ module.exports = {
|
|
|
810
824
|
octetString: readOctetString,
|
|
811
825
|
octetStringImplicit: readOctetStringImplicit,
|
|
812
826
|
nullValue: readNull,
|
|
827
|
+
nullImplicit: readNullImplicit,
|
|
813
828
|
oid: readOid,
|
|
814
829
|
string: readString,
|
|
815
830
|
time: readTime,
|
package/lib/framework-error.js
CHANGED
|
@@ -141,6 +141,10 @@ var Pkcs8Error = defineClass("Pkcs8Error", { withCause: true });
|
|
|
141
141
|
// SignedData (RFC 5652). Carries the underlying leaf fault as `.cause`.
|
|
142
142
|
var CmsError = defineClass("CmsError", { withCause: true });
|
|
143
143
|
|
|
144
|
+
// OcspError — a byte sequence that is not a well-formed OCSP request or response
|
|
145
|
+
// (OCSPRequest / OCSPResponse — RFC 6960 §4). Carries the leaf fault as `.cause`.
|
|
146
|
+
var OcspError = defineClass("OcspError", { withCause: true });
|
|
147
|
+
|
|
144
148
|
module.exports = {
|
|
145
149
|
PkiError: PkiError,
|
|
146
150
|
defineClass: defineClass,
|
|
@@ -154,4 +158,5 @@ module.exports = {
|
|
|
154
158
|
CsrError: CsrError,
|
|
155
159
|
Pkcs8Error: Pkcs8Error,
|
|
156
160
|
CmsError: CmsError,
|
|
161
|
+
OcspError: OcspError,
|
|
157
162
|
};
|
package/lib/oid.js
CHANGED
|
@@ -65,6 +65,20 @@ var FAMILIES = {
|
|
|
65
65
|
// PKIX private extensions (authorityInfoAccess et al).
|
|
66
66
|
pkixAccess: { base: [1, 3, 6, 1, 5, 5, 7, 1], of: { authorityInfoAccess: 1 } },
|
|
67
67
|
|
|
68
|
+
// PKIX Access Descriptor methods (id-ad, RFC 5280 §4.2.2.1/§4.2.2.2). id-ad-ocsp
|
|
69
|
+
// is the arc the OCSP responder OIDs hang under; id-ad-caIssuers names the AIA
|
|
70
|
+
// CA-issuers access method.
|
|
71
|
+
adAccess: { base: [1, 3, 6, 1, 5, 5, 7, 48], of: { ocsp: 1, caIssuers: 2 } },
|
|
72
|
+
|
|
73
|
+
// OCSP (RFC 6960) on the id-pkix-ocsp arc (= id-ad-ocsp). id-pkix-ocsp-basic is
|
|
74
|
+
// the ResponseBytes.responseType this build decodes; id-pkix-ocsp-nonce (§4.4.1)
|
|
75
|
+
// names the nonce extension; the remaining members name the other OCSP extensions
|
|
76
|
+
// (CRL references, acceptable-response-types, archive-cutoff, service-locator,
|
|
77
|
+
// preferred-signature-algorithms, extended-revoke — RFC 6960 §4.4, RFC 9654).
|
|
78
|
+
ocsp: { base: [1, 3, 6, 1, 5, 5, 7, 48, 1], of: {
|
|
79
|
+
ocspBasic: 1, ocspNonce: 2, ocspCrl: 3, ocspResponse: 4, ocspNoCheck: 5,
|
|
80
|
+
ocspArchiveCutoff: 6, ocspServiceLocator: 7, ocspPrefSigAlgs: 8, ocspExtendedRevoke: 9 } },
|
|
81
|
+
|
|
68
82
|
// PKCS#1 RSA public-key + RSASSA signature algorithms.
|
|
69
83
|
rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
|
|
70
84
|
rsaEncryption: 1, rsassaPss: 10, sha256WithRSAEncryption: 11,
|
|
@@ -95,15 +109,26 @@ var FAMILIES = {
|
|
|
95
109
|
// Edwards / Montgomery curves (RFC 8410).
|
|
96
110
|
edwards: { base: [1, 3, 101], of: { X25519: 110, X448: 111, Ed25519: 112, Ed448: 113 } },
|
|
97
111
|
|
|
112
|
+
// OIW Secsig SHA-1 (1.3.14.3.2.26) — the most common OCSP CertID hashAlgorithm;
|
|
113
|
+
// nistHash covers only SHA-256 and above.
|
|
114
|
+
oiwSecsig: { base: [1, 3, 14, 3, 2], of: { sha1: 26 } },
|
|
115
|
+
|
|
98
116
|
// NIST hash functions (SHA-2, SHA-3, SHAKE).
|
|
99
117
|
nistHash: { base: [2, 16, 840, 1, 101, 3, 4, 2], of: {
|
|
100
118
|
sha256: 1, sha384: 2, sha512: 3, "sha3-256": 8, "sha3-512": 10, shake256: 12 } },
|
|
101
119
|
|
|
102
120
|
// NIST signature algorithms — FIPS 204 ML-DSA + FIPS 205 SLH-DSA share the
|
|
103
|
-
// signature arc 2.16.840.1.101.3.4.3.
|
|
121
|
+
// signature arc 2.16.840.1.101.3.4.3. The 12 Pure SLH-DSA parameter sets are
|
|
122
|
+
// assigned sequentially (RFC 9909 §3): the SHA-2 sets .20-.25, then the SHAKE
|
|
123
|
+
// sets .26-.31.
|
|
104
124
|
nistSig: { base: [2, 16, 840, 1, 101, 3, 4, 3], of: {
|
|
105
125
|
"id-ml-dsa-44": 17, "id-ml-dsa-65": 18, "id-ml-dsa-87": 19,
|
|
106
|
-
"id-slh-dsa-sha2-128s": 20, "id-slh-dsa-
|
|
126
|
+
"id-slh-dsa-sha2-128s": 20, "id-slh-dsa-sha2-128f": 21,
|
|
127
|
+
"id-slh-dsa-sha2-192s": 22, "id-slh-dsa-sha2-192f": 23,
|
|
128
|
+
"id-slh-dsa-sha2-256s": 24, "id-slh-dsa-sha2-256f": 25,
|
|
129
|
+
"id-slh-dsa-shake-128s": 26, "id-slh-dsa-shake-128f": 27,
|
|
130
|
+
"id-slh-dsa-shake-192s": 28, "id-slh-dsa-shake-192f": 29,
|
|
131
|
+
"id-slh-dsa-shake-256s": 30, "id-slh-dsa-shake-256f": 31 } },
|
|
107
132
|
|
|
108
133
|
// NIST KEM — FIPS 203 ML-KEM (arc 2.16.840.1.101.3.4.4).
|
|
109
134
|
nistKem: { base: [2, 16, 840, 1, 101, 3, 4, 4], of: {
|
package/lib/schema-all.js
CHANGED
|
@@ -37,6 +37,7 @@ var crl = require("./schema-crl");
|
|
|
37
37
|
var csr = require("./schema-csr");
|
|
38
38
|
var pkcs8 = require("./schema-pkcs8");
|
|
39
39
|
var cms = require("./schema-cms");
|
|
40
|
+
var ocsp = require("./schema-ocsp");
|
|
40
41
|
var frameworkError = require("./framework-error");
|
|
41
42
|
|
|
42
43
|
var SchemaError = frameworkError.SchemaError;
|
|
@@ -65,6 +66,26 @@ var FORMATS = [
|
|
|
65
66
|
detect: cms.matches,
|
|
66
67
|
parse: function (input) { return cms.parse(input); },
|
|
67
68
|
},
|
|
69
|
+
{
|
|
70
|
+
// OCSPRequest ::= SEQUENCE { tbsRequest SEQUENCE, optionalSignature [0] EXPLICIT
|
|
71
|
+
// OPTIONAL } — a SEQUENCE of 1-2 whose first child is the tbsRequest SEQUENCE.
|
|
72
|
+
// Leads with a SEQUENCE like the signed-envelope trio, but is excluded by arity
|
|
73
|
+
// (the trio is EXACTLY 3 children; an OCSPRequest is 1-2), so it detects
|
|
74
|
+
// unambiguously regardless of registry order (RFC 6960 §4.1.1).
|
|
75
|
+
name: "ocsp-request",
|
|
76
|
+
module: ocsp,
|
|
77
|
+
detect: ocsp.matchesRequest,
|
|
78
|
+
parse: function (input) { return ocsp.parseRequest(input); },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
// OCSPResponse ::= SEQUENCE { responseStatus ENUMERATED, responseBytes [0]
|
|
82
|
+
// EXPLICIT OPTIONAL } — the only registered root that leads with an ENUMERATED
|
|
83
|
+
// child, so it is disjoint from every other format (RFC 6960 §4.2.1).
|
|
84
|
+
name: "ocsp-response",
|
|
85
|
+
module: ocsp,
|
|
86
|
+
detect: ocsp.matchesResponse,
|
|
87
|
+
parse: function (input) { return ocsp.parseResponse(input); },
|
|
88
|
+
},
|
|
68
89
|
{
|
|
69
90
|
// PKCS#8 PrivateKeyInfo / OneAsymmetricKey — SEQUENCE whose first child is an
|
|
70
91
|
// INTEGER (version) and third an OCTET STRING (privateKey); disjoint from the
|
|
@@ -120,7 +141,7 @@ var FORMATS = [
|
|
|
120
141
|
* The names of every registered format, in detection order.
|
|
121
142
|
*
|
|
122
143
|
* @example
|
|
123
|
-
* pki.schema.all(); // → ["cms", "pkcs8", "csr", "crl", "x509"]
|
|
144
|
+
* pki.schema.all(); // → ["cms", "ocsp-request", "ocsp-response", "pkcs8", "csr", "crl", "x509"]
|
|
124
145
|
*/
|
|
125
146
|
function all() { return FORMATS.map(function (f) { return f.name; }); }
|
|
126
147
|
|
|
@@ -163,6 +184,7 @@ module.exports = {
|
|
|
163
184
|
csr: { parse: csr.parse, pemDecode: csr.pemDecode, pemEncode: csr.pemEncode },
|
|
164
185
|
pkcs8: { parse: pkcs8.parse, parseEncrypted: pkcs8.parseEncrypted, pemDecode: pkcs8.pemDecode, pemEncode: pkcs8.pemEncode },
|
|
165
186
|
cms: { parse: cms.parse, pemDecode: cms.pemDecode, pemEncode: cms.pemEncode },
|
|
187
|
+
ocsp: { parseRequest: ocsp.parseRequest, parseResponse: ocsp.parseResponse, pemDecode: ocsp.pemDecode },
|
|
166
188
|
all: all,
|
|
167
189
|
parse: parse,
|
|
168
190
|
};
|
package/lib/schema-engine.js
CHANGED
|
@@ -104,6 +104,11 @@ function implicitBitString(tag) { return { kind: "leaf", read: function (n) { va
|
|
|
104
104
|
// implicitBitString, for e.g. the CMS SignerIdentifier subjectKeyIdentifier [0]
|
|
105
105
|
// (RFC 5652 §5.3). Yields the raw content bytes.
|
|
106
106
|
function implicitOctetString(tag) { return { kind: "leaf", read: function (n) { return asn1.read.octetStringImplicit(n, tag); } }; }
|
|
107
|
+
// A [tag] IMPLICIT NULL leaf (context-class primitive, empty content) — the sibling
|
|
108
|
+
// of implicitBitString/implicitOctetString, for e.g. the OCSP CertStatus good [0] /
|
|
109
|
+
// unknown [2] arms (RFC 6960 §4.2.1). Yields null; rejects a constructed or
|
|
110
|
+
// non-empty [tag] node fail-closed.
|
|
111
|
+
function implicitNull(tag) { return { kind: "leaf", read: function (n) { return asn1.read.nullImplicit(n, tag); } }; }
|
|
107
112
|
function any() { return { kind: "any" }; }
|
|
108
113
|
function decode(fn) { return { kind: "decode", fn: fn }; }
|
|
109
114
|
|
|
@@ -376,6 +381,7 @@ module.exports = {
|
|
|
376
381
|
// leaves
|
|
377
382
|
oidLeaf: oidLeaf, integerLeaf: integerLeaf, boolean: boolean, octetString: octetString,
|
|
378
383
|
bitString: bitString, implicitBitString: implicitBitString, implicitOctetString: implicitOctetString,
|
|
384
|
+
implicitNull: implicitNull,
|
|
379
385
|
any: any, decode: decode, time: time,
|
|
380
386
|
// engine
|
|
381
387
|
walk: walk,
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.schema.ocsp
|
|
6
|
+
* @nav Schema
|
|
7
|
+
* @title OCSP
|
|
8
|
+
* @order 150
|
|
9
|
+
* @slug ocsp
|
|
10
|
+
*
|
|
11
|
+
* @intro
|
|
12
|
+
* OCSP request and response handling per RFC 6960 (§4.1 OCSPRequest, §4.2
|
|
13
|
+
* OCSPResponse). Two entry points — `parseRequest` and `parseResponse` — turn a
|
|
14
|
+
* DER or PEM message into a structured object. A response is a two-stage
|
|
15
|
+
* OID-dispatch: `OCSPResponse` carries an ENUMERATED `responseStatus` and an
|
|
16
|
+
* OPTIONAL `responseBytes`; when the `responseType` is `id-pkix-ocsp-basic` its
|
|
17
|
+
* `response` OCTET STRING content is a fresh DER `BasicOCSPResponse` that is
|
|
18
|
+
* decoded and walked, so the per-certificate statuses surface structurally.
|
|
19
|
+
*
|
|
20
|
+
* OCSP is a signed protocol: the bytes an external verifier must hash are surfaced
|
|
21
|
+
* RAW and never re-serialized. `tbsRequestBytes` and `tbsResponseDataBytes` are the
|
|
22
|
+
* exact on-wire `tbsRequest` / `ResponseData` TLVs; each `CertID` surfaces its
|
|
23
|
+
* `issuerNameHash` / `issuerKeyHash` as raw octets, and the responder's `byKey`
|
|
24
|
+
* hash and the raw `signature` bytes are left for the caller to verify. Embedded
|
|
25
|
+
* certificates are surfaced as raw DER, so an unknown alternative never fails the
|
|
26
|
+
* parse. DER-only, fail-closed.
|
|
27
|
+
*
|
|
28
|
+
* @card
|
|
29
|
+
* Parse DER / PEM OCSP requests and responses (RFC 6960) into structured,
|
|
30
|
+
* validated fields — per-certificate status, responder identity, raw tbs bytes for
|
|
31
|
+
* external verification, certificates kept raw, fail-closed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var asn1 = require("./asn1-der");
|
|
35
|
+
var schema = require("./schema-engine");
|
|
36
|
+
var pkix = require("./schema-pkix");
|
|
37
|
+
var oid = require("./oid");
|
|
38
|
+
var frameworkError = require("./framework-error");
|
|
39
|
+
|
|
40
|
+
var OcspError = frameworkError.OcspError;
|
|
41
|
+
var PemError = frameworkError.PemError;
|
|
42
|
+
|
|
43
|
+
// The ocsp error namespace the schema engine walks under.
|
|
44
|
+
var NS = pkix.makeNS("ocsp", OcspError, oid);
|
|
45
|
+
|
|
46
|
+
var ALGORITHM_IDENTIFIER = pkix.algorithmIdentifier(NS);
|
|
47
|
+
var NAME = pkix.name(NS);
|
|
48
|
+
var EXTENSIONS = pkix.extensions(NS);
|
|
49
|
+
|
|
50
|
+
// Version ::= INTEGER { v1(0) }. OCSP defines only v1(0), which DER forbids
|
|
51
|
+
// encoding (it is the DEFAULT), so ANY explicitly-encoded version is a fault; the
|
|
52
|
+
// empty accept map rejects every value and absence yields the default 1.
|
|
53
|
+
var VERSION = pkix.versionReader(NS, {});
|
|
54
|
+
|
|
55
|
+
// id-pkix-ocsp-basic is the one ResponseBytes.responseType this build decodes; any
|
|
56
|
+
// other is recognized-and-deferred. Resolved from the registry, never a literal.
|
|
57
|
+
var OID_OCSP_BASIC = oid.byName("ocspBasic");
|
|
58
|
+
|
|
59
|
+
// OCSPResponseStatus ::= ENUMERATED — value 4 is "not used" and >= 7 is undefined,
|
|
60
|
+
// so both are rejected (RFC 6960 §4.2.1).
|
|
61
|
+
var STATUS_NAMES = { "0": "successful", "1": "malformedRequest", "2": "internalError", "3": "tryLater", "5": "sigRequired", "6": "unauthorized" };
|
|
62
|
+
|
|
63
|
+
// CRLReason ::= ENUMERATED — value 7 is "not used"; the rest are RFC 5280 §5.3.1.
|
|
64
|
+
var CRL_REASONS = { "0": "unspecified", "1": "keyCompromise", "2": "cACompromise", "3": "affiliationChanged", "4": "superseded", "5": "cessationOfOperation", "6": "certificateHold", "8": "removeFromCRL", "9": "privilegeWithdrawn", "10": "aACompromise" };
|
|
65
|
+
|
|
66
|
+
// ---- shared leaves ---------------------------------------------------
|
|
67
|
+
|
|
68
|
+
// A GeneralizedTime-only leaf. OCSP times (producedAt / thisUpdate / nextUpdate /
|
|
69
|
+
// revocationTime) are GeneralizedTime, never UTCTime (RFC 6960); assert the tag
|
|
70
|
+
// before the codec reads it so a UTCTime is rejected ocsp/bad-time.
|
|
71
|
+
var GENERALIZED_TIME = schema.decode(function (n, ctx) {
|
|
72
|
+
if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.GENERALIZED_TIME) {
|
|
73
|
+
throw ctx.E("ocsp/bad-time", "OCSP time must be a GeneralizedTime (RFC 6960)");
|
|
74
|
+
}
|
|
75
|
+
return asn1.read.time(n);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// OCSPResponseStatus decode-and-whitelist leaf. read.enumerated is tag-checked, so
|
|
79
|
+
// an INTEGER-encoded status is rejected at the leaf (asn1/unexpected-tag).
|
|
80
|
+
var OCSP_RESPONSE_STATUS = schema.decode(function (n, ctx) {
|
|
81
|
+
var v = asn1.read.enumerated(n);
|
|
82
|
+
var key = v.toString();
|
|
83
|
+
if (!Object.prototype.hasOwnProperty.call(STATUS_NAMES, key)) throw ctx.E("ocsp/bad-response-status", "undefined OCSPResponseStatus " + key + " (RFC 6960 §4.2.1)");
|
|
84
|
+
return { code: Number(v), name: STATUS_NAMES[key] };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// CRLReason decode-and-whitelist leaf.
|
|
88
|
+
var CRL_REASON = schema.decode(function (n, ctx) {
|
|
89
|
+
var v = asn1.read.enumerated(n);
|
|
90
|
+
var key = v.toString();
|
|
91
|
+
if (!Object.prototype.hasOwnProperty.call(CRL_REASONS, key)) throw ctx.E("ocsp/bad-revocation-reason", "undefined CRLReason " + key + " (RFC 5280 §5.3.1)");
|
|
92
|
+
return CRL_REASONS[key];
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// A certs [0] SEQUENCE OF Certificate element. Unlike the CMS CertificateChoices
|
|
96
|
+
// (a tagged CHOICE), an OCSP certs element is a plain Certificate — a universal
|
|
97
|
+
// SEQUENCE (RFC 5280 §4.1) — so assert that shape before surfacing its raw DER, and
|
|
98
|
+
// reject a non-SEQUENCE element rather than reporting arbitrary bytes as a cert.
|
|
99
|
+
function certificateBytes() {
|
|
100
|
+
return schema.decode(function (n, ctx) {
|
|
101
|
+
if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.SEQUENCE || !n.children) {
|
|
102
|
+
throw ctx.E("ocsp/bad-certs", "each certs element must be a Certificate (a SEQUENCE) (RFC 6960 §4.1.1/§4.2.1)");
|
|
103
|
+
}
|
|
104
|
+
return n.bytes;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// requestorName [1] EXPLICIT GeneralName — GeneralName ::= CHOICE (RFC 5280
|
|
109
|
+
// §4.2.1.6). We keep the value RAW (no pkix.generalName factory yet), but validate
|
|
110
|
+
// the chosen alternative's tag AND form/content so a malformed GeneralName fails
|
|
111
|
+
// closed rather than passing the signed-request requestorName guard: the constructed
|
|
112
|
+
// alternatives (otherName [0], x400Address [3], directoryName [4], ediPartyName [5])
|
|
113
|
+
// must be constructed; rfc822Name [1] / dNSName [2] / uniformResourceIdentifier [6]
|
|
114
|
+
// are primitive IA5String (7-bit); iPAddress [7] is a 4- or 16-octet OCTET STRING;
|
|
115
|
+
// registeredID [8] is a primitive OBJECT IDENTIFIER.
|
|
116
|
+
var GN_CONSTRUCTED = { 0: 1, 3: 1, 4: 1, 5: 1 };
|
|
117
|
+
var GN_IA5 = { 1: 1, 2: 1, 6: 1 };
|
|
118
|
+
function _validateGeneralName(n, ctx) {
|
|
119
|
+
if (n.tagClass !== "context" || n.tagNumber < 0 || n.tagNumber > 8) {
|
|
120
|
+
throw ctx.E("ocsp/bad-requestor-name", "requestorName must be a GeneralName (context tag [0]..[8]) (RFC 6960 §4.1.1)");
|
|
121
|
+
}
|
|
122
|
+
var t = n.tagNumber;
|
|
123
|
+
var constructed = !!n.children;
|
|
124
|
+
if (GN_CONSTRUCTED[t]) {
|
|
125
|
+
if (!constructed || n.children.length < 1) throw ctx.E("ocsp/bad-requestor-name", "GeneralName [" + t + "] must be a non-empty constructed value (RFC 5280 §4.2.1.6)");
|
|
126
|
+
if (t === 0) {
|
|
127
|
+
// otherName ::= SEQUENCE { type-id OBJECT IDENTIFIER, value [0] EXPLICIT ANY } —
|
|
128
|
+
// both fields mandatory: exactly two children, a type-id OID and a [0] EXPLICIT
|
|
129
|
+
// value wrapper carrying exactly one inner value.
|
|
130
|
+
if (n.children.length !== 2) throw ctx.E("ocsp/bad-requestor-name", "GeneralName otherName [0] must be a SEQUENCE { type-id, value [0] } (RFC 5280 §4.2.1.6)");
|
|
131
|
+
try { asn1.read.oid(n.children[0]); }
|
|
132
|
+
catch (e) { throw ctx.E("ocsp/bad-requestor-name", "GeneralName otherName [0] must lead with a type-id OBJECT IDENTIFIER", e); }
|
|
133
|
+
var v = n.children[1];
|
|
134
|
+
if (!(v.tagClass === "context" && v.tagNumber === 0 && v.children && v.children.length === 1)) {
|
|
135
|
+
throw ctx.E("ocsp/bad-requestor-name", "GeneralName otherName [0] value must be a [0] EXPLICIT wrapper carrying exactly one value");
|
|
136
|
+
}
|
|
137
|
+
} else if (t === 4) {
|
|
138
|
+
// directoryName [4] EXPLICIT Name — validate the wrapped RDNSequence.
|
|
139
|
+
if (n.children.length !== 1) throw ctx.E("ocsp/bad-requestor-name", "GeneralName directoryName [4] must wrap exactly one Name (RFC 5280 §4.2.1.6)");
|
|
140
|
+
schema.walk(NAME, n.children[0], ctx);
|
|
141
|
+
}
|
|
142
|
+
// x400Address [3] / ediPartyName [5]: validated to non-empty constructed form
|
|
143
|
+
// (their full ORAddress / EDIPartyName bodies are above the requestorName altitude).
|
|
144
|
+
} else {
|
|
145
|
+
if (constructed) throw ctx.E("ocsp/bad-requestor-name", "GeneralName [" + t + "] must be primitive (RFC 5280 §4.2.1.6)");
|
|
146
|
+
if (GN_IA5[t]) {
|
|
147
|
+
if (n.content.length === 0) throw ctx.E("ocsp/bad-requestor-name", "GeneralName [" + t + "] must be a non-empty IA5String");
|
|
148
|
+
for (var i = 0; i < n.content.length; i++) {
|
|
149
|
+
if (n.content[i] > 0x7f) throw ctx.E("ocsp/bad-requestor-name", "GeneralName [" + t + "] must be an IA5String (7-bit)");
|
|
150
|
+
}
|
|
151
|
+
} else if (t === 7) {
|
|
152
|
+
if (n.content.length !== 4 && n.content.length !== 16) throw ctx.E("ocsp/bad-requestor-name", "GeneralName iPAddress [7] must be a 4- or 16-octet address");
|
|
153
|
+
} else if (t === 8) {
|
|
154
|
+
try { asn1.decodeOidContent(n.content); }
|
|
155
|
+
catch (e) { throw ctx.E("ocsp/bad-requestor-name", "GeneralName registeredID [8] must be a valid OBJECT IDENTIFIER", e); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { bytes: n.bytes, tagClass: n.tagClass, tagNumber: n.tagNumber };
|
|
159
|
+
}
|
|
160
|
+
var GENERAL_NAME_RAW = schema.decode(_validateGeneralName);
|
|
161
|
+
|
|
162
|
+
// An OCSP signature BIT STRING is a byte string handed to a cryptographic verifier,
|
|
163
|
+
// so it MUST be octet-aligned (0 unused bits); a non-octet-aligned signature is
|
|
164
|
+
// malformed. Shared by the request Signature and the BasicOCSPResponse.
|
|
165
|
+
function _rawSignature(field) {
|
|
166
|
+
var sig = field.value;
|
|
167
|
+
if (sig.unusedBits !== 0) throw NS.E("ocsp/bad-signature", "an OCSP signature BIT STRING must be octet-aligned (0 unused bits)");
|
|
168
|
+
return sig.bytes;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// certs [0] EXPLICIT SEQUENCE OF Certificate — each element raw. Shared by the
|
|
172
|
+
// request Signature and the BasicOCSPResponse.
|
|
173
|
+
var CERTS = schema.seqOf(certificateBytes(), { assert: "sequence", code: "ocsp/bad-certs", what: "certs" });
|
|
174
|
+
|
|
175
|
+
// ---- CertID (shared request + response) ------------------------------
|
|
176
|
+
|
|
177
|
+
// CertID ::= SEQUENCE { hashAlgorithm AlgorithmIdentifier, issuerNameHash OCTET
|
|
178
|
+
// STRING, issuerKeyHash OCTET STRING, serialNumber CertificateSerialNumber }
|
|
179
|
+
// (RFC 6960 §4.1.1). The two hashes are surfaced raw for the caller to verify.
|
|
180
|
+
var CERT_ID = schema.seq([
|
|
181
|
+
schema.field("hashAlgorithm", ALGORITHM_IDENTIFIER),
|
|
182
|
+
schema.field("issuerNameHash", schema.octetString()),
|
|
183
|
+
schema.field("issuerKeyHash", schema.octetString()),
|
|
184
|
+
schema.field("serialNumber", schema.integerLeaf()),
|
|
185
|
+
], {
|
|
186
|
+
assert: "sequence", arity: { exact: 4 }, code: "ocsp/bad-cert-id", what: "CertID",
|
|
187
|
+
build: function (m) {
|
|
188
|
+
return {
|
|
189
|
+
hashAlgorithm: m.fields.hashAlgorithm.value.result,
|
|
190
|
+
issuerNameHash: m.fields.issuerNameHash.value,
|
|
191
|
+
issuerKeyHash: m.fields.issuerKeyHash.value,
|
|
192
|
+
serialNumber: m.fields.serialNumber.value,
|
|
193
|
+
serialNumberHex: m.fields.serialNumber.node.content.toString("hex"),
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---- response tree ---------------------------------------------------
|
|
199
|
+
|
|
200
|
+
// RevokedInfo ::= SEQUENCE { revocationTime GeneralizedTime, revocationReason [0]
|
|
201
|
+
// EXPLICIT CRLReason OPTIONAL } (RFC 6960 §4.2.1). Walked on the [1] IMPLICIT node,
|
|
202
|
+
// so assert:"constructed" (the context tag replaced the universal SEQUENCE tag).
|
|
203
|
+
var REVOKED_INFO = schema.seq([
|
|
204
|
+
schema.field("revocationTime", GENERALIZED_TIME),
|
|
205
|
+
schema.trailing([
|
|
206
|
+
{ tag: 0, name: "revocationReason", schema: CRL_REASON, explicit: true, emptyCode: "ocsp/bad-revoked-info" },
|
|
207
|
+
], { minTag: 0, maxTag: 0, unexpectedCode: "ocsp/bad-revoked-info", orderCode: "ocsp/bad-revoked-info" }),
|
|
208
|
+
], {
|
|
209
|
+
assert: "constructed", code: "ocsp/bad-revoked-info", what: "RevokedInfo",
|
|
210
|
+
build: function (m) {
|
|
211
|
+
return {
|
|
212
|
+
revocationTime: m.fields.revocationTime.value,
|
|
213
|
+
revocationReason: m.fields.revocationReason.present ? m.fields.revocationReason.value : null,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// CertStatus ::= CHOICE { good [0] IMPLICIT NULL, revoked [1] IMPLICIT RevokedInfo,
|
|
219
|
+
// unknown [2] IMPLICIT NULL } (RFC 6960 §4.2.1). good/unknown are primitive empty
|
|
220
|
+
// context nodes; revoked is a constructed context [1] whose body is RevokedInfo.
|
|
221
|
+
var CERT_STATUS = schema.choice([
|
|
222
|
+
{ when: { tagClass: "context", tagNumber: 0 }, schema: schema.implicitNull(0) },
|
|
223
|
+
{ when: { tagClass: "context", tagNumber: 1 }, schema: REVOKED_INFO },
|
|
224
|
+
{ when: { tagClass: "context", tagNumber: 2 }, schema: schema.implicitNull(2) },
|
|
225
|
+
], { code: "ocsp/bad-cert-status", what: "CertStatus" });
|
|
226
|
+
|
|
227
|
+
function _shapeCertStatus(field) {
|
|
228
|
+
var t = field.node.tagNumber;
|
|
229
|
+
if (t === 0) return { type: "good" };
|
|
230
|
+
if (t === 2) return { type: "unknown" };
|
|
231
|
+
var ri = field.value.result;
|
|
232
|
+
return { type: "revoked", revocationTime: ri.revocationTime, revocationReason: ri.revocationReason };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// SingleResponse ::= SEQUENCE { certID, certStatus, thisUpdate GeneralizedTime,
|
|
236
|
+
// nextUpdate [0] EXPLICIT GeneralizedTime OPTIONAL, singleExtensions [1] EXPLICIT
|
|
237
|
+
// Extensions OPTIONAL } (RFC 6960 §4.2.1). certStatus is consumed positionally
|
|
238
|
+
// (before thisUpdate), so its context [0] good arm cannot be confused with the
|
|
239
|
+
// trailing nextUpdate [0].
|
|
240
|
+
var SINGLE_RESPONSE = schema.seq([
|
|
241
|
+
schema.field("certID", CERT_ID),
|
|
242
|
+
schema.field("certStatus", CERT_STATUS),
|
|
243
|
+
schema.field("thisUpdate", GENERALIZED_TIME),
|
|
244
|
+
schema.trailing([
|
|
245
|
+
{ tag: 0, name: "nextUpdate", schema: GENERALIZED_TIME, explicit: true, emptyCode: "ocsp/bad-single-response" },
|
|
246
|
+
{ tag: 1, name: "singleExtensions", schema: EXTENSIONS, explicit: true, emptyCode: "ocsp/bad-single-response" },
|
|
247
|
+
], { minTag: 0, maxTag: 1, unexpectedCode: "ocsp/bad-single-response", orderCode: "ocsp/bad-single-response" }),
|
|
248
|
+
], {
|
|
249
|
+
assert: "sequence", code: "ocsp/bad-single-response", what: "SingleResponse",
|
|
250
|
+
build: function (m) {
|
|
251
|
+
return {
|
|
252
|
+
certID: m.fields.certID.value.result,
|
|
253
|
+
certStatus: _shapeCertStatus(m.fields.certStatus),
|
|
254
|
+
thisUpdate: m.fields.thisUpdate.value,
|
|
255
|
+
nextUpdate: m.fields.nextUpdate.present ? m.fields.nextUpdate.value : null,
|
|
256
|
+
singleExtensions: m.fields.singleExtensions.present ? m.fields.singleExtensions.value.result : null,
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ResponderID ::= CHOICE { byName [1] Name, byKey [2] KeyHash } (RFC 6960 §4.2.1) —
|
|
262
|
+
// both EXPLICIT (the module is EXPLICIT TAGS). byKey is an EXPLICIT-wrapped universal
|
|
263
|
+
// OCTET STRING (0xA2 04 ...), NOT an IMPLICIT primitive [2].
|
|
264
|
+
var RESPONDER_ID = schema.choice([
|
|
265
|
+
{ when: { tagClass: "context", tagNumber: 1 }, schema: schema.explicit(1, NAME, { code: "ocsp/bad-responder-id" }) },
|
|
266
|
+
{ when: { tagClass: "context", tagNumber: 2 }, schema: schema.explicit(2, schema.octetString(), { code: "ocsp/bad-responder-id" }) },
|
|
267
|
+
], { code: "ocsp/bad-responder-id", what: "ResponderID" });
|
|
268
|
+
|
|
269
|
+
function _shapeResponderID(field) {
|
|
270
|
+
if (field.node.tagNumber === 1) return { byName: field.value.result };
|
|
271
|
+
// RFC 6960 §4.2.1 — KeyHash is the SHA-1 hash of the responder's public key, so a
|
|
272
|
+
// conformant byKey ResponderID is exactly 20 octets; reject any other length.
|
|
273
|
+
var keyHash = field.value;
|
|
274
|
+
if (keyHash.length !== 20) throw NS.E("ocsp/bad-responder-id", "ResponderID byKey (KeyHash) must be a 20-byte SHA-1 hash (RFC 6960 §4.2.1)");
|
|
275
|
+
return { byKey: keyHash };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ResponseData ::= SEQUENCE { version [0] EXPLICIT DEFAULT v1, responderID,
|
|
279
|
+
// producedAt GeneralizedTime, responses SEQUENCE OF SingleResponse,
|
|
280
|
+
// responseExtensions [1] EXPLICIT Extensions OPTIONAL } (RFC 6960 §4.2.1).
|
|
281
|
+
var RESPONSE_DATA = schema.seq([
|
|
282
|
+
schema.optional("version", VERSION, { tag: 0, explicit: true, default: 1, emptyCode: "ocsp/bad-version" }),
|
|
283
|
+
schema.field("responderID", RESPONDER_ID),
|
|
284
|
+
schema.field("producedAt", GENERALIZED_TIME),
|
|
285
|
+
schema.field("responses", schema.seqOf(SINGLE_RESPONSE, { assert: "sequence", min: 1, code: "ocsp/bad-responses", what: "responses" })),
|
|
286
|
+
schema.trailing([
|
|
287
|
+
{ tag: 1, name: "responseExtensions", schema: EXTENSIONS, explicit: true, emptyCode: "ocsp/bad-response-data" },
|
|
288
|
+
], { minTag: 1, maxTag: 1, unexpectedCode: "ocsp/bad-response-data", orderCode: "ocsp/bad-response-data" }),
|
|
289
|
+
], {
|
|
290
|
+
assert: "sequence", code: "ocsp/bad-response-data", what: "ResponseData",
|
|
291
|
+
build: function (m) {
|
|
292
|
+
return {
|
|
293
|
+
version: m.fields.version.value,
|
|
294
|
+
responderID: _shapeResponderID(m.fields.responderID),
|
|
295
|
+
producedAt: m.fields.producedAt.value,
|
|
296
|
+
responses: m.fields.responses.value.items.map(function (it) { return it.value.result; }),
|
|
297
|
+
responseExtensions: m.fields.responseExtensions.present ? m.fields.responseExtensions.value.result : null,
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// BasicOCSPResponse ::= SEQUENCE { tbsResponseData ResponseData, signatureAlgorithm
|
|
303
|
+
// AlgorithmIdentifier, signature BIT STRING, certs [0] EXPLICIT SEQUENCE OF
|
|
304
|
+
// Certificate OPTIONAL } (RFC 6960 §4.2.1). tbsResponseData.node.bytes is the exact
|
|
305
|
+
// signed region (no CMS-style re-tag divergence — the clean case).
|
|
306
|
+
var BASIC_OCSP_RESPONSE = schema.seq([
|
|
307
|
+
schema.field("tbsResponseData", RESPONSE_DATA),
|
|
308
|
+
schema.field("signatureAlgorithm", ALGORITHM_IDENTIFIER),
|
|
309
|
+
schema.field("signature", schema.bitString()),
|
|
310
|
+
schema.trailing([
|
|
311
|
+
{ tag: 0, name: "certs", schema: CERTS, explicit: true, emptyCode: "ocsp/bad-basic-response" },
|
|
312
|
+
], { minTag: 0, maxTag: 0, unexpectedCode: "ocsp/bad-basic-response", orderCode: "ocsp/bad-basic-response" }),
|
|
313
|
+
], {
|
|
314
|
+
assert: "sequence", code: "ocsp/bad-basic-response", what: "BasicOCSPResponse",
|
|
315
|
+
build: function (m) {
|
|
316
|
+
var tbs = m.fields.tbsResponseData.value.result;
|
|
317
|
+
return {
|
|
318
|
+
tbsResponseDataBytes: m.fields.tbsResponseData.node.bytes,
|
|
319
|
+
version: tbs.version,
|
|
320
|
+
responderID: tbs.responderID,
|
|
321
|
+
producedAt: tbs.producedAt,
|
|
322
|
+
responses: tbs.responses,
|
|
323
|
+
responseExtensions: tbs.responseExtensions,
|
|
324
|
+
signatureAlgorithm: m.fields.signatureAlgorithm.value.result,
|
|
325
|
+
signature: _rawSignature(m.fields.signature),
|
|
326
|
+
certs: m.fields.certs.present ? m.fields.certs.value.items.map(function (it) { return it.value; }) : [],
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ResponseBytes ::= SEQUENCE { responseType OBJECT IDENTIFIER, response OCTET STRING }
|
|
332
|
+
// (RFC 6960 §4.2.1). For id-pkix-ocsp-basic the response OCTET STRING content is a
|
|
333
|
+
// fresh DER BasicOCSPResponse (decoded + walked); an unknown responseType is
|
|
334
|
+
// recognized-and-deferred with a precise ocsp/unsupported-response-type.
|
|
335
|
+
var RESPONSE_BYTES = schema.seq([
|
|
336
|
+
schema.field("responseType", schema.oidLeaf()),
|
|
337
|
+
schema.field("response", schema.octetString()),
|
|
338
|
+
], {
|
|
339
|
+
assert: "sequence", arity: { exact: 2 }, code: "ocsp/bad-response-bytes", what: "ResponseBytes",
|
|
340
|
+
build: function (m, ctx) {
|
|
341
|
+
var responseType = m.fields.responseType.value;
|
|
342
|
+
var raw = m.fields.response.value;
|
|
343
|
+
if (responseType !== OID_OCSP_BASIC) {
|
|
344
|
+
throw NS.E("ocsp/unsupported-response-type", (ctx.oid.name(responseType) || responseType) + " is not id-pkix-ocsp-basic (RFC 6960 §4.2.1)");
|
|
345
|
+
}
|
|
346
|
+
var inner;
|
|
347
|
+
try { inner = asn1.decode(raw); }
|
|
348
|
+
catch (e) { throw NS.E("ocsp/bad-der", "BasicOCSPResponse DER did not decode: " + ((e && e.message) || String(e)), e); }
|
|
349
|
+
return {
|
|
350
|
+
responseType: responseType,
|
|
351
|
+
responseTypeName: ctx.oid.name(responseType) || null,
|
|
352
|
+
response: raw,
|
|
353
|
+
basicResponse: schema.walk(BASIC_OCSP_RESPONSE, inner, ctx).result,
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// OCSPResponse ::= SEQUENCE { responseStatus OCSPResponseStatus, responseBytes [0]
|
|
359
|
+
// EXPLICIT ResponseBytes OPTIONAL } (RFC 6960 §4.2.1). The status <-> responseBytes
|
|
360
|
+
// biconditional (successful iff responseBytes present) is the OCSP-specific
|
|
361
|
+
// cross-field guard.
|
|
362
|
+
var OCSP_RESPONSE = schema.seq([
|
|
363
|
+
schema.field("responseStatus", OCSP_RESPONSE_STATUS),
|
|
364
|
+
schema.optional("responseBytes", RESPONSE_BYTES, { tag: 0, explicit: true, emptyCode: "ocsp/bad-ocsp-response" }),
|
|
365
|
+
], {
|
|
366
|
+
assert: "sequence", arity: { min: 1 }, code: "ocsp/bad-ocsp-response", what: "OCSPResponse",
|
|
367
|
+
build: function (m) {
|
|
368
|
+
var status = m.fields.responseStatus.value;
|
|
369
|
+
var present = m.fields.responseBytes.present;
|
|
370
|
+
// RFC 6960 §4.2.1 — a successful response MUST carry responseBytes; any other
|
|
371
|
+
// status MUST NOT (there is nothing to convey but the status).
|
|
372
|
+
if (status.code === 0 && !present) throw NS.E("ocsp/bad-response-bytes", "a successful OCSPResponse must carry responseBytes (RFC 6960 §4.2.1)");
|
|
373
|
+
if (status.code !== 0 && present) throw NS.E("ocsp/bad-response-bytes", "a non-successful OCSPResponse must not carry responseBytes (RFC 6960 §4.2.1)");
|
|
374
|
+
var rb = present ? m.fields.responseBytes.value.result : null;
|
|
375
|
+
return {
|
|
376
|
+
responseStatus: status,
|
|
377
|
+
responseBytes: rb ? { responseType: rb.responseType, responseTypeName: rb.responseTypeName, response: rb.response } : null,
|
|
378
|
+
basicResponse: rb ? rb.basicResponse : null,
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ---- request tree ----------------------------------------------------
|
|
384
|
+
|
|
385
|
+
// Request ::= SEQUENCE { reqCert CertID, singleRequestExtensions [0] EXPLICIT
|
|
386
|
+
// Extensions OPTIONAL } (RFC 6960 §4.1.1).
|
|
387
|
+
var REQUEST = schema.seq([
|
|
388
|
+
schema.field("reqCert", CERT_ID),
|
|
389
|
+
schema.trailing([
|
|
390
|
+
{ tag: 0, name: "singleRequestExtensions", schema: EXTENSIONS, explicit: true, emptyCode: "ocsp/bad-request" },
|
|
391
|
+
], { minTag: 0, maxTag: 0, unexpectedCode: "ocsp/bad-request", orderCode: "ocsp/bad-request" }),
|
|
392
|
+
], {
|
|
393
|
+
assert: "sequence", code: "ocsp/bad-request", what: "Request",
|
|
394
|
+
build: function (m) {
|
|
395
|
+
return {
|
|
396
|
+
certID: m.fields.reqCert.value.result,
|
|
397
|
+
singleRequestExtensions: m.fields.singleRequestExtensions.present ? m.fields.singleRequestExtensions.value.result : null,
|
|
398
|
+
};
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Signature ::= SEQUENCE { signatureAlgorithm AlgorithmIdentifier, signature BIT
|
|
403
|
+
// STRING, certs [0] EXPLICIT SEQUENCE OF Certificate OPTIONAL } (RFC 6960 §4.1.1).
|
|
404
|
+
var SIGNATURE = schema.seq([
|
|
405
|
+
schema.field("signatureAlgorithm", ALGORITHM_IDENTIFIER),
|
|
406
|
+
schema.field("signature", schema.bitString()),
|
|
407
|
+
schema.trailing([
|
|
408
|
+
{ tag: 0, name: "certs", schema: CERTS, explicit: true, emptyCode: "ocsp/bad-signature" },
|
|
409
|
+
], { minTag: 0, maxTag: 0, unexpectedCode: "ocsp/bad-signature", orderCode: "ocsp/bad-signature" }),
|
|
410
|
+
], {
|
|
411
|
+
assert: "sequence", code: "ocsp/bad-signature", what: "Signature",
|
|
412
|
+
build: function (m) {
|
|
413
|
+
return {
|
|
414
|
+
signatureAlgorithm: m.fields.signatureAlgorithm.value.result,
|
|
415
|
+
signature: _rawSignature(m.fields.signature),
|
|
416
|
+
certs: m.fields.certs.present ? m.fields.certs.value.items.map(function (it) { return it.value; }) : [],
|
|
417
|
+
};
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// TBSRequest ::= SEQUENCE { version [0] EXPLICIT DEFAULT v1, requestorName [1]
|
|
422
|
+
// EXPLICIT GeneralName OPTIONAL, requestList SEQUENCE OF Request, requestExtensions
|
|
423
|
+
// [2] EXPLICIT Extensions OPTIONAL } (RFC 6960 §4.1.1). requestorName is surfaced
|
|
424
|
+
// raw (GeneralName is a wide CHOICE with no factory yet).
|
|
425
|
+
var TBS_REQUEST = schema.seq([
|
|
426
|
+
schema.optional("version", VERSION, { tag: 0, explicit: true, default: 1, emptyCode: "ocsp/bad-version" }),
|
|
427
|
+
schema.optional("requestorName", GENERAL_NAME_RAW, { tag: 1, explicit: true, emptyCode: "ocsp/bad-requestor-name" }),
|
|
428
|
+
schema.field("requestList", schema.seqOf(REQUEST, { assert: "sequence", min: 1, code: "ocsp/bad-request-list", what: "requestList" })),
|
|
429
|
+
schema.trailing([
|
|
430
|
+
{ tag: 2, name: "requestExtensions", schema: EXTENSIONS, explicit: true, emptyCode: "ocsp/bad-tbs-request" },
|
|
431
|
+
], { minTag: 2, maxTag: 2, unexpectedCode: "ocsp/bad-tbs-request", orderCode: "ocsp/bad-tbs-request" }),
|
|
432
|
+
], {
|
|
433
|
+
assert: "sequence", code: "ocsp/bad-tbs-request", what: "TBSRequest",
|
|
434
|
+
build: function (m) {
|
|
435
|
+
var rn = m.fields.requestorName;
|
|
436
|
+
return {
|
|
437
|
+
version: m.fields.version.value,
|
|
438
|
+
requestorName: rn.present ? { bytes: rn.value.bytes, tagClass: rn.value.tagClass, tagNumber: rn.value.tagNumber } : null,
|
|
439
|
+
requestList: m.fields.requestList.value.items.map(function (it) { return it.value.result; }),
|
|
440
|
+
requestExtensions: m.fields.requestExtensions.present ? m.fields.requestExtensions.value.result : null,
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// OCSPRequest ::= SEQUENCE { tbsRequest TBSRequest, optionalSignature [0] EXPLICIT
|
|
446
|
+
// Signature OPTIONAL } (RFC 6960 §4.1.1). tbsRequest.node.bytes is the raw signed
|
|
447
|
+
// region for external verification.
|
|
448
|
+
var OCSP_REQUEST = schema.seq([
|
|
449
|
+
schema.field("tbsRequest", TBS_REQUEST),
|
|
450
|
+
schema.optional("optionalSignature", SIGNATURE, { tag: 0, explicit: true, emptyCode: "ocsp/not-a-request" }),
|
|
451
|
+
], {
|
|
452
|
+
assert: "sequence", arity: { min: 1 }, code: "ocsp/not-a-request", what: "OCSPRequest",
|
|
453
|
+
build: function (m) {
|
|
454
|
+
var tbs = m.fields.tbsRequest.value.result;
|
|
455
|
+
var optionalSignature = m.fields.optionalSignature.present ? m.fields.optionalSignature.value.result : null;
|
|
456
|
+
// RFC 6960 §4.1.2 — if the request is signed, the requestor SHALL specify its
|
|
457
|
+
// name in requestorName (the identity the signature binds to). A signed request
|
|
458
|
+
// without it is internally inconsistent; reject fail-closed.
|
|
459
|
+
if (optionalSignature && tbs.requestorName === null) {
|
|
460
|
+
throw NS.E("ocsp/missing-requestor-name", "a signed OCSPRequest must specify requestorName (RFC 6960 §4.1.2)");
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
tbsRequestBytes: m.fields.tbsRequest.node.bytes,
|
|
464
|
+
version: tbs.version,
|
|
465
|
+
requestorName: tbs.requestorName,
|
|
466
|
+
requestList: tbs.requestList,
|
|
467
|
+
requestExtensions: tbs.requestExtensions,
|
|
468
|
+
optionalSignature: optionalSignature,
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @primitive pki.schema.ocsp.parseRequest
|
|
475
|
+
* @signature pki.schema.ocsp.parseRequest(input) -> ocspRequest
|
|
476
|
+
* @since 0.1.11
|
|
477
|
+
* @status experimental
|
|
478
|
+
* @spec RFC 6960
|
|
479
|
+
* @related pki.schema.ocsp.parseResponse, pki.schema.parse
|
|
480
|
+
*
|
|
481
|
+
* Parse a DER `Buffer` or a PEM (`OCSP REQUEST`) string into a structured
|
|
482
|
+
* OCSPRequest: `{ tbsRequestBytes, version, requestorName, requestList,
|
|
483
|
+
* requestExtensions, optionalSignature }`. `tbsRequestBytes` is the exact on-wire
|
|
484
|
+
* `tbsRequest` TLV for external signature verification; each `requestList` entry
|
|
485
|
+
* carries its `CertID` (with the two issuer hashes raw). A malformed structure
|
|
486
|
+
* throws a typed `OcspError` (`ocsp/*`) and a leaf-level codec fault surfaces as
|
|
487
|
+
* `asn1/*`.
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* var req = pki.schema.ocsp.parseRequest(der);
|
|
491
|
+
* req.requestList[0].certID.serialNumberHex; // -> "1332"
|
|
492
|
+
*/
|
|
493
|
+
var parseRequest = pkix.makeParser({ pemLabel: "OCSP REQUEST", PemError: PemError, ErrorClass: OcspError, prefix: "ocsp", what: "OCSP request", topSchema: OCSP_REQUEST, ns: NS });
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* @primitive pki.schema.ocsp.parseResponse
|
|
497
|
+
* @signature pki.schema.ocsp.parseResponse(input) -> ocspResponse
|
|
498
|
+
* @since 0.1.11
|
|
499
|
+
* @status experimental
|
|
500
|
+
* @spec RFC 6960
|
|
501
|
+
* @related pki.schema.ocsp.parseRequest, pki.schema.parse
|
|
502
|
+
*
|
|
503
|
+
* Parse a DER `Buffer` or a PEM (`OCSP RESPONSE`) string into a structured
|
|
504
|
+
* OCSPResponse: `{ responseStatus, responseBytes, basicResponse }`. For a successful
|
|
505
|
+
* basic response, `basicResponse` carries `{ tbsResponseDataBytes, responderID,
|
|
506
|
+
* producedAt, responses, signatureAlgorithm, signature, certs }`; each
|
|
507
|
+
* `responses[i].certStatus` is `{ type: "good" | "revoked" | "unknown" }`. A
|
|
508
|
+
* non-successful status carries no `responseBytes`. A malformed structure throws a
|
|
509
|
+
* typed `OcspError` (`ocsp/*`); an unsupported `responseType` throws
|
|
510
|
+
* `ocsp/unsupported-response-type`.
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* var res = pki.schema.ocsp.parseResponse(der);
|
|
514
|
+
* res.responseStatus.name; // -> "successful"
|
|
515
|
+
* res.basicResponse.responses[0].certStatus.type; // -> "good" | "revoked" | "unknown"
|
|
516
|
+
*/
|
|
517
|
+
var parseResponse = pkix.makeParser({ pemLabel: "OCSP RESPONSE", PemError: PemError, ErrorClass: OcspError, prefix: "ocsp", what: "OCSP response", topSchema: OCSP_RESPONSE, ns: NS });
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* @primitive pki.schema.ocsp.pemDecode
|
|
521
|
+
* @signature pki.schema.ocsp.pemDecode(text, label?) -> Buffer
|
|
522
|
+
* @since 0.1.11
|
|
523
|
+
* @status experimental
|
|
524
|
+
* @spec RFC 7468, RFC 6960
|
|
525
|
+
* @related pki.schema.ocsp.parseResponse
|
|
526
|
+
*
|
|
527
|
+
* Extract the DER bytes from a PEM OCSP block (default label `OCSP RESPONSE`; pass
|
|
528
|
+
* `"OCSP REQUEST"` for a request). Throws `PemError` on a missing / mismatched
|
|
529
|
+
* envelope or a non-base64 body.
|
|
530
|
+
*
|
|
531
|
+
* @example
|
|
532
|
+
* var der = pki.schema.ocsp.pemDecode(pemText, "OCSP REQUEST");
|
|
533
|
+
*/
|
|
534
|
+
function pemDecode(text, label) { return pkix.pemDecode(text, label || "OCSP RESPONSE", PemError); }
|
|
535
|
+
|
|
536
|
+
// An OCSPResponse root is the only registered structure that leads with an
|
|
537
|
+
// ENUMERATED child (a SEQUENCE of 1-2: responseStatus + an optional context [0]
|
|
538
|
+
// responseBytes). Every other root leads with a SEQUENCE (x509/crl/csr/ocsp-request),
|
|
539
|
+
// an OBJECT IDENTIFIER (cms), or an INTEGER (pkcs8), so the detector is
|
|
540
|
+
// unconditionally exclusive.
|
|
541
|
+
function matchesResponse(root) {
|
|
542
|
+
var T = asn1.TAGS;
|
|
543
|
+
if (!root || root.tagClass !== "universal" || root.tagNumber !== T.SEQUENCE || !root.children) return false;
|
|
544
|
+
var k = root.children;
|
|
545
|
+
if (k.length < 1 || k.length > 2) return false;
|
|
546
|
+
if (!(k[0].tagClass === "universal" && k[0].tagNumber === T.ENUMERATED)) return false;
|
|
547
|
+
if (k.length === 2 && !(k[1].tagClass === "context" && k[1].tagNumber === 0 && k[1].children)) return false;
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// An OCSPRequest root is a SEQUENCE of 1-2 whose first child is the tbsRequest
|
|
552
|
+
// SEQUENCE (optionally followed by a context [0] EXPLICIT signature). It leads with
|
|
553
|
+
// a SEQUENCE like x509/crl/csr, but those require EXACTLY 3 children (the
|
|
554
|
+
// signed-envelope trio), while an OCSPRequest is 1-2 — so it is excluded by arity.
|
|
555
|
+
function matchesRequest(root) {
|
|
556
|
+
var T = asn1.TAGS;
|
|
557
|
+
if (!root || root.tagClass !== "universal" || root.tagNumber !== T.SEQUENCE || !root.children) return false;
|
|
558
|
+
var k = root.children;
|
|
559
|
+
if (k.length < 1 || k.length > 2) return false;
|
|
560
|
+
if (!(k[0].tagClass === "universal" && k[0].tagNumber === T.SEQUENCE && k[0].children)) return false;
|
|
561
|
+
if (k.length === 2 && !(k[1].tagClass === "context" && k[1].tagNumber === 0 && k[1].children)) return false;
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
module.exports = {
|
|
566
|
+
parseRequest: parseRequest,
|
|
567
|
+
parseResponse: parseResponse,
|
|
568
|
+
pemDecode: pemDecode,
|
|
569
|
+
matchesRequest: matchesRequest,
|
|
570
|
+
matchesResponse: matchesResponse,
|
|
571
|
+
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:88d4b070-1605-4f2d-84b3-a7851ac00842",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-05T20:47:24.516Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.1.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.1.12",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.1.
|
|
25
|
+
"version": "0.1.12",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.1.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.1.12",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.1.
|
|
57
|
+
"ref": "@blamejs/pki@0.1.12",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|