@blamejs/pki 0.1.29 → 0.1.30

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 CHANGED
@@ -4,7 +4,20 @@ 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.29 — 2026-07-11
7
+ ## v0.1.30 — 2026-07-11
8
+
9
+ Fail-closed hardening of the byte-input and text-decode boundaries.
10
+
11
+ ### Changed
12
+
13
+ - 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.
14
+
15
+ ### Fixed
16
+
17
+ - 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.
18
+ - pki.oid.fromDER rejects a non-Buffer or detached-backed input with a typed oid/bad-input error instead of a raw TypeError.
19
+
20
+ ## v0.1.29 — 2026-07-10
8
21
 
9
22
  A detached-backed BufferSource now fails closed with a typed error at every byte-input boundary.
10
23
 
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
- // A Buffer is a Uint8Array, so it routes through the same guarded re-view --
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
- var maxBytes = _capOpt(opts.maxBytes, "maxBytes", constants.LIMITS.DER_MAX_BYTES);
180
- var maxDepth = _capOpt(opts.maxDepth, "maxDepth", constants.LIMITS.DER_MAX_DEPTH);
181
- if (maxDepth > constants.LIMITS.MAX_DECODE_DEPTH_CEILING) {
182
- throw new TypeError("decode: maxDepth " + maxDepth + " exceeds the stack-safe ceiling " + constants.LIMITS.MAX_DECODE_DEPTH_CEILING);
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
- // A Buffer is itself a Uint8Array, so it goes through the same guarded re-view
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 = _capOpt(opts.maxBytes, "maxBytes", constants.LIMITS.CBOR_MAX_BYTES);
287
- var maxDepth = _capOpt(opts.maxDepth, "maxDepth", constants.LIMITS.CBOR_MAX_DEPTH);
288
- if (maxDepth > constants.LIMITS.MAX_DECODE_DEPTH_CEILING) {
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
- // A Buffer is a Uint8Array, so it routes through the same guarded re-view --
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
- if (s.length > cap) throw E("est/too-large", "the EST payload exceeds the transfer size cap");
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 = Buffer.isBuffer(body) ? body.toString("latin1") : String(body);
190
- if (text.length > constants.LIMITS.DER_MAX_BYTES * 2) throw E("est/too-large", "the multipart body exceeds the size cap");
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
- var text = Buffer.isBuffer(body) ? body.toString("utf8") : String(body || "");
530
- throw E("est/http-error", "EST server returned HTTP " + status + (text ? ": " + text.slice(0, 512) : ""));
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
  }
@@ -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
- var OidError = defineClass("OidError");
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
@@ -0,0 +1,38 @@
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
+ //
22
+ // Each shape is enforced by a codebase-patterns detector: the characteristic
23
+ // token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
24
+ // timingSafeEqual call, the MAX_DECODE_DEPTH_CEILING check) must appear ONLY in
25
+ // its guard module, so a new boundary cannot re-inline the shape and forget the
26
+ // defence.
27
+
28
+ var bytes = require("./guard-bytes");
29
+ var text = require("./guard-text");
30
+ var limits = require("./guard-limits");
31
+ var crypto = require("./guard-crypto");
32
+
33
+ module.exports = {
34
+ bytes: bytes,
35
+ text: text,
36
+ limits: limits,
37
+ crypto: crypto,
38
+ };
@@ -0,0 +1,57 @@
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
+ function view(input, ErrorClass, code, label) {
31
+ if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
32
+ try {
33
+ return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
34
+ } catch (e) {
35
+ throw new ErrorClass(code, label + ": input is not a usable byte view (detached backing buffer?)", e);
36
+ }
37
+ }
38
+ throw new ErrorClass(code, label + ": expected a Buffer / Uint8Array");
39
+ }
40
+
41
+ // source(input, ErrorClass, code, label) -> Buffer | throws ErrorClass
42
+ // Accepts the full W3C BufferSource (Buffer / TypedArray view / raw ArrayBuffer)
43
+ // -- the WebCrypto input contract. A raw ArrayBuffer is copied via Buffer.from,
44
+ // which also throws on a detached backing store.
45
+ function source(input, ErrorClass, code, label) {
46
+ var isAb = input instanceof ArrayBuffer;
47
+ if (isAb || ArrayBuffer.isView(input)) {
48
+ try {
49
+ return isAb ? Buffer.from(input) : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
50
+ } catch (e) {
51
+ throw new ErrorClass(code, label + ": input is not a usable byte source (detached backing buffer?)", e);
52
+ }
53
+ }
54
+ throw new ErrorClass(code, label + ": expected a BufferSource (ArrayBuffer / TypedArray / Buffer)");
55
+ }
56
+
57
+ module.exports = { view: view, source: source };
@@ -0,0 +1,32 @@
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
+ function constantTimeEqual(a, b) {
29
+ return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
30
+ }
31
+
32
+ module.exports = { constantTimeEqual: constantTimeEqual };
@@ -0,0 +1,49 @@
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
+ function cap(value, key, dflt) {
31
+ if (value === undefined) return dflt;
32
+ if (!Number.isInteger(value) || value < 0) {
33
+ throw new TypeError("decode: " + key + " must be a non-negative integer");
34
+ }
35
+ return value;
36
+ }
37
+
38
+ // depthCap(value, key, dflt) -> a validated recursion-depth cap: cap() plus the
39
+ // stack-safe ceiling, so a raised maxDepth cannot drive recursive descent into a
40
+ // raw RangeError past the native frame limit.
41
+ function depthCap(value, key, dflt) {
42
+ var n = cap(value, key, dflt);
43
+ if (n > constants.LIMITS.MAX_DECODE_DEPTH_CEILING) {
44
+ throw new TypeError("decode: " + key + " " + n + " exceeds the stack-safe ceiling " + constants.LIMITS.MAX_DECODE_DEPTH_CEILING);
45
+ }
46
+ return n;
47
+ }
48
+
49
+ module.exports = { cap: cap, depthCap: depthCap };
@@ -0,0 +1,63 @@
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
+ function decode(input, maxBytes, ErrorClass, spec) {
39
+ var charset = spec.charset || LATIN1;
40
+ if (Buffer.isBuffer(input)) {
41
+ // Re-view through the byte guard FIRST so a detached backing ArrayBuffer (a
42
+ // transferred / structuredClone'd Buffer, which reads as zero-length) fails
43
+ // closed here -- the same detached-buffer defence the byte boundaries get --
44
+ // instead of being decoded as an empty string. Then cap, then decode.
45
+ input = bytes.view(input, ErrorClass, spec.badInput, spec.label);
46
+ if (input.length > maxBytes) throw new ErrorClass(spec.tooLarge, spec.label + " exceeds the size cap");
47
+ if (spec.fatal) {
48
+ try { return new TextDecoder(charset, { fatal: true }).decode(input); }
49
+ catch (e) { throw new ErrorClass(spec.badDecode, spec.label + " is not valid " + charset, e); }
50
+ }
51
+ return input.toString(charset);
52
+ }
53
+ if (typeof input === "string") {
54
+ // A latin1 string's char length equals its byte length; a UTF-8 string's
55
+ // does not, so bound the encoded byte length.
56
+ var byteLen = charset === LATIN1 ? input.length : Buffer.byteLength(input, "utf8");
57
+ if (byteLen > maxBytes) throw new ErrorClass(spec.tooLarge, spec.label + " exceeds the size cap");
58
+ return input;
59
+ }
60
+ throw new ErrorClass(spec.badInput, spec.label + " expects a string or Buffer");
61
+ }
62
+
63
+ module.exports = { decode: decode };
package/lib/jose.js CHANGED
@@ -38,6 +38,7 @@
38
38
  var constants = require("./constants");
