@blamejs/pki 0.1.29 → 0.1.31
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 +23 -1
- package/lib/asn1-der.js +8 -31
- package/lib/cbor-det.js +5 -31
- package/lib/ct.js +2 -13
- package/lib/est.js +15 -6
- package/lib/framework-error.js +4 -3
- package/lib/guard-all.js +45 -0
- package/lib/guard-bytes.js +60 -0
- package/lib/guard-crypto.js +34 -0
- package/lib/guard-limits.js +56 -0
- package/lib/guard-name.js +53 -0
- package/lib/guard-range.js +69 -0
- package/lib/guard-text.js +66 -0
- package/lib/jose.js +13 -18
- package/lib/merkle.js +3 -12
- package/lib/oid.js +2 -1
- package/lib/path-validate.js +4 -9
- package/lib/schema-all.js +2 -2
- package/lib/schema-attrcert.js +3 -3
- package/lib/schema-cmp.js +5 -6
- package/lib/schema-crmf.js +3 -3
- package/lib/schema-csrattrs.js +1 -1
- package/lib/schema-pkcs12.js +11 -17
- package/lib/schema-pkix.js +32 -39
- package/lib/webcrypto.js +8 -25
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,29 @@ 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.
|
|
7
|
+
## v0.1.31 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
The DER format cohort and the JOSE surface graduate to stable.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- pki.schema.pkcs12 / attrcert / crmf / cmp / csrattrs (parse, pemDecode, pemEncode), pki.schema.all and pki.schema.parse, and the pki.jose signing / verification / thumbprint / base64url / JSON surface graduate from experimental to stable.
|
|
14
|
+
- The LTS-CALENDAR graduation criterion now states that a settled, well-tested format no mainstream tool implements graduates on the toolkit's own conformance-vector round-trip plus coverage-guided fuzzing, rather than on a harness oracle that does not exist.
|
|
15
|
+
|
|
16
|
+
## v0.1.30 — 2026-07-11
|
|
17
|
+
|
|
18
|
+
Fail-closed hardening of the byte-input and text-decode boundaries.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- A detached-backed BufferSource (a transferred / structuredClone'd view) passed to any DER format parser -- pki.schema.x509 / crl / csr / pkcs8 / cms / pkcs12 -- fails closed with the format's typed bad-input error at the shared parse-input boundary rather than being decoded as an empty buffer.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- The EST transfer and multipart-mixed decoders enforce their size cap on the raw byte length before decoding the payload to a string, and an HTTP error response body is decoded only up to the prefix shown in the message -- closing a single-input string-allocation amplification where an oversized body was materialized in full before the cap rejected it.
|
|
27
|
+
- pki.oid.fromDER rejects a non-Buffer or detached-backed input with a typed oid/bad-input error instead of a raw TypeError.
|
|
28
|
+
|
|
29
|
+
## v0.1.29 — 2026-07-10
|
|
8
30
|
|
|
9
31
|
A detached-backed BufferSource now fails closed with a typed error at every byte-input boundary.
|
|
10
32
|
|
package/lib/asn1-der.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
var constants = require("./constants");
|
|
34
34
|
var frameworkError = require("./framework-error");
|
|
35
|
+
var guard = require("./guard-all");
|
|
35
36
|
|
|
36
37
|
var Asn1Error = frameworkError.Asn1Error;
|
|
37
38
|
var OidError = frameworkError.OidError;
|
|
@@ -105,19 +106,7 @@ function _className(bits) {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
function _asBuffer(input, who) {
|
|
108
|
-
|
|
109
|
-
// NOT a `Buffer.isBuffer` fast-path that returns it as-is. A detached backing
|
|
110
|
-
// ArrayBuffer (a transferred / structuredClone'd view) reads as zero-length;
|
|
111
|
-
// re-viewing through Buffer.from surfaces it as a typed error here rather than
|
|
112
|
-
// a misleading downstream truncated-DER verdict on an empty buffer.
|
|
113
|
-
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
114
|
-
try {
|
|
115
|
-
return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
116
|
-
} catch (e) {
|
|
117
|
-
throw new Asn1Error("asn1/not-buffer", who + ": input is not a usable byte view (detached backing buffer?)", e);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
throw new Asn1Error("asn1/not-buffer", who + ": expected a Buffer / Uint8Array");
|
|
109
|
+
return guard.bytes.view(input, Asn1Error, "asn1/not-buffer", who);
|
|
121
110
|
}
|
|
122
111
|
|
|
123
112
|
// ---- Decoder --------------------------------------------------------
|
|
@@ -160,27 +149,15 @@ function _asBuffer(input, who) {
|
|
|
160
149
|
* var node = pki.asn1.decode(der);
|
|
161
150
|
* node.tagNumber === pki.asn1.TAGS.SEQUENCE;
|
|
162
151
|
*/
|
|
163
|
-
// A size/depth cap opt: absent means the constants.LIMITS default; a provided
|
|
164
|
-
// value must be a non-negative finite integer. A non-finite cap (NaN, Infinity)
|
|
165
|
-
// silently DISABLES the guard -- every `>` compare against it is false -- so a
|
|
166
|
-
// deeply nested input would run to a bare RangeError instead of the typed
|
|
167
|
-
// asn1/too-deep verdict. A bad cap is a config fault: throw at entry.
|
|
168
|
-
function _capOpt(v, key, dflt) {
|
|
169
|
-
if (v === undefined) return dflt;
|
|
170
|
-
if (typeof v !== "number" || !isFinite(v) || v < 0 || Math.floor(v) !== v) {
|
|
171
|
-
throw new TypeError("decode: " + key + " must be a non-negative integer");
|
|
172
|
-
}
|
|
173
|
-
return v;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
152
|
function decode(input, opts) {
|
|
177
153
|
opts = opts || {};
|
|
178
154
|
var buf = _asBuffer(input, "decode");
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
155
|
+
// A size/depth cap opt: absent means the constants.LIMITS default; a provided
|
|
156
|
+
// value must be a non-negative integer (guard.limits.cap). depthCap also
|
|
157
|
+
// refuses a maxDepth above the stack-safe ceiling so a raised cap cannot drive
|
|
158
|
+
// recursive descent into a bare RangeError instead of the typed asn1/too-deep.
|
|
159
|
+
var maxBytes = guard.limits.cap(opts.maxBytes, "maxBytes", constants.LIMITS.DER_MAX_BYTES);
|
|
160
|
+
var maxDepth = guard.limits.depthCap(opts.maxDepth, "maxDepth", constants.LIMITS.DER_MAX_DEPTH);
|
|
184
161
|
if (buf.length > maxBytes) {
|
|
185
162
|
throw new Asn1Error("asn1/too-large", "input " + buf.length + " bytes exceeds cap " + maxBytes);
|
|
186
163
|
}
|
package/lib/cbor-det.js
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
var constants = require("./constants");
|
|
38
38
|
var frameworkError = require("./framework-error");
|
|
39
39
|
var asn1 = require("./asn1-der");
|
|
40
|
+
var guard = require("./guard-all");
|
|
40
41
|
|
|
41
42
|
var CborError = frameworkError.CborError;
|
|
42
43
|
|
|
@@ -57,31 +58,7 @@ var _utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
|
57
58
|
var _MAX_EPOCH_SECONDS = 8640000000000n;
|
|
58
59
|
|
|
59
60
|
function _asBuffer(input, who) {
|
|
60
|
-
|
|
61
|
-
// -- NOT a `Buffer.isBuffer` fast-path that would return it as-is. A detached
|
|
62
|
-
// backing ArrayBuffer (a transferred / structuredClone'd view) has had its
|
|
63
|
-
// bytes removed and reads as zero-length; the fast-path would hand the byte
|
|
64
|
-
// walk an empty buffer. Always re-view through Buffer.from so a detached input
|
|
65
|
-
// fails closed here as a typed error rather than a raw TypeError deeper in the
|
|
66
|
-
// walk (or a misleading truncated-input verdict).
|
|
67
|
-
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
68
|
-
try {
|
|
69
|
-
return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
70
|
-
} catch (e) {
|
|
71
|
-
throw new CborError("cbor/not-buffer", who + ": input is not a usable byte view (detached backing buffer?)", e);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
throw new CborError("cbor/not-buffer", who + ": expected a Buffer / Uint8Array");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Config-time cap validation: a bad cap is an authoring bug, so it throws a
|
|
78
|
-
// TypeError (not a CborError) exactly like asn1-der's _capOpt.
|
|
79
|
-
function _capOpt(v, key, dflt) {
|
|
80
|
-
if (v === undefined) return dflt;
|
|
81
|
-
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
82
|
-
throw new TypeError("decode: " + key + " must be a non-negative integer");
|
|
83
|
-
}
|
|
84
|
-
return v;
|
|
61
|
+
return guard.bytes.view(input, CborError, "cbor/not-buffer", who);
|
|
85
62
|
}
|
|
86
63
|
|
|
87
64
|
// Determinism profile -> a ruleset. v1 whitelists only "deterministic"
|
|
@@ -283,12 +260,9 @@ function _decodeItem(buf, start, limit, depth, maxD, rules, state) {
|
|
|
283
260
|
function decode(input, opts) {
|
|
284
261
|
opts = opts || {};
|
|
285
262
|
var buf = _asBuffer(input, "decode");
|
|
286
|
-
var maxBytes =
|
|
287
|
-
var maxDepth =
|
|
288
|
-
|
|
289
|
-
throw new TypeError("decode: maxDepth " + maxDepth + " exceeds the stack-safe ceiling " + constants.LIMITS.MAX_DECODE_DEPTH_CEILING);
|
|
290
|
-
}
|
|
291
|
-
var maxItems = _capOpt(opts.maxItems, "maxItems", constants.LIMITS.CBOR_MAX_ITEMS);
|
|
263
|
+
var maxBytes = guard.limits.cap(opts.maxBytes, "maxBytes", constants.LIMITS.CBOR_MAX_BYTES);
|
|
264
|
+
var maxDepth = guard.limits.depthCap(opts.maxDepth, "maxDepth", constants.LIMITS.CBOR_MAX_DEPTH);
|
|
265
|
+
var maxItems = guard.limits.cap(opts.maxItems, "maxItems", constants.LIMITS.CBOR_MAX_ITEMS);
|
|
292
266
|
var rules = _profile(opts.profile);
|
|
293
267
|
if (buf.length > maxBytes) throw new CborError("cbor/too-large", "input " + buf.length + " bytes exceeds cap " + maxBytes);
|
|
294
268
|
var r = _decodeItem(buf, 0, buf.length, 0, maxDepth, rules, { n: 0, max: maxItems });
|
package/lib/ct.js
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
var asn1 = require("./asn1-der.js");
|
|
42
42
|
var constants = require("./constants.js");
|
|
43
43
|
var frameworkError = require("./framework-error.js");
|
|
44
|
+
var guard = require("./guard-all.js");
|
|
44
45
|
|
|
45
46
|
var CtError = frameworkError.CtError;
|
|
46
47
|
var C = constants;
|
|
@@ -111,19 +112,7 @@ function _peelInner(extValue) {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
function _toBuffer(v, field) {
|
|
114
|
-
|
|
115
|
-
// NOT a `Buffer.isBuffer` fast-path that returns it as-is. A detached backing
|
|
116
|
-
// ArrayBuffer (a transferred / structuredClone'd view) reads as zero-length;
|
|
117
|
-
// re-viewing through Buffer.from surfaces it as a typed error here rather than
|
|
118
|
-
// a misleading downstream bad-DER verdict on an empty buffer.
|
|
119
|
-
if (Buffer.isBuffer(v) || v instanceof Uint8Array) {
|
|
120
|
-
try {
|
|
121
|
-
return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
122
|
-
} catch (e) {
|
|
123
|
-
throw new CtError("ct/bad-input", field + " is not a usable byte view (detached backing buffer?)", e);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
throw new CtError("ct/bad-input", field + " must be a Buffer or Uint8Array");
|
|
115
|
+
return guard.bytes.view(v, CtError, "ct/bad-input", field);
|
|
127
116
|
}
|
|
128
117
|
|
|
129
118
|
// Parse one SerializedSCT body inside a sub-reader bounded to the element (so a
|
package/lib/est.js
CHANGED
|
@@ -50,6 +50,7 @@ var crl = require("./schema-crl");
|
|
|
50
50
|
var pkcs8 = require("./schema-pkcs8");
|
|
51
51
|
var csr = require("./schema-csr");
|
|
52
52
|
var frameworkError = require("./framework-error");
|
|
53
|
+
var guard = require("./guard-all");
|
|
53
54
|
|
|
54
55
|
var EstError = frameworkError.EstError;
|
|
55
56
|
function E(code, message, cause) { return new EstError(code, message, cause); }
|
|
@@ -128,14 +129,16 @@ function _httpDateMs(s) {
|
|
|
128
129
|
* var roundTripped = pki.est.transferDecode(pki.est.transferEncode(der));
|
|
129
130
|
*/
|
|
130
131
|
function transferDecode(body) {
|
|
131
|
-
var s = Buffer.isBuffer(body) ? body.toString("latin1") : String(body);
|
|
132
132
|
// The pre-decode ceiling: the largest base64 that could yield a DER_MAX_BYTES
|
|
133
133
|
// document, plus a generous line-wrapping allowance (CRLF at 64/76-char lines is
|
|
134
134
|
// ~3%; 1/8 leaves ample margin) so a normally-wrapped near-limit body is not
|
|
135
135
|
// rejected before the real DER_MAX_BYTES limit is enforced on the decode below.
|
|
136
|
+
// guard.text.decode caps the raw byte length BEFORE the latin1 copy.
|
|
136
137
|
var b64Len = Math.ceil(constants.LIMITS.DER_MAX_BYTES * 4 / 3);
|
|
137
138
|
var cap = b64Len + Math.ceil(b64Len / 8) + constants.BYTES.kib(64);
|
|
138
|
-
|
|
139
|
+
var s = guard.text.decode(body, cap, EstError, {
|
|
140
|
+
charset: "latin1", tooLarge: "est/too-large", badInput: "est/bad-input", label: "the EST payload",
|
|
141
|
+
});
|
|
139
142
|
var stripped = s.replace(/[\r\n \t]/g, "");
|
|
140
143
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(stripped)) throw E("est/bad-base64", "the EST payload is not RFC 4648 base64 (RFC 8951 sec. 3.1)");
|
|
141
144
|
var der = Buffer.from(stripped, "base64");
|
|
@@ -186,8 +189,9 @@ function _multipartBoundary(contentType) {
|
|
|
186
189
|
function splitMultipartMixed(body, contentType) {
|
|
187
190
|
var boundary = _multipartBoundary(contentType);
|
|
188
191
|
if (!boundary) throw E("est/bad-multipart", "a serverkeygen response must be multipart/mixed with a boundary (RFC 7030 sec. 4.4.2)");
|
|
189
|
-
var text =
|
|
190
|
-
|
|
192
|
+
var text = guard.text.decode(body, constants.LIMITS.DER_MAX_BYTES * 2, EstError, {
|
|
193
|
+
charset: "latin1", tooLarge: "est/too-large", badInput: "est/bad-input", label: "the multipart body",
|
|
194
|
+
});
|
|
191
195
|
var esc = boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
192
196
|
var delimRe = new RegExp("(?:^|\\r?\\n)--" + esc + "(--)?[ \\t]*(?:\\r?\\n|$)", "g");
|
|
193
197
|
var marks = [], m;
|
|
@@ -526,8 +530,13 @@ function classifyResponse(status, headers, body, opts) {
|
|
|
526
530
|
}
|
|
527
531
|
if (status >= 300 && status < 400) return { status: "redirect", location: h["location"] || null };
|
|
528
532
|
if (status >= 400) {
|
|
529
|
-
|
|
530
|
-
|
|
533
|
+
// Decode only a bounded prefix for the message -- a huge error body must not
|
|
534
|
+
// be materialized as a full string just to show its first 512 characters.
|
|
535
|
+
// toString(enc, start, end) bounds the decode WITHOUT constructing a subarray
|
|
536
|
+
// view, so a detached body reads as "" here rather than throwing a raw
|
|
537
|
+
// TypeError that would mask the est/http-error verdict.
|
|
538
|
+
var text = Buffer.isBuffer(body) ? body.toString("utf8", 0, 512) : String(body || "").slice(0, 512);
|
|
539
|
+
throw E("est/http-error", "EST server returned HTTP " + status + (text ? ": " + text : ""));
|
|
531
540
|
}
|
|
532
541
|
return { status: "unexpected", httpStatus: status };
|
|
533
542
|
}
|
package/lib/framework-error.js
CHANGED
|
@@ -143,12 +143,13 @@ var CborError = defineClass("CborError", { withCause: true });
|
|
|
143
143
|
|
|
144
144
|
// OidError -- a malformed object-identifier: fewer than two arcs, a first
|
|
145
145
|
// arc outside 0..2, a second arc >= 40 under arcs 0/1, or a non-minimal
|
|
146
|
-
// base-128 sub-identifier.
|
|
147
|
-
|
|
146
|
+
// base-128 sub-identifier. withCause threads a raw byte-view failure (a detached
|
|
147
|
+
// backing ArrayBuffer at fromDER) as the cause rather than discarding it.
|
|
148
|
+
var OidError = defineClass("OidError", { withCause: true });
|
|
148
149
|
|
|
149
150
|
// PemError -- a malformed PEM envelope: missing / mismatched BEGIN/END
|
|
150
151
|
// markers, a bad label, or non-base64 body.
|
|
151
|
-
var PemError = defineClass("PemError");
|
|
152
|
+
var PemError = defineClass("PemError", { withCause: true });
|
|
152
153
|
|
|
153
154
|
// CertificateError -- a byte sequence that is not a well-formed X.509
|
|
154
155
|
// certificate (wrong outer structure, unparseable field, unsupported
|
package/lib/guard-all.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- no operator-facing namespace. This is lib composition only; the
|
|
6
|
+
// documented surface is the primitives whose boundaries compose these guards.
|
|
7
|
+
//
|
|
8
|
+
// guard-all -- the guard-family orchestrator (schema-all's role for the guard
|
|
9
|
+
// family). It assembles the per-shape fail-closed guards into one namespaced
|
|
10
|
+
// surface every input boundary composes, so a boundary depends on the family
|
|
11
|
+
// rather than re-implementing a guard inline:
|
|
12
|
+
//
|
|
13
|
+
// guard.bytes.view / .source -- untrusted byte-source -> Buffer re-view
|
|
14
|
+
// (detached-buffer fail-open defence)
|
|
15
|
+
// guard.text.decode -- byte-source -> string, cap BEFORE copy
|
|
16
|
+
// (parser-DoS string-allocation defence)
|
|
17
|
+
// guard.limits.cap / .depthCap -- config-time resource-cap validation
|
|
18
|
+
// (recursion / allocation DoS defence)
|
|
19
|
+
// guard.crypto.constantTimeEqual -- length-checked constant-time compare
|
|
20
|
+
// (timing side-channel defence)
|
|
21
|
+
// guard.range.int / .uint31 / .positiveInt31
|
|
22
|
+
// -- bound a decoded integer before narrowing
|
|
23
|
+
// to Number (silent-narrowing defence)
|
|
24
|
+
//
|
|
25
|
+
// Each shape is enforced by a codebase-patterns detector: the characteristic
|
|
26
|
+
// token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
|
|
27
|
+
// timingSafeEqual call, the MAX_DECODE_DEPTH_CEILING check) must appear ONLY in
|
|
28
|
+
// its guard module, so a new boundary cannot re-inline the shape and forget the
|
|
29
|
+
// defence.
|
|
30
|
+
|
|
31
|
+
var bytes = require("./guard-bytes");
|
|
32
|
+
var text = require("./guard-text");
|
|
33
|
+
var limits = require("./guard-limits");
|
|
34
|
+
var crypto = require("./guard-crypto");
|
|
35
|
+
var range = require("./guard-range");
|
|
36
|
+
var name = require("./guard-name");
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
bytes: bytes,
|
|
40
|
+
text: text,
|
|
41
|
+
limits: limits,
|
|
42
|
+
crypto: crypto,
|
|
43
|
+
range: range,
|
|
44
|
+
name: name,
|
|
45
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
// primitives whose input boundaries compose these guards (pki.asn1.decode,
|
|
7
|
+
// pki.cbor.decode, pki.ct.parseSctList, pki.webcrypto.*).
|
|
8
|
+
//
|
|
9
|
+
// guard-bytes -- fail-closed coercion of an untrusted byte-source input to a
|
|
10
|
+
// Buffer view. One of the enforced choke points of the guard family: a
|
|
11
|
+
// codebase-patterns detector requires every byte-input boundary to route
|
|
12
|
+
// through here, so the defence below cannot be forgotten at a new boundary.
|
|
13
|
+
//
|
|
14
|
+
// Defends the detached-buffer fail-OPEN: a transferred / structuredClone'd
|
|
15
|
+
// Buffer or view has a detached backing ArrayBuffer and reads as ZERO-LENGTH.
|
|
16
|
+
// An identity fast-path (`Buffer.isBuffer(x) return x`) that skips the re-view
|
|
17
|
+
// hands the caller an empty buffer, so a downstream digest / signature / parse
|
|
18
|
+
// silently processes EMPTY input instead of failing (CWE-20 improper input
|
|
19
|
+
// validation feeding a CWE-347-style verification-of-nothing). Always re-viewing
|
|
20
|
+
// through Buffer.from(x.buffer, x.byteOffset, x.byteLength) turns the detached
|
|
21
|
+
// read into a typed reject at the boundary. Size / length-field allocation
|
|
22
|
+
// bounds (CWE-770 / CWE-400, the parser-DoS class) are NOT enforced here -- they
|
|
23
|
+
// are per-format (a multi-MB CRL is legitimate, a Merkle proof is tiny), so they
|
|
24
|
+
// live in guard-params and each decoder's own cap.
|
|
25
|
+
|
|
26
|
+
// view(input, ErrorClass, code, label) -> Buffer view | throws ErrorClass(code, msg, cause)
|
|
27
|
+
// Accepts a Buffer / Uint8Array -- the DER / CBOR / CT / Merkle input contract.
|
|
28
|
+
// ErrorClass MUST be a withCause PkiError subclass (the raw detach failure is
|
|
29
|
+
// threaded as the cause).
|
|
30
|
+
// @enforced-by guard-shape-reinlined
|
|
31
|
+
// @guard-shape Buffer\.from\(\s*([A-Za-z_$][\w$]*)\.buffer\s*,\s*\1\.byteOffset
|
|
32
|
+
function view(input, ErrorClass, code, label) {
|
|
33
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
34
|
+
try {
|
|
35
|
+
return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
throw new ErrorClass(code, label + ": input is not a usable byte view (detached backing buffer?)", e);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new ErrorClass(code, label + ": expected a Buffer / Uint8Array");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// source(input, ErrorClass, code, label) -> Buffer | throws ErrorClass
|
|
44
|
+
// Accepts the full W3C BufferSource (Buffer / TypedArray view / raw ArrayBuffer)
|
|
45
|
+
// -- the WebCrypto input contract. A raw ArrayBuffer is copied via Buffer.from,
|
|
46
|
+
// which also throws on a detached backing store.
|
|
47
|
+
// @enforced-by guard-shape-reinlined (the re-view shape is declared on view above)
|
|
48
|
+
function source(input, ErrorClass, code, label) {
|
|
49
|
+
var isAb = input instanceof ArrayBuffer;
|
|
50
|
+
if (isAb || ArrayBuffer.isView(input)) {
|
|
51
|
+
try {
|
|
52
|
+
return isAb ? Buffer.from(input) : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new ErrorClass(code, label + ": input is not a usable byte source (detached backing buffer?)", e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new ErrorClass(code, label + ": expected a BufferSource (ArrayBuffer / TypedArray / Buffer)");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { view: view, source: source };
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
// verify paths whose comparison composes this guard (pki.merkle,
|
|
7
|
+
// pki.webcrypto.subtle.verify).
|
|
8
|
+
//
|
|
9
|
+
// guard-crypto -- fail-closed cryptographic-invariant guards. Enforced choke
|
|
10
|
+
// point: a codebase-patterns detector requires every constant-time byte
|
|
11
|
+
// comparison to route through here, so no verify path re-implements it with a
|
|
12
|
+
// timing leak.
|
|
13
|
+
//
|
|
14
|
+
// Defends the timing side-channel class (CWE-208 observable timing discrepancy;
|
|
15
|
+
// the Lucky13 / MAC-forgery family). A secret-dependent byte comparison with an
|
|
16
|
+
// early exit (=== or a short-circuited &&) leaks the position of the first
|
|
17
|
+
// differing byte, letting an attacker recover a MAC / tag / tree root
|
|
18
|
+
// byte-by-byte. constantTimeEqual runs the whole comparison: a public length
|
|
19
|
+
// gate (lengths are not secret; crypto.timingSafeEqual RangeErrors on unequal
|
|
20
|
+
// lengths, so the gate must precede it) then a single timingSafeEqual over the
|
|
21
|
+
// equal-length buffers -- both operands are always read, no data-dependent
|
|
22
|
+
// branch. A length mismatch is an honest false, never a throw.
|
|
23
|
+
|
|
24
|
+
var nodeCrypto = require("node:crypto");
|
|
25
|
+
|
|
26
|
+
// constantTimeEqual(a, b) -> boolean. Length-checked, then constant-time over
|
|
27
|
+
// equal lengths. a and b are Buffers.
|
|
28
|
+
// @enforced-by guard-shape-reinlined
|
|
29
|
+
// @guard-shape \.timingSafeEqual\s*\(
|
|
30
|
+
function constantTimeEqual(a, b) {
|
|
31
|
+
return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { constantTimeEqual: constantTimeEqual };
|
|
@@ -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
|
+
// decoders whose option validation composes these guards (pki.asn1.decode,
|
|
7
|
+
// pki.cbor.decode).
|
|
8
|
+
//
|
|
9
|
+
// guard-limits -- fail-closed validation of operator-supplied decode limits at
|
|
10
|
+
// config time (the tier that throws on a bad argument so the operator catches
|
|
11
|
+
// the typo at boot). Enforced choke point: a codebase-patterns detector requires
|
|
12
|
+
// the stack-safe recursion ceiling to live only here, so a new decoder cannot be
|
|
13
|
+
// configured past it.
|
|
14
|
+
//
|
|
15
|
+
// Defends the parser-DoS / resource-exhaustion class (CWE-834 excessive
|
|
16
|
+
// recursion, CWE-770 allocation without limits; MITRE T1499.001; D3FEND
|
|
17
|
+
// Recursion-Bounding + Input-Size-Restriction). A recursive-descent decoder
|
|
18
|
+
// bounded only by a caller-tunable maxDepth overflows the native call stack (a
|
|
19
|
+
// raw RangeError, not a typed verdict) once the cap is raised past the frame
|
|
20
|
+
// limit; depthCap refuses a maxDepth above the stack-safe ceiling. A bad limit
|
|
21
|
+
// is an authoring bug, so this tier throws a TypeError (not a PkiError). NIST
|
|
22
|
+
// 800-53 SI-10 / SC-5 do not require these bounds (framework lag) -- the bound
|
|
23
|
+
// at the parser is the actual control.
|
|
24
|
+
|
|
25
|
+
var constants = require("./constants");
|
|
26
|
+
|
|
27
|
+
// cap(value, key, dflt) -> a validated non-negative integer, or dflt when value
|
|
28
|
+
// is undefined. A non-integer / non-finite / negative value is a config-time
|
|
29
|
+
// TypeError (Number.isInteger already rejects non-numbers, NaN, and Infinity).
|
|
30
|
+
// @enforced-by behavioral -- a config-time cap validator has no rename-proof code
|
|
31
|
+
// shape; the decode-rejects-a-bad-cap RED vectors (asn1/cbor) are the guard.
|
|
32
|
+
function cap(value, key, dflt) {
|
|
33
|
+
if (value === undefined) return dflt;
|
|
34
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
35
|
+
throw new TypeError("decode: " + key + " must be a non-negative integer");
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// depthCap(value, key, dflt) -> a validated recursion-depth cap: cap() plus the
|
|
41
|
+
// stack-safe ceiling, so a raised maxDepth cannot drive recursive descent into a
|
|
42
|
+
// raw RangeError past the native frame limit.
|
|
43
|
+
// @enforced-by guard-shape-reinlined
|
|
44
|
+
// @guard-scope file
|
|
45
|
+
// @guard-shape \bopts\.maxDepth\b
|
|
46
|
+
// @guard-shape \bdepth\s*\+\s*1\b
|
|
47
|
+
// @guard-via \.depthCap\s*\(
|
|
48
|
+
function depthCap(value, key, dflt) {
|
|
49
|
+
var n = cap(value, key, dflt);
|
|
50
|
+
if (n > constants.LIMITS.MAX_DECODE_DEPTH_CEILING) {
|
|
51
|
+
throw new TypeError("decode: " + key + " " + n + " exceeds the stack-safe ceiling " + constants.LIMITS.MAX_DECODE_DEPTH_CEILING);
|
|
52
|
+
}
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { cap: cap, depthCap: depthCap };
|
|
@@ -0,0 +1,53 @@
|
|
|
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 name-string integrity composes this guard (pki.path.validate
|
|
7
|
+
// DN chaining, pki.schema.x509 GeneralName / SAN decode).
|
|
8
|
+
//
|
|
9
|
+
// guard-name -- fail-closed rejection of an embedded control byte in a
|
|
10
|
+
// distinguished-name / SAN string. Two policies over one concern.
|
|
11
|
+
//
|
|
12
|
+
// Defends the name-truncation / display-confusion class (CVE-2009-2408): a NUL
|
|
13
|
+
// or control byte embedded in a decoded name lets an attacker make two different
|
|
14
|
+
// names compare equal (or a UI truncate at the NUL), so a cert issued for
|
|
15
|
+
// "good.example.com\0.evil.com" is treated as "good.example.com". CWE-158
|
|
16
|
+
// (improper neutralization of null byte) / CWE-20. The reject is at decode, so a
|
|
17
|
+
// truncation name never reaches a comparison or a display.
|
|
18
|
+
|
|
19
|
+
// assertNoControlBytes(str, E, code, label) -> str | throws E(code, ...)
|
|
20
|
+
// DirectoryString policy (a DN attribute value): reject NUL and C0 control bytes
|
|
21
|
+
// in a DECODED name string. TAB (0x09) is exempt; printable non-ASCII (a
|
|
22
|
+
// UTF8String CN carries accented / CJK characters) is allowed. `str` is assumed a
|
|
23
|
+
// string (the caller guards typeof). E is the (code, message) typed-error factory.
|
|
24
|
+
// @enforced-by behavioral -- the control-byte reject has no rename-proof code
|
|
25
|
+
// shape distinct from the ASN.1 charset readers; the CVE-2009-2408 RED vectors
|
|
26
|
+
// (a DN string with an embedded NUL / control byte rejects) are the guard.
|
|
27
|
+
function assertNoControlBytes(str, E, code, label) {
|
|
28
|
+
for (var i = 0; i < str.length; i++) {
|
|
29
|
+
var c = str.charCodeAt(i);
|
|
30
|
+
if (c === 0 || (c < 0x20 && c !== 0x09)) {
|
|
31
|
+
throw E(code, label + " contains an embedded control byte (CVE-2009-2408)");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return str;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// assertPrintableIa5(buf, E, code, label) -> buf | throws E(code, ...)
|
|
38
|
+
// IA5String policy (a dNSName / rfc822Name / URI GeneralName): every byte must be
|
|
39
|
+
// printable 7-bit ASCII [0x20, 0x7e] -- an embedded NUL / control byte enables the
|
|
40
|
+
// same name-truncation bypass downstream. `buf` is the raw GeneralName content.
|
|
41
|
+
// @enforced-by behavioral -- the printable-IA5 byte-range reject has no rename-proof
|
|
42
|
+
// code shape distinct from the ASN.1 IA5 reader; the CVE-2009-2408 RED vectors
|
|
43
|
+
// (a SAN with a control byte rejects) are the guard.
|
|
44
|
+
function assertPrintableIa5(buf, E, code, label) {
|
|
45
|
+
for (var i = 0; i < buf.length; i++) {
|
|
46
|
+
if (buf[i] < 0x20 || buf[i] > 0x7e) {
|
|
47
|
+
throw E(code, label + " must be a printable IA5String (no control bytes)");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return buf;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { assertNoControlBytes: assertNoControlBytes, assertPrintableIa5: assertPrintableIa5 };
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
// parsers whose decoded-integer bounds compose this guard (pki.schema.x509 /
|
|
7
|
+
// crl / pkcs12 / cmp, pki.path.validate).
|
|
8
|
+
//
|
|
9
|
+
// guard-range -- fail-closed bound of an untrusted DECODED integer before it is
|
|
10
|
+
// narrowed to a JS Number. Parse-time (Tier-2): the value is malformed content,
|
|
11
|
+
// so it throws the caller's typed PkiError -- distinct from guard-limits, which
|
|
12
|
+
// validates an operator-supplied option at config time (Tier-1, TypeError).
|
|
13
|
+
//
|
|
14
|
+
// Defends the silent-narrowing class (a decoded ASN.1 INTEGER past 2^53 rounds
|
|
15
|
+
// when coerced to a Number, so a caller acting on the result -- a pathLen /
|
|
16
|
+
// skipCerts / saltLength / iteration counter -- acts on the WRONG number;
|
|
17
|
+
// CWE-190 integer overflow / CWE-681 incorrect conversion). The bound and the
|
|
18
|
+
// narrow are ATOMIC here: a caller cannot obtain the Number without the range
|
|
19
|
+
// having been enforced, so the value can never round silently and be acted on.
|
|
20
|
+
|
|
21
|
+
// 2^31 - 1 -- the shared skip-cert / path-length / salt / iteration ceiling.
|
|
22
|
+
// This literal lives ONLY here (a re-inline anywhere else is flagged: the
|
|
23
|
+
// guard-shape walk anchors on the literal). 2^53 - 1 is the safe-narrow ceiling.
|
|
24
|
+
var UINT31_MAX = 2147483647n;
|
|
25
|
+
var SAFE_MAX = 9007199254740991n;
|
|
26
|
+
var SAFE_MIN = -9007199254740991n;
|
|
27
|
+
|
|
28
|
+
// int(value, min, max, E, code, label) -> a Number in [min, max].
|
|
29
|
+
// value : the BigInt result of an ASN.1 INTEGER / ENUMERATED read.
|
|
30
|
+
// min, max : inclusive BigInt bounds.
|
|
31
|
+
// E : the (code, message, cause) typed-error factory in scope at the call
|
|
32
|
+
// site (ns.E / ctx.E / the module-local E) -- guard-range injects the
|
|
33
|
+
// caller's typed error through it, the tier-appropriate currency (a
|
|
34
|
+
// parse boundary carries a factory, not a bare ErrorClass).
|
|
35
|
+
// code : the frozen domain/reason code this field rejects under.
|
|
36
|
+
// label : field phrase (+ optional RFC cite) for the message.
|
|
37
|
+
// @enforced-by guard-shape-reinlined
|
|
38
|
+
// @guard-shape 2147483647n
|
|
39
|
+
function int(value, min, max, E, code, label) {
|
|
40
|
+
// Config-time authoring guard (Tier-1): narrowing to Number is lossless only
|
|
41
|
+
// when the WHOLE range sits inside the safe-integer band [-(2^53-1), 2^53-1].
|
|
42
|
+
// Both ends bind -- a min below the floor rounds a decoded value near it just
|
|
43
|
+
// as a max above the ceiling does. A wider range needs a BigInt-preserving
|
|
44
|
+
// guard, not this one (this is why the uint64 Merkle-coordinate domain must
|
|
45
|
+
// NOT route here).
|
|
46
|
+
if (max > SAFE_MAX) {
|
|
47
|
+
throw new TypeError("guard.range.int: max " + max + " exceeds the safe-integer ceiling; use a BigInt-preserving guard");
|
|
48
|
+
}
|
|
49
|
+
if (min < SAFE_MIN) {
|
|
50
|
+
throw new TypeError("guard.range.int: min " + min + " is below the safe-integer floor; use a BigInt-preserving guard");
|
|
51
|
+
}
|
|
52
|
+
// Parse-time reject (Tier-2): malformed / out-of-range decoded content.
|
|
53
|
+
if (typeof value !== "bigint" || value < min || value > max) {
|
|
54
|
+
throw E(code, label + " must be an integer within " + min + ".." + max);
|
|
55
|
+
}
|
|
56
|
+
return Number(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// uint31(value, E, code, label) -> Number in [0, 2^31-1]. The DER-INTEGER
|
|
60
|
+
// non-negative skip/path/salt shape (RFC 5280 / 4211 / 9810).
|
|
61
|
+
// @enforced-by guard-shape-reinlined (shares the 2147483647n shape declared on int)
|
|
62
|
+
function uint31(value, E, code, label) { return int(value, 0n, UINT31_MAX, E, code, label); }
|
|
63
|
+
|
|
64
|
+
// positiveInt31(value, E, code, label) -> Number in [1, 2^31-1]. The PKCS#12
|
|
65
|
+
// PBKDF2 / MAC iteration-count shape (a count of at least one).
|
|
66
|
+
// @enforced-by guard-shape-reinlined (shares the 2147483647n shape declared on int)
|
|
67
|
+
function positiveInt31(value, E, code, label) { return int(value, 1n, UINT31_MAX, E, code, label); }
|
|
68
|
+
|
|
69
|
+
module.exports = { int: int, uint31: uint31, positiveInt31: positiveInt31 };
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
// primitives whose text boundaries compose this guard (pki.jose.parseJson, the
|
|
7
|
+
// PEM decoders, the EST transfer decoders).
|
|
8
|
+
//
|
|
9
|
+
// guard-text -- fail-closed decode of an untrusted byte-source input to a
|
|
10
|
+
// string, capping the RAW byte length BEFORE the string is materialized.
|
|
11
|
+
// Enforced choke point for the byte->string boundary.
|
|
12
|
+
//
|
|
13
|
+
// Defends the parser-DoS string-allocation class (CWE-770 allocation without
|
|
14
|
+
// limits, CWE-400 uncontrolled resource consumption; MITRE T1499.001). The
|
|
15
|
+
// ORDERING is the invariant: cap the byte length, THEN decode. Decoding a Buffer
|
|
16
|
+
// to a string first allocates a full-size string for input the cap is about to
|
|
17
|
+
// reject; worse, a buffer above Node's max string length escapes the decode as
|
|
18
|
+
// an untyped ERR_STRING_TOO_LONG instead of the caller's typed <tooLarge>. This
|
|
19
|
+
// guard defines the cap-before-copy ordering and the fatal-UTF-8 decode once so
|
|
20
|
+
// no boundary re-derives it (and drifts to cap-after, as some had).
|
|
21
|
+
|
|
22
|
+
var bytes = require("./guard-bytes");
|
|
23
|
+
|
|
24
|
+
var LATIN1 = "latin1";
|
|
25
|
+
|
|
26
|
+
// decode(input, maxBytes, ErrorClass, spec) -> string.
|
|
27
|
+
// input : a Buffer (decoded per spec.charset) or a string (taken as-is).
|
|
28
|
+
// maxBytes : the raw byte-length ceiling, checked BEFORE any string copy.
|
|
29
|
+
// ErrorClass: a PkiError subclass (withCause where spec.fatal is set).
|
|
30
|
+
// spec : { charset: "latin1"|"utf-8" (default latin1),
|
|
31
|
+
// fatal: boolean -- strict UTF-8 (a lone continuation /
|
|
32
|
+
// truncated sequence throws, never substitutes U+FFFD),
|
|
33
|
+
// tooLarge: code for the over-cap reject,
|
|
34
|
+
// badDecode: code for a fatal-charset (bad-UTF-8) reject
|
|
35
|
+
// (required only when fatal is set),
|
|
36
|
+
// badInput: code for a non-Buffer/non-string reject,
|
|
37
|
+
// label: human phrase for the message }
|
|
38
|
+
// @enforced-by behavioral -- cap-before-copy has no rename-proof code shape; the
|
|
39
|
+
// over-cap RED vectors + the detached re-view through guard.bytes.view (itself
|
|
40
|
+
// enforced) are the guard.
|
|
41
|
+
function decode(input, maxBytes, ErrorClass, spec) {
|
|
42
|
+
var charset = spec.charset || LATIN1;
|
|
43
|
+
if (Buffer.isBuffer(input)) {
|
|
44
|
+
// Re-view through the byte guard FIRST so a detached backing ArrayBuffer (a
|
|
45
|
+
// transferred / structuredClone'd Buffer, which reads as zero-length) fails
|
|
46
|
+
// closed here -- the same detached-buffer defence the byte boundaries get --
|
|
47
|
+
// instead of being decoded as an empty string. Then cap, then decode.
|
|
48
|
+
input = bytes.view(input, ErrorClass, spec.badInput, spec.label);
|
|
49
|
+
if (input.length > maxBytes) throw new ErrorClass(spec.tooLarge, spec.label + " exceeds the size cap");
|
|
50
|
+
if (spec.fatal) {
|
|
51
|
+
try { return new TextDecoder(charset, { fatal: true }).decode(input); }
|
|
52
|
+
catch (e) { throw new ErrorClass(spec.badDecode, spec.label + " is not valid " + charset, e); }
|
|
53
|
+
}
|
|
54
|
+
return input.toString(charset);
|
|
55
|
+
}
|
|
56
|
+
if (typeof input === "string") {
|
|
57
|
+
// A latin1 string's char length equals its byte length; a UTF-8 string's
|
|
58
|
+
// does not, so bound the encoded byte length.
|
|
59
|
+
var byteLen = charset === LATIN1 ? input.length : Buffer.byteLength(input, "utf8");
|
|
60
|
+
if (byteLen > maxBytes) throw new ErrorClass(spec.tooLarge, spec.label + " exceeds the size cap");
|
|
61
|
+
return input;
|
|
62
|
+
}
|
|
63
|
+
throw new ErrorClass(spec.badInput, spec.label + " expects a string or Buffer");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { decode: decode };
|