@blamejs/pki 0.1.32 → 0.2.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/CHANGELOG.md +25 -0
- package/README.md +2 -1
- package/index.js +7 -0
- package/lib/asn1-der.js +31 -22
- package/lib/constants.js +29 -0
- package/lib/ct.js +13 -12
- package/lib/est.js +10 -10
- package/lib/framework-error.js +11 -0
- package/lib/guard-all.js +15 -0
- package/lib/guard-crypto.js +31 -1
- package/lib/guard-encoding.js +90 -0
- package/lib/guard-identifier.js +56 -0
- package/lib/guard-json.js +149 -0
- package/lib/guard-limits.js +58 -8
- package/lib/guard-range.js +40 -7
- package/lib/guard-text.js +7 -0
- package/lib/jose.js +17 -128
- package/lib/merkle.js +7 -16
- package/lib/oid.js +22 -9
- package/lib/path-validate.js +173 -16
- package/lib/schema-attrcert.js +5 -0
- package/lib/schema-cms.js +22 -1
- package/lib/schema-crmf.js +3 -5
- package/lib/schema-ocsp.js +5 -1
- package/lib/schema-pkix.js +92 -12
- package/lib/trust.js +707 -0
- package/lib/webcrypto.js +18 -7
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ 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.2.0 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
Trust-store ingestion, sharded-CRL revocation, and a hardened input-guard layer.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.trust.parseCertdata(text) and pki.trust.parseCcadbCsv(text) parse the Mozilla/NSS certdata.txt object stream and the CCADB All Certificate Records CSV into one Anchor shape carrying the exact { name, publicKey, algorithm, parameters } fields pki.path.validate consumes, plus per-purpose distrustAfter dates, delegator purposes (only CKT_NSS_TRUSTED_DELEGATOR grants a purpose), subjectDer, label, and mozillaCaPolicy. Certificate and trust objects are paired by byte-exact issuer + serial (never adjacency) and cross-checked against the parsed DER, so metadata can never attach to the wrong root. pki.trust.anchor(entry, { purpose }) hands the anchor to validate, failing fast when the entry does not delegate the purpose. Malformed octal, an oversized block or file, an unrecognized trust value, an undecodable distrust date, or a mispaired object throws a typed trust/* error. Offline and pure: the caller supplies the text; nothing fetches.
|
|
14
|
+
- pki.path.validate gains opts.checkPurpose plus trust-anchor constraint enforcement: with an anchor carrying distrustAfter / purposes metadata, a leaf whose notBefore is strictly after the anchor's distrust date for the checked purpose fails with path/distrusted-after (the boundary instant stays trusted, matching Mozilla's enforcement), and a purpose the anchor does not delegate fails with path/purpose-not-trusted. Anchors without the metadata validate exactly as before.
|
|
15
|
+
- cRLDistributionPoints and freshestCRL certificate-extension decoders, and RFC 5280 sec. 6.3.3 distribution-point correspondence in pki.path.crlChecker: a partitioned CRL whose critical issuing-distribution-point shares an identically-encoded name with one of the certificate's distribution points, carries no reason restriction on either side, is current, and verifies now establishes a good status for its shard. A non-corresponding, reason-restricted, non-critical-IDP, delta, or unverifiable shard keeps failing closed to undetermined -- and a listed serial still reports revoked regardless of correspondence.
|
|
16
|
+
- pki.asn1.decode and pki.cbor.decode accept a maxItems option (default C.LIMITS.DER_MAX_ITEMS) capping the total decoded elements, so a small dense input cannot fan out into an unbounded node tree (asn1/too-many-items). OCSP responses are capped at C.LIMITS.OCSP_MAX_CERTS embedded certificates (ocsp/too-many-certs), bounding the pre-authentication signature work an attacker-supplied response can demand.
|
|
17
|
+
- Strict JWK oct key-material decoding: importKey / unwrapKey reject a JWK oct key whose k member is missing, empty, padded, or non-canonical base64url instead of importing a wrong or empty key, and JSON key unwrap rejects duplicate members at every depth so a smuggled second parameter can no longer resolve last-wins. Unwrapped JWK text is bounded (size, nesting depth, strict UTF-8).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- OID string handling is canonical everywhere: pki.asn1.build.oid and pki.oid.toDER reject a leading-zero arc (previously accepted and silently encoded as a DIFFERENT OID -- "2.05.29.15" emitted the DER of 2.5.29.15), and pki.oid.toArcs / register now enforce the X.660 arc bounds on the string form (a root above 2, or a second arc above 39 under roots 0 and 1, can never be DER-encoded and now throws oid/bad-arc). Error codes consolidated with the canonical form: a one-arc or non-numeric-arc string throws oid/bad-input (previously oid/too-short and oid/bad-arc; oid/too-short is removed).
|
|
22
|
+
- pki.jose and WebCrypto JWK unwrap JSON limits are enforced through one shared bounded reader; an unwrapped-key JSON parse failure reports the typed webcrypto/data error without a nested SyntaxError cause.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Signing via pki.jose over a payload backed by a detached ArrayBuffer now fails closed with a typed error instead of silently signing an empty payload; PEM encoding and EST transfer encoding reject a detached or non-buffer input the same way.
|
|
27
|
+
- EST server-side key generation rejects an EnvelopedData whose encryptedContent is present but zero-length (est/bad-key-part); previously only the fully absent form was rejected, so an empty ciphertext could reach the caller's decrypt step. The same shared check now backs the CRMF encrypted-key proof-of-possession and PKCS#12 paths.
|
|
28
|
+
- An attribute certificate whose objectDigestInfo digest is not octet-aligned is rejected at parse (attrcert/bad-object-digest-info) instead of surfacing a bit-truncated digest.
|
|
29
|
+
- A malformed content-type attribute value inside AuthEnvelopedData authenticated attributes surfaces cms/bad-content-type-attr instead of a raw asn1/* codec error.
|
|
30
|
+
- pki.path.validate rejects fractional maxPathCerts / maxPolicyNodes values (path/bad-input) instead of silently tolerating them, and validates requiredEku / userInitialPolicySet entries as canonical OID strings at the entry point -- a leading-zero or out-of-bounds key could never match decoder output and now fails at boot instead of silently never matching.
|
|
31
|
+
|
|
7
32
|
## v0.1.32 — 2026-07-11
|
|
8
33
|
|
|
9
34
|
OCSP-backed revocation checking joins certification-path validation.
|
package/README.md
CHANGED
|
@@ -220,9 +220,10 @@ is callable today; nothing below is a stub.
|
|
|
220
220
|
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
221
221
|
| `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
222
222
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
|
|
223
|
-
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes
|
|
223
|
+
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
|
|
224
224
|
| `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
|
|
225
225
|
| `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
|
|
226
|
+
| `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
|
226
227
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
227
228
|
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError`, each carrying a stable `code` in `domain/reason` form |
|
|
228
229
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
|
package/index.js
CHANGED
|
@@ -40,6 +40,7 @@ var merkle = require("./lib/merkle");
|
|
|
40
40
|
var est = require("./lib/est");
|
|
41
41
|
var jose = require("./lib/jose");
|
|
42
42
|
var acme = require("./lib/acme");
|
|
43
|
+
var trust = require("./lib/trust");
|
|
43
44
|
|
|
44
45
|
module.exports = {
|
|
45
46
|
version: constants.version,
|
|
@@ -87,6 +88,12 @@ module.exports = {
|
|
|
87
88
|
// http-01 / dns-01 / tls-alpn-01 challenge math, and the ARI certID. A message
|
|
88
89
|
// layer, not an HTTP client -- transport is the operator's to inject.
|
|
89
90
|
acme: acme,
|
|
91
|
+
// `trust` is Mozilla/NSS certdata.txt + CCADB CSV root-store ingestion --
|
|
92
|
+
// pki.trust.parseCertdata / parseCcadbCsv produce constraint-carrying trust
|
|
93
|
+
// anchors (per-purpose trust bits + per-purpose distrust-after dates the bare
|
|
94
|
+
// root list omits); pki.trust.anchor hands one to pki.path.validate. Offline:
|
|
95
|
+
// the operator supplies the text; no fetch.
|
|
96
|
+
trust: trust,
|
|
90
97
|
// A ready W3C Crypto instance (globalThis.crypto shape) with the classes for
|
|
91
98
|
// constructing more attached under the same namespace (pki.webcrypto.CryptoKey,
|
|
92
99
|
// .SubtleCrypto, .Crypto, .WebCryptoError). PQC-first, classical-capable, zero-dep.
|
package/lib/asn1-der.js
CHANGED
|
@@ -37,6 +37,14 @@ var guard = require("./guard-all");
|
|
|
37
37
|
var Asn1Error = frameworkError.Asn1Error;
|
|
38
38
|
var OidError = frameworkError.OidError;
|
|
39
39
|
|
|
40
|
+
// (code, message) -> Asn1Error, the factory shape the composed guards throw
|
|
41
|
+
// through so a malformed codec argument keeps the asn1/* typed verdict.
|
|
42
|
+
function _asn1Error(c, m) { return new Asn1Error(c, m); }
|
|
43
|
+
|
|
44
|
+
// (code, message) -> OidError, the factory the OID string guard throws through
|
|
45
|
+
// so build.oid keeps its oid/* typed verdict (oid/bad-input, oid/bad-arc).
|
|
46
|
+
function _oidErr(c, m) { return new OidError(c, m); }
|
|
47
|
+
|
|
40
48
|
// ---- Tag constants (universal class) --------------------------------
|
|
41
49
|
|
|
42
50
|
// L1 -- the ASN.1 universal-type descriptor registry: the single source of
|
|
@@ -158,20 +166,26 @@ function decode(input, opts) {
|
|
|
158
166
|
// recursive descent into a bare RangeError instead of the typed asn1/too-deep.
|
|
159
167
|
var maxBytes = guard.limits.cap(opts.maxBytes, "maxBytes", constants.LIMITS.DER_MAX_BYTES);
|
|
160
168
|
var maxDepth = guard.limits.depthCap(opts.maxDepth, "maxDepth", constants.LIMITS.DER_MAX_DEPTH);
|
|
169
|
+
// Total decoded-node ceiling: the eager node tree lets a dense run of tiny TLVs
|
|
170
|
+
// amplify a small input into a huge tree, so a per-node counter fails closed at
|
|
171
|
+
// the cap (the memory-fanout class the CBOR codec closed with CBOR_MAX_ITEMS).
|
|
172
|
+
var maxItems = guard.limits.cap(opts.maxItems, "maxItems", constants.LIMITS.DER_MAX_ITEMS);
|
|
161
173
|
if (buf.length > maxBytes) {
|
|
162
174
|
throw new Asn1Error("asn1/too-large", "input " + buf.length + " bytes exceeds cap " + maxBytes);
|
|
163
175
|
}
|
|
164
|
-
var
|
|
176
|
+
var ctr = guard.limits.counter(maxItems, _asn1Error, "asn1/too-many-items", "decoded DER node");
|
|
177
|
+
var r = _decodeTLV(buf, 0, buf.length, 0, maxDepth, opts.ber === true, undefined, ctr);
|
|
165
178
|
if (!opts.allowTrailing && r.end !== buf.length) {
|
|
166
179
|
throw new Asn1Error("asn1/trailing-bytes", (buf.length - r.end) + " trailing byte(s) after the top-level value");
|
|
167
180
|
}
|
|
168
181
|
return r.node;
|
|
169
182
|
}
|
|
170
183
|
|
|
171
|
-
function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth) {
|
|
184
|
+
function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth, ctr) {
|
|
172
185
|
if (depth > maxDepth) {
|
|
173
186
|
throw new Asn1Error("asn1/too-deep", "nesting exceeds depth cap " + maxDepth);
|
|
174
187
|
}
|
|
188
|
+
if (ctr) ctr.tick(); // one decoded node -> one tick (memory-fanout cap)
|
|
175
189
|
var p = start;
|
|
176
190
|
if (p >= limit) throw new Asn1Error("asn1/truncated", "expected an identifier octet");
|
|
177
191
|
var first = buf[p]; p += 1;
|
|
@@ -271,7 +285,7 @@ function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth) {
|
|
|
271
285
|
for (;;) {
|
|
272
286
|
if (cp + 2 > limit) throw new Asn1Error("asn1/truncated", "indefinite-length value is missing its end-of-contents octets");
|
|
273
287
|
if (buf[cp] === 0x00 && buf[cp + 1] === 0x00) break;
|
|
274
|
-
child = _decodeTLV(buf, cp, limit, depth + 1, maxDepth, ber, strDepth);
|
|
288
|
+
child = _decodeTLV(buf, cp, limit, depth + 1, maxDepth, ber, strDepth, ctr);
|
|
275
289
|
children.push(child.node);
|
|
276
290
|
cp = child.end;
|
|
277
291
|
}
|
|
@@ -279,7 +293,7 @@ function _decodeTLV(buf, start, limit, depth, maxDepth, ber, strDepth) {
|
|
|
279
293
|
end = cp + 2;
|
|
280
294
|
} else {
|
|
281
295
|
while (cp < contentEnd) {
|
|
282
|
-
child = _decodeTLV(buf, cp, contentEnd, depth + 1, maxDepth, ber, strDepth);
|
|
296
|
+
child = _decodeTLV(buf, cp, contentEnd, depth + 1, maxDepth, ber, strDepth, ctr);
|
|
283
297
|
children.push(child.node);
|
|
284
298
|
cp = child.end;
|
|
285
299
|
}
|
|
@@ -793,7 +807,10 @@ function encodeIdentifier(classBits, constructed, tagNumber) {
|
|
|
793
807
|
* pki.asn1.encode(0x00, false, pki.asn1.TAGS.NULL, Buffer.alloc(0));
|
|
794
808
|
*/
|
|
795
809
|
function encodeTLV(classBits, constructed, tagNumber, content) {
|
|
796
|
-
|
|
810
|
+
// A Buffer content is re-viewed so a detached backing store fails closed with a
|
|
811
|
+
// typed asn1/not-buffer instead of a raw TypeError out of Buffer.concat; an
|
|
812
|
+
// array / undefined content stays the normal builder path.
|
|
813
|
+
var body = Buffer.isBuffer(content) ? guard.bytes.view(content, Asn1Error, "asn1/not-buffer", "encodeTLV content") : Buffer.from(content || []);
|
|
797
814
|
var id = encodeIdentifier(classBits, constructed, tagNumber);
|
|
798
815
|
return Buffer.concat([id, encodeLength(body.length), body]);
|
|
799
816
|
}
|
|
@@ -824,17 +841,12 @@ function intToDer(v) {
|
|
|
824
841
|
}
|
|
825
842
|
|
|
826
843
|
function encodeOidContent(dotted) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
if (!/^\d+$/.test(parts[i])) throw new OidError("oid/bad-arc", "arc " + JSON.stringify(parts[i]) + " is not a non-negative integer");
|
|
833
|
-
arcs.push(BigInt(parts[i]));
|
|
834
|
-
}
|
|
844
|
+
// Canonical dotted-decimal + X.660 arc bounds live in the one string-OID guard,
|
|
845
|
+
// so build.oid cannot emit bytes that decode to a DIFFERENT OID than the string
|
|
846
|
+
// names (a leading-zero arc) or an arc that can never DER-encode.
|
|
847
|
+
guard.identifier.assertCanonicalOid(dotted, _oidErr, "oid/bad-input", "OID", "oid/bad-arc");
|
|
848
|
+
var arcs = dotted.split(".").map(function (p) { return BigInt(p); });
|
|
835
849
|
var a1 = arcs[0], a2 = arcs[1];
|
|
836
|
-
if (a1 > 2n) throw new OidError("oid/bad-arc", "first arc must be 0, 1, or 2");
|
|
837
|
-
if (a1 < 2n && a2 >= 40n) throw new OidError("oid/bad-arc", "second arc must be < 40 when the first arc is 0 or 1");
|
|
838
850
|
var subids = [a1 * 40n + a2].concat(arcs.slice(2));
|
|
839
851
|
var out = [];
|
|
840
852
|
for (var s = 0; s < subids.length; s++) {
|
|
@@ -905,14 +917,11 @@ var build = {
|
|
|
905
917
|
oid: function (dotted) { return _universal(TAGS.OBJECT_IDENTIFIER, false, encodeOidContent(dotted)); },
|
|
906
918
|
octetString: function (buf) { return _universal(TAGS.OCTET_STRING, false, _asBuffer(buf, "build.octetString")); },
|
|
907
919
|
bitString: function (buf, unusedBits) {
|
|
908
|
-
var u = unusedBits == null ? 0 : unusedBits;
|
|
909
920
|
// An integer in 0..7 (X.690 sec. 8.6.2.2). A fractional / non-finite count
|
|
910
|
-
// would be silently bit-truncated into a DIFFERENT value when written
|
|
911
|
-
//
|
|
912
|
-
// inputs (encodeLength, the tag number).
|
|
913
|
-
|
|
914
|
-
throw new Asn1Error("asn1/bad-bit-string", "unusedBits must be an integer 0..7");
|
|
915
|
-
}
|
|
921
|
+
// would be silently bit-truncated into a DIFFERENT value when written into
|
|
922
|
+
// the leading octet -- the shared integer cap rejects it like the other
|
|
923
|
+
// numeric builder inputs (encodeLength, the tag number).
|
|
924
|
+
var u = guard.limits.cap(unusedBits == null ? 0 : unusedBits, "unusedBits", 0, { E: _asn1Error, code: "asn1/bad-bit-string", min: 0, max: 7 });
|
|
916
925
|
var body = _asBuffer(buf, "build.bitString");
|
|
917
926
|
// X.690 8.6.2.3: an empty BIT STRING has no content octets, so its
|
|
918
927
|
// unused-bit count must be zero -- there are no bits to leave unused.
|
package/lib/constants.js
CHANGED
|
@@ -132,6 +132,14 @@ var BYTES = {
|
|
|
132
132
|
var LIMITS = {
|
|
133
133
|
DER_MAX_BYTES: BYTES.mib(16),
|
|
134
134
|
DER_MAX_DEPTH: 64,
|
|
135
|
+
// Total decoded-node ceiling: the DER decoder builds an eager node tree, so a
|
|
136
|
+
// dense run of tiny TLVs (a 2-byte NULL is a node) fans a small input into a
|
|
137
|
+
// huge tree -- ~350 B/node, so a 16 MiB input of NULLs is ~8M nodes ~= 2.8 GiB.
|
|
138
|
+
// 4M sits well above the largest realistic structure (a full 16 MiB CRL carries
|
|
139
|
+
// ~500k revoked entries ~= 2.5M nodes) and below the dense-bomb count; the
|
|
140
|
+
// operator may override via opts.maxItems. (The CBOR sibling caps at 1M, too
|
|
141
|
+
// low for DER's legitimate large CRLs.)
|
|
142
|
+
DER_MAX_ITEMS: 4000000,
|
|
135
143
|
// Hard stack-safe ceiling on the recursion depth of the DER / CBOR
|
|
136
144
|
// decoders, independent of the (operator-tunable) maxDepth. Both decoders
|
|
137
145
|
// are recursive descent; a maxDepth raised above the engine's native call-
|
|
@@ -222,6 +230,27 @@ var LIMITS = {
|
|
|
222
230
|
// ACME challenge token entropy floor (RFC 8555 sec. 8, errata 6950): >= 128
|
|
223
231
|
// bits of base64url is >= 22 characters. A shorter token is refused before use.
|
|
224
232
|
ACME_TOKEN_MIN_CHARS: 22,
|
|
233
|
+
// OCSP embedded-certificate ceiling. A BasicOCSPResponse (or an OCSP request
|
|
234
|
+
// Signature) carries the responder / requestor certificate plus at most a short
|
|
235
|
+
// chain; the delegated-responder authorization loop verifies each candidate
|
|
236
|
+
// BEFORE the response signature is checked, so an attacker-crafted stapled /
|
|
237
|
+
// relayed response with a huge certs list would drive unbounded pre-auth
|
|
238
|
+
// signature verifies. 32 is generous headroom over a real chain.
|
|
239
|
+
OCSP_MAX_CERTS: 32,
|
|
240
|
+
// Trust-store ingestion ceilings (Mozilla/NSS certdata.txt + CCADB CSV).
|
|
241
|
+
// Every cap is refused BEFORE the offending allocation with a typed trust/*
|
|
242
|
+
// error -- never a silent truncate. A live certdata.txt is a few MiB and a
|
|
243
|
+
// CCADB all-records export a few MiB more, so 16 MiB whole-input headroom
|
|
244
|
+
// matches the codec siblings. A root certificate (or a CCADB PEM cell) is a
|
|
245
|
+
// few KiB; a MULTILINE_OCTAL blob or CSV field past 64 KiB is amplification,
|
|
246
|
+
// not a root. The real store is ~150 roots x 2 object blocks (and one CCADB
|
|
247
|
+
// row per root), so 10000 objects / rows is generous headroom while bounding
|
|
248
|
+
// a block/row bomb.
|
|
249
|
+
TRUST_MAX_BYTES: BYTES.mib(16),
|
|
250
|
+
TRUST_MAX_OCTAL_BYTES: BYTES.kib(64),
|
|
251
|
+
TRUST_MAX_OBJECTS: 10000,
|
|
252
|
+
TRUST_MAX_CSV_ROWS: 10000,
|
|
253
|
+
TRUST_MAX_CSV_FIELD_BYTES: BYTES.kib(64),
|
|
225
254
|
};
|
|
226
255
|
|
|
227
256
|
// Single-sourced from the package manifest so the reported version can
|
package/lib/ct.js
CHANGED
|
@@ -46,6 +46,10 @@ var guard = require("./guard-all.js");
|
|
|
46
46
|
var CtError = frameworkError.CtError;
|
|
47
47
|
var C = constants;
|
|
48
48
|
|
|
49
|
+
// (code, message) -> CtError, the factory the composed guards throw through so a
|
|
50
|
+
// malformed value keeps the ct/* typed verdict.
|
|
51
|
+
function _ctErr(c, m) { return new CtError(c, m); }
|
|
52
|
+
|
|
49
53
|
// RFC 5246 sec. 7.4.1.4.1 code points -- 1-byte, NOT OIDs. Surfaced named; an
|
|
50
54
|
// unknown code surfaces as its numeric byte with a null name (never rejected --
|
|
51
55
|
// off-profile-pair rejection is a verifier-tier log-conformance concern).
|
|
@@ -211,6 +215,9 @@ function parseSctList(extValue) {
|
|
|
211
215
|
throw new CtError("ct/empty-list", "an SCT list must contain at least one SCT (RFC 6962 sec. 3.3)");
|
|
212
216
|
}
|
|
213
217
|
var scts = [], unknownScts = [];
|
|
218
|
+
// The shared item counter bounds the TOTAL element count (known + preserved-
|
|
219
|
+
// unknown) before it can drive unbounded per-element work (RFC 6962 sec. 3.3).
|
|
220
|
+
var sctCount = guard.limits.counter(C.LIMITS.SCT_MAX_COUNT, _ctErr, "ct/too-many-scts", "SCT");
|
|
214
221
|
while (!outer.atEnd()) {
|
|
215
222
|
if (outer.remaining() < 2) {
|
|
216
223
|
throw new CtError("ct/list-trailing-bytes", "a dangling partial element after the last complete SCT (RFC 6962 sec. 3.3)");
|
|
@@ -222,14 +229,10 @@ function parseSctList(extValue) {
|
|
|
222
229
|
if (outer.remaining() < sctLen) {
|
|
223
230
|
throw new CtError("ct/list-trailing-bytes", "a SerializedSCT length " + sctLen + " overruns the list (RFC 6962 sec. 3.3)");
|
|
224
231
|
}
|
|
232
|
+
sctCount.tick();
|
|
225
233
|
var one = _parseSct(outer.subReader(sctLen, "ct/list-trailing-bytes"), sctLen);
|
|
226
234
|
if (one.unknown) unknownScts.push({ version: one.version, rawSct: one.rawSct });
|
|
227
235
|
else scts.push(one);
|
|
228
|
-
// The cap bounds the TOTAL element count (known + preserved-unknown) before it
|
|
229
|
-
// can drive unbounded per-element work.
|
|
230
|
-
if (scts.length + unknownScts.length > C.LIMITS.SCT_MAX_COUNT) {
|
|
231
|
-
throw new CtError("ct/too-many-scts", "SCT count exceeds the cap " + C.LIMITS.SCT_MAX_COUNT);
|
|
232
|
-
}
|
|
233
236
|
}
|
|
234
237
|
return { scts: scts, unknownScts: unknownScts };
|
|
235
238
|
}
|
|
@@ -283,16 +286,14 @@ function reconstructSignedData(entry, sct) {
|
|
|
283
286
|
if (!sct || typeof sct.timestamp !== "bigint" || sct.version !== 0) {
|
|
284
287
|
throw new CtError("ct/bad-input", "reconstructSignedData expects a decoded v1 SCT from parseSctList().scts[]");
|
|
285
288
|
}
|
|
286
|
-
// RFC 6962 3.2: the timestamp is a uint64.
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
throw new CtError("ct/bad-input", "sct.timestamp must be a uint64 (0..2^64-1), got " + sct.timestamp.toString() + " (RFC 6962 3.2)");
|
|
291
|
-
}
|
|
289
|
+
// RFC 6962 3.2: the timestamp is a uint64. The BigInt-preserving range guard
|
|
290
|
+
// refuses a value that cannot fill the fixed 8-byte field, typed, rather than
|
|
291
|
+
// letting it escape as a raw RangeError from the Buffer write.
|
|
292
|
+
var tsVal = guard.range.uint64(sct.timestamp, _ctErr, "ct/bad-input", "sct.timestamp (RFC 6962 3.2)");
|
|
292
293
|
var parts = [];
|
|
293
294
|
parts.push(Buffer.from([sct.version & 0xff])); // Version -- v1(0)
|
|
294
295
|
parts.push(Buffer.from([0])); // SignatureType -- certificate_timestamp(0)
|
|
295
|
-
var ts = Buffer.alloc(8); ts.writeBigUInt64BE(
|
|
296
|
+
var ts = Buffer.alloc(8); ts.writeBigUInt64BE(tsVal); parts.push(ts); // uint64 timestamp
|
|
296
297
|
parts.push(Buffer.from([(entryType >> 8) & 0xff, entryType & 0xff])); // LogEntryType (2 bytes BE)
|
|
297
298
|
if (entryType === 0) {
|
|
298
299
|
var cert = _toBuffer(entry.leafCert, "leafCert");
|
package/lib/est.js
CHANGED
|
@@ -140,13 +140,10 @@ function transferDecode(body) {
|
|
|
140
140
|
charset: "latin1", tooLarge: "est/too-large", badInput: "est/bad-input", label: "the EST payload",
|
|
141
141
|
});
|
|
142
142
|
var stripped = s.replace(/[\r\n \t]/g, "");
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
// round-trip so a malformed payload fails closed instead of decoding to a
|
|
148
|
-
// different, shorter DER (the PEM decoder applies the same canonical check).
|
|
149
|
-
if (der.toString("base64") !== stripped) throw E("est/bad-base64", "the EST payload is not canonical RFC 4648 base64 (RFC 8951 sec. 3.1)");
|
|
143
|
+
// Strict canonical RFC 4648 base64 (RFC 8951 sec. 3.1) via the shared encoding
|
|
144
|
+
// guard: the alphabet + whole-4-group + canonical round-trip fail a malformed
|
|
145
|
+
// body closed instead of silently truncating to a shorter, different DER.
|
|
146
|
+
var der = guard.encoding.base64(stripped, null, E, "est/bad-base64", "the EST payload");
|
|
150
147
|
if (der.length > constants.LIMITS.DER_MAX_BYTES) throw E("est/too-large", "the decoded EST DER exceeds the size cap");
|
|
151
148
|
return der;
|
|
152
149
|
}
|
|
@@ -167,6 +164,8 @@ function transferDecode(body) {
|
|
|
167
164
|
*/
|
|
168
165
|
function transferEncode(der) {
|
|
169
166
|
if (!Buffer.isBuffer(der)) throw E("est/bad-input", "transferEncode requires a DER Buffer");
|
|
167
|
+
// Re-view: a detached-backed Buffer reads as zero-length and would encode "".
|
|
168
|
+
der = guard.bytes.view(der, EstError, "est/bad-input", "transferEncode DER input");
|
|
170
169
|
return der.toString("base64");
|
|
171
170
|
}
|
|
172
171
|
|
|
@@ -414,9 +413,10 @@ function parseServerKeygenResponse(body, contentType, opts) {
|
|
|
414
413
|
// not just the outer ContentInfo, so a key wrapped around id-data or anything
|
|
415
414
|
// else fails closed before the caller decrypts it.
|
|
416
415
|
if (!parsedKey.encryptedContentInfo || parsedKey.encryptedContentInfo.contentType !== ID_SIGNED_DATA) throw E("est/bad-key-part", "a server-generated encrypted key's EnvelopedData must encapsulate a CMS SignedData (RFC 7030 sec. 4.4.2)");
|
|
417
|
-
// A detached
|
|
418
|
-
//
|
|
419
|
-
|
|
416
|
+
// A detached OR empty EnvelopedData carries no ciphertext to decrypt -- there is
|
|
417
|
+
// no key to recover, so it is not a valid key response (the shared CMS assert
|
|
418
|
+
// rejects both null and zero-length, matching the CRMF / PKCS#12 siblings).
|
|
419
|
+
cms.assertAttachedCiphertext(parsedKey.encryptedContentInfo, E, "est/bad-key-part", "a server-generated encrypted key's EnvelopedData");
|
|
420
420
|
if (Buffer.isBuffer(opts.expectedRecipientKeyId) || opts.expectedRecipientIssuerSerial) {
|
|
421
421
|
if (!_recipientMatches(parsedKey.recipientInfos, opts)) throw E("est/recipient-mismatch", "the server-generated key is not encrypted to the advertised recipient (RFC 7030 sec. 4.4.2)");
|
|
422
422
|
}
|
package/lib/framework-error.js
CHANGED
|
@@ -272,6 +272,16 @@ var JoseError = defineClass("JoseError", { withCause: true });
|
|
|
272
272
|
// Carries the delegated jose/* or a DER leaf fault as `.cause`.
|
|
273
273
|
var AcmeError = defineClass("AcmeError", { withCause: true });
|
|
274
274
|
|
|
275
|
+
// TrustError -- a Mozilla/NSS certdata.txt or CCADB CSV root-store input that
|
|
276
|
+
// fails ingestion: a malformed or oversized object block, a bad \ooo octal
|
|
277
|
+
// escape, an unrecognized CK_TRUST value, a certificate/trust-object pairing
|
|
278
|
+
// disagreement (metadata must never attach to the wrong root), an undecodable
|
|
279
|
+
// distrust-after time, a CSV framing / required-column violation, a CKA_VALUE
|
|
280
|
+
// or PEM cell that is not a well-formed certificate, or a purpose the entry is
|
|
281
|
+
// not a trusted delegator for. Carries the underlying leaf fault (an x509/* or
|
|
282
|
+
// asn1/* error) as `.cause`.
|
|
283
|
+
var TrustError = defineClass("TrustError", { withCause: true });
|
|
284
|
+
|
|
275
285
|
module.exports = {
|
|
276
286
|
PkiError: PkiError,
|
|
277
287
|
defineClass: defineClass,
|
|
@@ -300,4 +310,5 @@ module.exports = {
|
|
|
300
310
|
EstError: EstError,
|
|
301
311
|
JoseError: JoseError,
|
|
302
312
|
AcmeError: AcmeError,
|
|
313
|
+
TrustError: TrustError,
|
|
303
314
|
};
|
package/lib/guard-all.js
CHANGED
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
// guard.range.int / .uint31 / .positiveInt31
|
|
22
22
|
// -- bound a decoded integer before narrowing
|
|
23
23
|
// to Number (silent-narrowing defence)
|
|
24
|
+
// guard.name.dnEqual / .rdnEqual / .assertNoControlBytes / .assertPrintableIa5
|
|
25
|
+
// -- canonical DN identity + name-string integrity
|
|
26
|
+
// (CVE-2009-2408 truncation / identity defence)
|
|
27
|
+
// guard.encoding.base64url / .base64 / .hex
|
|
28
|
+
// -- strict textual-encoding decode, canonical
|
|
29
|
+
// + capped (encoding-malleability defence)
|
|
30
|
+
// guard.identifier.assertCanonicalOid
|
|
31
|
+
// -- canonical dotted-decimal OID string form
|
|
32
|
+
// (canonicalization-divergence defence)
|
|
24
33
|
//
|
|
25
34
|
// Each shape is enforced by a codebase-patterns detector: the characteristic
|
|
26
35
|
// token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
|
|
@@ -34,6 +43,9 @@ var limits = require("./guard-limits");
|
|
|
34
43
|
var crypto = require("./guard-crypto");
|
|
35
44
|
var range = require("./guard-range");
|
|
36
45
|
var name = require("./guard-name");
|
|
46
|
+
var encoding = require("./guard-encoding");
|
|
47
|
+
var json = require("./guard-json");
|
|
48
|
+
var identifier = require("./guard-identifier");
|
|
37
49
|
|
|
38
50
|
module.exports = {
|
|
39
51
|
bytes: bytes,
|
|
@@ -42,4 +54,7 @@ module.exports = {
|
|
|
42
54
|
crypto: crypto,
|
|
43
55
|
range: range,
|
|
44
56
|
name: name,
|
|
57
|
+
encoding: encoding,
|
|
58
|
+
json: json,
|
|
59
|
+
identifier: identifier,
|
|
45
60
|
};
|
package/lib/guard-crypto.js
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
// lengths, so the gate must precede it) then a single timingSafeEqual over the
|
|
21
21
|
// equal-length buffers -- both operands are always read, no data-dependent
|
|
22
22
|
// branch. A length mismatch is an honest false, never a throw.
|
|
23
|
+
//
|
|
24
|
+
// Also defends the signature-malleability class (CWE-347 / CWE-20): a signature /
|
|
25
|
+
// MAC / digest BIT STRING with a non-zero unused-bits count is not whole octets,
|
|
26
|
+
// so trailing sub-octet bits let two encodings carry "one" signature. Every RFC
|
|
27
|
+
// 9481/4211/6960/5280 signature output is a whole number of octets, so a value
|
|
28
|
+
// with unusedBits !== 0 fails closed BEFORE its bytes reach a verifier.
|
|
23
29
|
|
|
24
30
|
var nodeCrypto = require("node:crypto");
|
|
25
31
|
|
|
@@ -31,4 +37,28 @@ function constantTimeEqual(a, b) {
|
|
|
31
37
|
return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
// isOctetAligned(bitString) -> boolean. True when a decoded BIT STRING (a
|
|
41
|
+
// { bytes, unusedBits } node) is whole octets. For the never-throw verify paths
|
|
42
|
+
// (a boolean/continue verdict) so a malleable signature is skipped, not honored.
|
|
43
|
+
// @enforced-by behavioral -- shares the octet-alignment rule below; a bare
|
|
44
|
+
// .unusedBits check has legitimate non-signature siblings, so the RED vectors
|
|
45
|
+
// (a non-octet-aligned signature is skipped / rejected) are the guard.
|
|
46
|
+
function isOctetAligned(bitString) {
|
|
47
|
+
return !!bitString && bitString.unusedBits === 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// assertOctetAligned(bitString, E, code, label) -> bitString | throws E(code,...).
|
|
51
|
+
// The throwing form for a parse boundary: a signature / MAC / digest BIT STRING
|
|
52
|
+
// MUST be octet-aligned, else the malleable value is rejected fail-closed.
|
|
53
|
+
// @enforced-by behavioral -- .unusedBits !== 0 is a per-field RFC rule, and the
|
|
54
|
+
// same token legitimately tests a DIFFERENT invariant (NamedBitList minimality,
|
|
55
|
+
// schema-engine assertMinimalNamedBits), so a blanket detector would false-fire;
|
|
56
|
+
// the RED vectors (a non-octet-aligned signature/digest rejects) are the guard.
|
|
57
|
+
function assertOctetAligned(bitString, E, code, label) {
|
|
58
|
+
if (!isOctetAligned(bitString)) {
|
|
59
|
+
throw E(code, (label || "signature") + " BIT STRING must be octet-aligned (0 unused bits)");
|
|
60
|
+
}
|
|
61
|
+
return bitString;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { constantTimeEqual: constantTimeEqual, isOctetAligned: isOctetAligned, assertOctetAligned: assertOctetAligned };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- no operator-facing namespace. The documented surface is the
|
|
6
|
+
// consumers whose textual-encoding integrity composes this guard (pki.jose
|
|
7
|
+
// base64url, pki.schema.* PEM bodies, pki.webcrypto JWK key material, pki.acme
|
|
8
|
+
// / pki.est transfer encodings).
|
|
9
|
+
//
|
|
10
|
+
// guard-encoding -- strict decode of an untrusted base64 / base64url / hex text
|
|
11
|
+
// to bytes. Node's Buffer.from(x, "base64"|"base64url"|"hex") is LENIENT: it
|
|
12
|
+
// silently drops the first invalid character (and everything after a stray one),
|
|
13
|
+
// accepts non-canonical trailing bits, and tolerates missing/extra padding, so
|
|
14
|
+
// two distinct texts alias one byte string -- or a malformed text decodes to a
|
|
15
|
+
// SHORTER, different value than intended. And it allocates the whole decode
|
|
16
|
+
// before any size is checked. This is CWE-172 (encoding-transformation error) /
|
|
17
|
+
// CWE-20 (canonicalization malleability) + CWE-770 (allocate-before-cap): RFC
|
|
18
|
+
// 4648 sec. 3.5 / sec. 5 and RFC 8555 sec. 6.1 require the UNIQUE canonical
|
|
19
|
+
// encoding. Each decoder here gates the alphabet, rejects an impossible length,
|
|
20
|
+
// enforces the byte cap BEFORE the copy, decodes, and verifies canonicality by a
|
|
21
|
+
// re-encode round-trip -- so a value that is not the one canonical encoding of
|
|
22
|
+
// its bytes fails closed with the caller's typed error.
|
|
23
|
+
//
|
|
24
|
+
// E is the caller's (code, message) typed-error factory; `code` the frozen
|
|
25
|
+
// domain/reason; `label` the field phrase; `maxBytes` an optional decoded-size
|
|
26
|
+
// cap (null/undefined = uncapped, for a PEM cert body already bounded upstream).
|
|
27
|
+
|
|
28
|
+
var B64URL_ALPHABET = /^[A-Za-z0-9_-]*$/;
|
|
29
|
+
var B64_ALPHABET = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
30
|
+
var HEX_ALPHABET = /^[0-9A-Fa-f]*$/;
|
|
31
|
+
|
|
32
|
+
// Reject before Buffer.from allocates: a base64 text of N chars decodes to at
|
|
33
|
+
// most floor(N*3/4) bytes, a hex text to N/2.
|
|
34
|
+
function _capBefore(nChars, perByteChars, maxBytes, E, code, label) {
|
|
35
|
+
if (maxBytes == null) return; // documented uncapped mode (bounded upstream)
|
|
36
|
+
// The cap is an authoring input: a NaN / fractional / negative maxBytes makes
|
|
37
|
+
// the comparison below always false -- the cap silently disabled. Config-time
|
|
38
|
+
// TypeError instead (null/undefined stays the documented uncapped mode).
|
|
39
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 0) {
|
|
40
|
+
throw new TypeError("guard.encoding: maxBytes must be a non-negative integer or null");
|
|
41
|
+
}
|
|
42
|
+
if (Math.floor(nChars / perByteChars) > maxBytes) {
|
|
43
|
+
throw E(code, label + " exceeds the maximum decoded size of " + maxBytes + " bytes");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// base64url(text, maxBytes, E, code, label) -> Buffer. Unpadded base64url (RFC
|
|
48
|
+
// 4648 sec. 5 / RFC 7515): no "=" padding, no "+"/"/", canonical final char.
|
|
49
|
+
// @enforced-by base64-decode-not-via-guard
|
|
50
|
+
function base64url(text, maxBytes, E, code, label) {
|
|
51
|
+
if (typeof text !== "string") throw E(code, label + " must be a string");
|
|
52
|
+
if (!B64URL_ALPHABET.test(text)) throw E(code, label + " is not base64url (padding or a non-alphabet character)");
|
|
53
|
+
if (text.length % 4 === 1) throw E(code, label + " has an impossible base64url length");
|
|
54
|
+
_capBefore(text.length * 3, 4, maxBytes, E, code, label);
|
|
55
|
+
var buf = Buffer.from(text, "base64url");
|
|
56
|
+
if (buf.toString("base64url") !== text) throw E(code, label + " is not canonical base64url");
|
|
57
|
+
return buf;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// base64(text, maxBytes, E, code, label) -> Buffer. Padded base64 (RFC 4648
|
|
61
|
+
// sec. 4): whole 4-character groups, canonical padding + trailing bits.
|
|
62
|
+
// @enforced-by base64-decode-not-via-guard
|
|
63
|
+
function base64(text, maxBytes, E, code, label) {
|
|
64
|
+
if (typeof text !== "string") throw E(code, label + " must be a string");
|
|
65
|
+
if (!B64_ALPHABET.test(text)) throw E(code, label + " is not base64 (a non-alphabet character)");
|
|
66
|
+
if (text.length % 4 !== 0) throw E(code, label + " must be whole 4-character base64 groups (RFC 4648 sec. 3.5)");
|
|
67
|
+
_capBefore(text.length * 3, 4, maxBytes, E, code, label);
|
|
68
|
+
var buf = Buffer.from(text, "base64");
|
|
69
|
+
if (buf.toString("base64") !== text) throw E(code, label + " is not canonical base64 (RFC 4648 sec. 3.5)");
|
|
70
|
+
return buf;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// hex(text, maxBytes, E, code, label) -> Buffer. Even-length hex; canonical via
|
|
74
|
+
// a lower-case re-encode round-trip (RFC 4514 sec. 2.4 hex-string values).
|
|
75
|
+
// @enforced-by behavioral -- the hex Buffer.from token has legitimate non-decode
|
|
76
|
+
// siblings in the tree (a hard-coded AES-KW IV constant, an internal
|
|
77
|
+
// parser-emitted canonical serial, an internal BigInt->hex), so a blanket
|
|
78
|
+
// Buffer.from(x,"hex") detector would false-fire; the RED conformance vectors
|
|
79
|
+
// (a non-canonical / odd-length / non-hex #hex attribute value rejects) guard it.
|
|
80
|
+
function hex(text, maxBytes, E, code, label) {
|
|
81
|
+
if (typeof text !== "string") throw E(code, label + " must be a string");
|
|
82
|
+
if (!HEX_ALPHABET.test(text)) throw E(code, label + " is not hexadecimal");
|
|
83
|
+
if (text.length % 2 !== 0) throw E(code, label + " must have an even number of hex digits");
|
|
84
|
+
_capBefore(text.length, 2, maxBytes, E, code, label);
|
|
85
|
+
var buf = Buffer.from(text, "hex");
|
|
86
|
+
if (buf.toString("hex") !== text.toLowerCase()) throw E(code, label + " is not canonical hexadecimal");
|
|
87
|
+
return buf;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { base64url: base64url, base64: base64, hex: hex };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- no operator-facing namespace. The documented surface is the
|
|
6
|
+
// consumers whose identifier-string integrity composes this guard (pki.oid
|
|
7
|
+
// name/arc resolution, pki.asn1 OID encoding, pki.path.validate requiredEku /
|
|
8
|
+
// userInitialPolicySet key checking).
|
|
9
|
+
//
|
|
10
|
+
// guard-identifier -- fail-closed canonicalization of the structured-identifier
|
|
11
|
+
// STRINGS the toolkit compares and encodes. An identifier used as a lookup /
|
|
12
|
+
// comparison key must be in one canonical form, or the string form and the
|
|
13
|
+
// decoded form of the same identifier silently disagree.
|
|
14
|
+
//
|
|
15
|
+
// Defends the canonicalization-divergence class (CWE-20): a dotted-decimal OID
|
|
16
|
+
// with a leading-zero arc ("2.05.29.15") round-trips to a DIFFERENT OID
|
|
17
|
+
// ("2.5.29.15"), and an arc outside the X.660 bounds can never DER-encode at all.
|
|
18
|
+
// A hand-rolled string check that accepts either lets `build.oid` emit bytes that
|
|
19
|
+
// decode to a different OID than the string names, or lets a non-canonical policy /
|
|
20
|
+
// EKU key compare unequal to the canonical decoder output it is matched against (a
|
|
21
|
+
// silent false-reject). Every string-form identifier check routes through here so
|
|
22
|
+
// the string and DER forms cannot diverge.
|
|
23
|
+
|
|
24
|
+
// assertCanonicalOid(str, E, code, label, boundsCode) -> str | throws
|
|
25
|
+
// A canonical dotted-decimal object identifier string: two or more arcs, each a
|
|
26
|
+
// non-negative decimal integer with no leading zero (the SYNTAX), and -- unless
|
|
27
|
+
// bounds are waived -- the root arc 0..2 with the second arc 0..39 under roots 0
|
|
28
|
+
// and 1 (the X.660 ENCODABILITY bounds: the first two arcs pack into a single
|
|
29
|
+
// sub-identifier as 40*root+second). E is the (code, message) typed-error factory:
|
|
30
|
+
// - a syntax fault always throws E(code, ...).
|
|
31
|
+
// - boundsCode === null waives the arc-bound check -- for a LOOKUP key (oid.name
|
|
32
|
+
// / oid.has), where a well-formed but non-encodable OID is simply not
|
|
33
|
+
// registered (a miss), not an error.
|
|
34
|
+
// - otherwise an out-of-range arc throws E(boundsCode, ...) (boundsCode defaults
|
|
35
|
+
// to code), so a caller distinguishing the two reasons (oid.js: oid/bad-input
|
|
36
|
+
// syntax vs oid/bad-arc bounds) keeps both codes.
|
|
37
|
+
// @enforced-by behavioral -- string-form OID canonicalization has no rename-proof
|
|
38
|
+
// code shape distinct from the arc-based bounds check oid.js legitimately keeps
|
|
39
|
+
// for the arc path; the divergence RED vectors (build.oid / oid.toArcs / requiredEku
|
|
40
|
+
// reject a non-canonical OID) driving the composing consumers are the guard.
|
|
41
|
+
function assertCanonicalOid(str, E, code, label, boundsCode) {
|
|
42
|
+
var who = label || "OID";
|
|
43
|
+
if (typeof str !== "string" || !/^(0|[1-9]\d*)(\.(0|[1-9]\d*))+$/.test(str)) {
|
|
44
|
+
throw E(code, who + " must be a canonical dotted-decimal OID string of two or more arcs with no leading-zero component");
|
|
45
|
+
}
|
|
46
|
+
if (boundsCode === null) return str;
|
|
47
|
+
var bcode = boundsCode === undefined ? code : boundsCode;
|
|
48
|
+
var parts = str.split(".");
|
|
49
|
+
var root = BigInt(parts[0]);
|
|
50
|
+
var second = BigInt(parts[1]);
|
|
51
|
+
if (root > 2n) throw E(bcode, who + " root arc must be 0, 1, or 2 (X.660)");
|
|
52
|
+
if (root < 2n && second > 39n) throw E(bcode, who + " second arc must be 0..39 under roots 0 and 1 (X.660)");
|
|
53
|
+
return str;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { assertCanonicalOid: assertCanonicalOid };
|