39
39
  var webcrypto = require("./webcrypto").webcrypto;
40
40
  var frameworkError = require("./framework-error");
41
+ var guard = require("./guard-all");
41
42
 
42
43
  var JoseError = frameworkError.JoseError;
43
44
  function E(code, message, cause) { return new JoseError(code, message, cause); }
@@ -228,17 +229,11 @@ function _jsonReader(str) {
228
229
  * pki.jose.parseJson('{"a":1}'); // -> { a: 1 }
229
230
  */
230
231
  function parseJson(input) {
231
- var str;
232
- if (Buffer.isBuffer(input)) {
233
- if (input.length > LIMITS.JSON_MAX_BYTES) throw E("jose/too-large", "the JSON document exceeds the size cap");
234
- try { str = new TextDecoder("utf-8", { fatal: true }).decode(input); }
235
- catch (e) { throw E("jose/bad-json", "the JSON document is not valid UTF-8", e); }
236
- } else if (typeof input === "string") {
237
- if (Buffer.byteLength(input, "utf8") > LIMITS.JSON_MAX_BYTES) throw E("jose/too-large", "the JSON document exceeds the size cap");
238
- str = input;
239
- } else {
240
- throw E("jose/bad-input", "parseJson requires a Buffer or string");
241
- }
232
+ // guard.text.decode caps the raw byte length BEFORE the (strict, fatal) UTF-8
233
+ // decode, so an oversized document is rejected before it is materialized.
234
+ var str = guard.text.decode(input, LIMITS.JSON_MAX_BYTES, JoseError, {
235
+ charset: "utf-8", fatal: true, tooLarge: "jose/too-large", badDecode: "jose/bad-json", badInput: "jose/bad-input", label: "the JSON document",
236
+ });
242
237
  return _jsonReader(str);
243
238
  }
244
239
 
package/lib/merkle.js CHANGED
@@ -44,6 +44,7 @@
44
44
  var nodeCrypto = require("node:crypto");
45
45
  var constants = require("./constants");
46
46
  var frameworkError = require("./framework-error");
47
+ var guard = require("./guard-all");
47
48
 
48
49
  var MerkleError = frameworkError.MerkleError;
49
50
 
@@ -58,17 +59,7 @@ function _sha256(buf) {
58
59
  }
59
60
 
60
61
  function _toBuffer(v, field) {
61
- if (!Buffer.isBuffer(v) && !(v instanceof Uint8Array)) {
62
- throw new MerkleError("merkle/bad-input", field + " must be a Buffer or Uint8Array");
63
- }
64
- try {
65
- // A zero-copy view over the same bytes; this also surfaces a detached
66
- // backing ArrayBuffer (a transferred / structuredClone'd view) as a typed
67
- // error here rather than a raw TypeError from a later concat / digest.
68
- return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
69
- } catch (e) {
70
- throw new MerkleError("merkle/bad-input", field + " is not a usable byte view", e);
71
- }
62
+ return guard.bytes.view(v, MerkleError, "merkle/bad-input", field);
72
63
  }
73
64
 
74
65
  function _node32(v, field) {
@@ -113,7 +104,7 @@ function _proofNodes(proof) {
113
104
 
114
105
  // Constant-time equality of two already-validated equal-length 32-byte buffers.
115
106
  function _ctEq(a, b) {
116
- return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
107
+ return guard.crypto.constantTimeEqual(a, b);
117
108
  }
118
109
 
119
110
  /**
package/lib/oid.js CHANGED
@@ -35,6 +35,7 @@
35
35
 
36
36
  var asn1 = require("./asn1-der");
37
37
  var frameworkError = require("./framework-error");
38
+ var guard = require("./guard-all");
38
39
 
39
40
  var OidError = frameworkError.OidError;
40
41
 
@@ -454,7 +455,7 @@ function fromArcs(arcs) {
454
455
  // one namespace when they have a dotted string in hand.
455
456
  function toDER(dotted) { _assertDotted(dotted, "toDER"); return asn1.build.oid(dotted); }
456
457
  function fromDER(input) {
457
- var buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
458
+ var buf = guard.bytes.view(input, OidError, "oid/bad-input", "fromDER");
458
459
  var node = asn1.decode(buf);
459
460
  return asn1.read.oid(node);
460
461
  }
@@ -16,6 +16,7 @@
16
16
  var asn1 = require("./asn1-der");
17
17
  var constants = require("./constants");
18
18
  var schema = require("./schema-engine");
19
+ var guard = require("./guard-all");
19
20
  // ct is a leaf format module (requires only the codec / oid / constants / error
20
21
  // core, never pkix) so the SCT-list extension VALUE decoder registers here as a
21
22
  // registry row, not a switch. The one-directional edge keeps the layering acyclic.
@@ -35,16 +36,13 @@ var PEM_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/;
35
36
  // lenient Buffer.from would otherwise let several distinct PEM texts alias one
36
37
  // DER (PEM-layer malleability) and silently drop trailing content bits.
37
38
  function pemDecode(text, label, PemError) {
38
- // The size cap is enforced on the raw byte length BEFORE the latin1 string
39
- // copy: converting first would allocate a full-size string for an input the
40
- // cap is about to reject, and a buffer above Node's max string length would
41
- // escape as an untyped ERR_STRING_TOO_LONG instead of pem/too-large.
42
- if (Buffer.isBuffer(text)) {
43
- if (text.length > constants.LIMITS.PEM_MAX_BYTES) throw new PemError("pem/too-large", "PEM input exceeds size cap");
44
- text = text.toString("latin1");
45
- }
46
- if (typeof text !== "string") throw new PemError("pem/bad-input", "pemDecode expects a string or Buffer");
47
- if (text.length > constants.LIMITS.PEM_MAX_BYTES) throw new PemError("pem/too-large", "PEM input exceeds size cap");
39
+ // guard.text.decode caps the raw byte length BEFORE the latin1 string copy:
40
+ // converting first would allocate a full-size string for input the cap is
41
+ // about to reject, and a buffer above Node's max string length would escape as
42
+ // an untyped ERR_STRING_TOO_LONG instead of pem/too-large.
43
+ text = guard.text.decode(text, constants.LIMITS.PEM_MAX_BYTES, PemError, {
44
+ charset: "latin1", tooLarge: "pem/too-large", badInput: "pem/bad-input", label: "PEM input",
45
+ });
48
46
  var m = PEM_RE.exec(text);
49
47
  if (!m) throw new PemError("pem/no-block", "no PEM block found");
50
48
  if (label && m[1] !== label) throw new PemError("pem/label-mismatch", "expected " + JSON.stringify(label) + " block, got " + JSON.stringify(m[1]));
@@ -64,12 +62,9 @@ function pemDecode(text, label, PemError) {
64
62
  // base64 body is canonical (the pemDecode rule); returns an array of DER buffers.
65
63
  function pemDecodeAll(text, label, PemError) {
66
64
  label = label || "CERTIFICATE";
67
- if (Buffer.isBuffer(text)) {
68
- if (text.length > constants.LIMITS.PEM_MAX_BYTES) throw new PemError("pem/too-large", "PEM input exceeds size cap");
69
- text = text.toString("latin1");
70
- }
71
- if (typeof text !== "string") throw new PemError("pem/bad-input", "pemDecodeAll expects a string or Buffer");
72
- if (text.length > constants.LIMITS.PEM_MAX_BYTES) throw new PemError("pem/too-large", "PEM input exceeds size cap");
65
+ text = guard.text.decode(text, constants.LIMITS.PEM_MAX_BYTES, PemError, {
66
+ charset: "latin1", tooLarge: "pem/too-large", badInput: "pem/bad-input", label: "PEM input",
67
+ });
73
68
  var re = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
74
69
  var blocks = [];
75
70
  var lastEnd = 0, m;
@@ -107,9 +102,12 @@ function pemEncode(der, label, PemError) {
107
102
  // Uint8Array is taken as bytes. opts: { pemLabel, PemError, ErrorClass, prefix }.
108
103
  function coerceToDer(input, opts) {
109
104
  if (typeof input === "string") return pemDecode(input, opts.pemLabel, opts.PemError);
110
- if (input instanceof Uint8Array && !Buffer.isBuffer(input)) input = Buffer.from(input);
111
- if (Buffer.isBuffer(input)) {
112
- return _isPemArmor(input) ? pemDecode(input, opts.pemLabel, opts.PemError) : input;
105
+ if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
106
+ // Route the DER Buffer / Uint8Array through the shared byte guard so a
107
+ // detached backing store fails closed here for EVERY format that composes
108
+ // this boundary (x509/crl/csr/pkcs8/cms/pkcs12), not per-format.
109
+ var buf = guard.bytes.view(input, opts.ErrorClass, opts.prefix + "/bad-input", "parse");
110
+ return _isPemArmor(buf) ? pemDecode(buf, opts.pemLabel, opts.PemError) : buf;
113
111
  }
114
112
  throw new opts.ErrorClass(opts.prefix + "/bad-input", "parse expects a DER Buffer or a PEM string");
115
113
  }
package/lib/webcrypto.js CHANGED
@@ -35,6 +35,7 @@
35
35
 
36
36
  var nodeCrypto = require("node:crypto");
37
37
  var frameworkError = require("./framework-error");
38
+ var guard = require("./guard-all");
38
39
 
39
40
  // Single-owner error class -- co-located with its module (framework-error
40
41
  // stays the cross-module home; this is webcrypto-private). withCause: a
@@ -47,21 +48,7 @@ var MAX_RANDOM_BYTES = 65536;
47
48
  // ---- value helpers ---------------------------------------------------
48
49
 
49
50
  function _toBuf(data, who) {
50
- var isAb = data instanceof ArrayBuffer;
51
- // A Buffer is itself an ArrayBuffer view, so it is handled by the view arm --
52
- // NOT a `Buffer.isBuffer` fast-path that would return it as-is. A detached
53
- // backing ArrayBuffer (a transferred / structuredClone'd Buffer, view, or
54
- // ArrayBuffer) has had its bytes removed and reads as zero-length; the
55
- // fast-path would silently hash / sign EMPTY (a fail-OPEN). Always re-view
56
- // through Buffer.from so a detached input fails closed here as a typed error.
57
- if (isAb || ArrayBuffer.isView(data)) {
58
- try {
59
- return isAb ? Buffer.from(data) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
60
- } catch (e) {
61
- throw new WebCryptoError("webcrypto/data", (who || "input") + ": input is not a usable byte source (detached backing buffer?)", e);
62
- }
63
- }
64
- throw new WebCryptoError("webcrypto/data", (who || "input") + ": expected BufferSource (ArrayBuffer / TypedArray / Buffer)");
51
+ return guard.bytes.source(data, WebCryptoError, "webcrypto/data", who || "input");
65
52
  }
66
53
 
67
54
  function _toArrayBuffer(buf) {
@@ -354,7 +341,7 @@ SubtleCrypto.prototype.verify = async function verify(algorithm, key, signature,
354
341
  // in constant time. The length check leaks nothing a constant-time compare
355
342
  // would protect; the secret-dependent byte comparison is timingSafeEqual.
356
343
  var digest = hm.digest();
357
- return digest.length === sig.length && nodeCrypto.timingSafeEqual(digest, sig);
344
+ return guard.crypto.constantTimeEqual(digest, sig);
358
345
  }
359
346
  throw new WebCryptoError("webcrypto/not-supported", "verify: unsupported algorithm " + JSON.stringify(name));
360
347
  };
@@ -729,15 +716,11 @@ Crypto.prototype.getRandomValues = function getRandomValues(typedArray) {
729
716
  if (typedArray.byteLength > MAX_RANDOM_BYTES) {
730
717
  throw new WebCryptoError("webcrypto/data", "getRandomValues: byteLength exceeds " + MAX_RANDOM_BYTES);
731
718
  }
732
- var view;
733
- try {
734
- // A detached backing ArrayBuffer surfaces as a typed error rather than a
735
- // raw TypeError from Buffer.from over the transferred/cloned view.
736
- view = Buffer.from(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
737
- } catch (e) {
738
- throw new WebCryptoError("webcrypto/data", "getRandomValues: byte view is not usable (detached backing buffer?)", e);
739
- }
740
- nodeCrypto.randomFillSync(view);
719
+ // Re-view the (already integer-TypedArray-validated) output buffer via
720
+ // guard.bytes.source -- any TypedArray, not just Uint8Array -- so a detached
721
+ // backing store fails closed here; the returned Buffer aliases typedArray, so
722
+ // randomFillSync writes through to the caller's array.
723
+ nodeCrypto.randomFillSync(guard.bytes.source(typedArray, WebCryptoError, "webcrypto/data", "getRandomValues"));
741
724
  return typedArray;
742
725
  };
743
726
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:26032baa-c793-470f-863c-569dc97c186e",
5
+ "serialNumber": "urn:uuid:9b3c1a14-9eb9-492b-a652-b6fa68d6b4b4",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-11T05:58:59.165Z",
8
+ "timestamp": "2026-07-11T07:38:46.451Z",
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.29",
22
+ "bom-ref": "@blamejs/pki@0.1.30",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.29",
25
+ "version": "0.1.30",
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",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.1.30",
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.29",
57
+ "ref": "@blamejs/pki@0.1.30",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]