@blamejs/pki 0.5.4 → 0.5.5

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.
@@ -25,9 +25,33 @@
25
25
  // domain/reason; `label` the field phrase; `maxBytes` an optional decoded-size
26
26
  // cap (null/undefined = uncapped, for a PEM cert body already bounded upstream).
27
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]*$/;
28
+ // The alphabets are TABLES, walked one character at a time, rather than regular
29
+ // expressions. A guard runs on the most hostile input the toolkit sees, and a
30
+ // pattern engine's cost on a non-matching string is a property of the pattern
31
+ // rather than of the length -- the one thing a bound cannot be placed on from the
32
+ // outside. A table lookup is one array index per character and its cost is the
33
+ // length, which the caller already caps. It is also the more honest statement of
34
+ // the rule: the set of permitted characters IS the rule, written out.
35
+ function _alphabet(chars) {
36
+ var t = new Uint8Array(128);
37
+ for (var i = 0; i < chars.length; i++) t[chars.charCodeAt(i)] = 1;
38
+ return t;
39
+ }
40
+ var UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", LOWER = "abcdefghijklmnopqrstuvwxyz", DIGITS = "0123456789";
41
+ var B64URL_ALPHABET = _alphabet(UPPER + LOWER + DIGITS + "-_");
42
+ var B64_ALPHABET = _alphabet(UPPER + LOWER + DIGITS + "+/");
43
+ var HEX_ALPHABET = _alphabet(DIGITS + "abcdef" + "ABCDEF");
44
+
45
+ // Every character of `text` is in `table`. A code point outside Latin-1's low half
46
+ // (including every astral half) is outside all three alphabets, so the table bound
47
+ // is the reject rather than an index that reads undefined.
48
+ function _inAlphabet(text, table) {
49
+ for (var i = 0; i < text.length; i++) {
50
+ var c = text.charCodeAt(i);
51
+ if (c > 127 || table[c] !== 1) return false;
52
+ }
53
+ return true;
54
+ }
31
55
 
32
56
  // Reject before Buffer.from allocates: a base64 text of N chars decodes to at
33
57
  // most floor(N*3/4) bytes, a hex text to N/2.
@@ -49,7 +73,7 @@ function _capBefore(nChars, perByteChars, maxBytes, E, code, label) {
49
73
  // @enforced-by base64-decode-not-via-guard
50
74
  function base64url(text, maxBytes, E, code, label) {
51
75
  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)");
76
+ if (!_inAlphabet(text, B64URL_ALPHABET)) throw E(code, label + " is not base64url (padding or a non-alphabet character)");
53
77
  if (text.length % 4 === 1) throw E(code, label + " has an impossible base64url length");
54
78
  _capBefore(text.length * 3, 4, maxBytes, E, code, label);
55
79
  var buf = Buffer.from(text, "base64url");
@@ -62,7 +86,12 @@ function base64url(text, maxBytes, E, code, label) {
62
86
  // @enforced-by base64-decode-not-via-guard
63
87
  function base64(text, maxBytes, E, code, label) {
64
88
  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)");
89
+ // Padding is positional, not alphabetic: at most two "=" and only at the end, so
90
+ // the body is measured first and the alphabet applies to what remains. An "="
91
+ // anywhere else leaves a non-alphabet character in the body and rejects there.
92
+ var pad = 0;
93
+ while (pad < 2 && text.length > pad && text.charCodeAt(text.length - 1 - pad) === 0x3d) pad++;
94
+ if (!_inAlphabet(text.slice(0, text.length - pad), B64_ALPHABET)) throw E(code, label + " is not base64 (a non-alphabet character)");
66
95
  if (text.length % 4 !== 0) throw E(code, label + " must be whole 4-character base64 groups (RFC 4648 sec. 3.5)");
67
96
  _capBefore(text.length * 3, 4, maxBytes, E, code, label);
68
97
  var buf = Buffer.from(text, "base64");
@@ -79,7 +108,7 @@ function base64(text, maxBytes, E, code, label) {
79
108
  // (a non-canonical / odd-length / non-hex #hex attribute value rejects) guard it.
80
109
  function hex(text, maxBytes, E, code, label) {
81
110
  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");
111
+ if (!_inAlphabet(text, HEX_ALPHABET)) throw E(code, label + " is not hexadecimal");
83
112
  if (text.length % 2 !== 0) throw E(code, label + " must have an even number of hex digits");
84
113
  _capBefore(text.length, 2, maxBytes, E, code, label);
85
114
  var buf = Buffer.from(text, "hex");
@@ -21,6 +21,32 @@
21
21
  // silent false-reject). Every string-form identifier check routes through here so
22
22
  // the string and DER forms cannot diverge.
23
23
 
24
+ // The dotted-decimal grammar, walked rather than matched. `(0|[1-9]\d*)(\.(0|[1-9]\d*))+`
25
+ // nests a quantified group inside a quantified group with an alternation in each,
26
+ // which is the shape whose cost on a REJECTING string is a property of the pattern
27
+ // rather than of the length -- and this guard's whole job is to be handed strings
28
+ // that reject. Walking the string is one pass, one comparison per character, and it
29
+ // states the two rules plainly: an arc is one or more digits, and an arc longer than
30
+ // one digit does not start with zero (the leading-zero form round-trips to a
31
+ // DIFFERENT OID, which is the divergence this guard exists to stop).
32
+ function _isDottedDecimal(str) {
33
+ if (str.length === 0) return false;
34
+ var arcs = 0, digits = 0, leadingZero = false;
35
+ for (var i = 0; i < str.length; i++) {
36
+ var c = str.charCodeAt(i);
37
+ if (c === 0x2e) { // "."
38
+ if (digits === 0 || leadingZero) return false; // empty arc, or "01"
39
+ arcs++; digits = 0; leadingZero = false;
40
+ continue;
41
+ }
42
+ if (c < 0x30 || c > 0x39) return false; // not a digit
43
+ if (digits === 1 && str.charCodeAt(i - 1) === 0x30) leadingZero = true;
44
+ digits++;
45
+ }
46
+ if (digits === 0 || leadingZero) return false; // trailing "." or a final "01"
47
+ return arcs >= 1; // two or more arcs
48
+ }
49
+
24
50
  // assertCanonicalOid(str, E, code, label, boundsCode) -> str | throws
25
51
  // A canonical dotted-decimal object identifier string: two or more arcs, each a
26
52
  // non-negative decimal integer with no leading zero (the SYNTAX), and -- unless
@@ -40,7 +66,7 @@
40
66
  // reject a non-canonical OID) driving the composing consumers are the guard.
41
67
  function assertCanonicalOid(str, E, code, label, boundsCode) {
42
68
  var who = label || "OID";
43
- if (typeof str !== "string" || !/^(0|[1-9]\d*)(\.(0|[1-9]\d*))+$/.test(str)) {
69
+ if (typeof str !== "string" || !_isDottedDecimal(str)) {
44
70
  throw E(code, who + " must be a canonical dotted-decimal OID string of two or more arcs with no leading-zero component");
45
71
  }
46
72
  if (boundsCode === null) return str;
package/lib/guard-json.js CHANGED
@@ -25,6 +25,14 @@
25
25
  var text = require("./guard-text");
26
26
  var limits = require("./guard-limits");
27
27
 
28
+ // One hex digit's value, or -1. Written out because the three ranges ARE the rule.
29
+ function _hexVal(c) {
30
+ if (c >= 0x30 && c <= 0x39) return c - 0x30; // 0-9
31
+ if (c >= 0x61 && c <= 0x66) return c - 0x61 + 10; // a-f
32
+ if (c >= 0x41 && c <= 0x46) return c - 0x41 + 10; // A-F
33
+ return -1;
34
+ }
35
+
28
36
  // parse(input, ErrorClass, spec) -> value. `input` is a Buffer or a string.
29
37
  // spec = { maxBytes, maxDepth, badJson, tooDeep, duplicateMember, tooLarge,
30
38
  // badInput, label } -- the caller's caps + frozen domain/reason codes.
@@ -118,9 +126,17 @@ function parse(input, ErrorClass, spec) {
118
126
  else if (e === "r") s += "\r";
119
127
  else if (e === "t") s += "\t";
120
128
  else if (e === "u") {
121
- var hex = str.substr(i, 4);
122
- if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail("bad \\u escape");
123
- s += String.fromCharCode(parseInt(hex, 16));
129
+ // Four hex digits, read as digits rather than matched as a pattern: the
130
+ // scanner is already walking this string one character at a time, and a
131
+ // parser handed hostile input should not hand any of it to a second engine.
132
+ var cp = 0;
133
+ if (i + 4 > n) fail("bad \\u escape");
134
+ for (var h = 0; h < 4; h++) {
135
+ var d = _hexVal(str.charCodeAt(i + h));
136
+ if (d < 0) fail("bad \\u escape");
137
+ cp = (cp << 4) | d;
138
+ }
139
+ s += String.fromCharCode(cp);
124
140
  i += 4;
125
141
  } else fail("bad escape");
126
142
  } else if (c.charCodeAt(0) < 0x20) {
@@ -128,15 +144,35 @@ function parse(input, ErrorClass, spec) {
128
144
  } else s += c;
129
145
  }
130
146
  }
147
+ // RFC 8259 sec. 6, enforced BY the walk rather than by re-matching the token
148
+ // afterwards. The scan already knows where each part starts and ends, so the
149
+ // grammar's three rules -- an integer part that is "0" or has no leading zero, a
150
+ // fraction with at least one digit, an exponent with at least one digit -- are
151
+ // checked as it goes. Re-matching what the scanner just read meant maintaining
152
+ // the same grammar twice, in two notations, and the pattern was the copy whose
153
+ // cost on a rejecting token could not be bounded from outside.
131
154
  function number() {
132
155
  var start = i;
133
156
  if (str[i] === "-") i++;
157
+ var intStart = i;
134
158
  while (i < n && str[i] >= "0" && str[i] <= "9") i++;
135
- if (str[i] === ".") { i++; while (i < n && str[i] >= "0" && str[i] <= "9") i++; }
136
- if (str[i] === "e" || str[i] === "E") { i++; if (str[i] === "+" || str[i] === "-") i++; while (i < n && str[i] >= "0" && str[i] <= "9") i++; }
137
- var tok = str.slice(start, i);
138
- if (!/^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/.test(tok)) fail("malformed number");
139
- var v = Number(tok);
159
+ var intLen = i - intStart;
160
+ if (intLen === 0) fail("malformed number");
161
+ if (intLen > 1 && str[intStart] === "0") fail("malformed number"); // no leading zero
162
+ if (str[i] === ".") {
163
+ i++;
164
+ var fracStart = i;
165
+ while (i < n && str[i] >= "0" && str[i] <= "9") i++;
166
+ if (i === fracStart) fail("malformed number"); // "1." has no fraction
167
+ }
168
+ if (str[i] === "e" || str[i] === "E") {
169
+ i++;
170
+ if (str[i] === "+" || str[i] === "-") i++;
171
+ var expStart = i;
172
+ while (i < n && str[i] >= "0" && str[i] <= "9") i++;
173
+ if (i === expStart) fail("malformed number"); // "1e" has no exponent
174
+ }
175
+ var v = Number(str.slice(start, i));
140
176
  if (!isFinite(v)) fail("bad number");
141
177
  return v;
142
178
  }
package/lib/guard-name.js CHANGED
@@ -65,10 +65,25 @@ function assertPrintableIa5(buf, E, code, label) {
65
65
  // matches OpenSSL's X509_NAME_cmp, so a chain OpenSSL accepts is not rejected. This
66
66
  // canonicalization is the shape the guard-shape-reinlined detector keys on
67
67
  // (declared on dnEqual): a boundary hand-rolling it is re-implementing DN identity.
68
+ // The collapse is a single walk rather than a pattern replace. This runs on every
69
+ // attribute value of every name the toolkit compares -- a certificate an attacker
70
+ // supplies included -- and one pass with a running "was the last character a space"
71
+ // flag costs exactly the length. It also spells out which characters count as
72
+ // whitespace: RFC 5280 sec. 7.1 defers to X.520's caseIgnoreMatch, whose SPACE is
73
+ // the ASCII space, and a pattern's \s silently also folds VT, FF, NBSP and every
74
+ // Unicode space separator, which would equate two names X.520 keeps distinct.
75
+ function _isSpace(c) { return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d; }
68
76
  function _canonAttrValue(v, E, code, label) {
69
77
  if (typeof v !== "string") return v;
70
78
  assertNoControlBytes(v, E, code, label);
71
- return v.trim().replace(/\s+/g, " ").toLowerCase();
79
+ var out = "", lastWasSpace = true; // true so leading whitespace is dropped
80
+ for (var i = 0; i < v.length; i++) {
81
+ if (_isSpace(v.charCodeAt(i))) { lastWasSpace = true; continue; }
82
+ if (lastWasSpace && out.length) out += " ";
83
+ lastWasSpace = false;
84
+ out += v.charAt(i);
85
+ }
86
+ return out.toLowerCase();
72
87
  }
73
88
  // rdnEqual(a, b, E, code, label) -> boolean. Canonical comparison of a single
74
89
  // RelativeDistinguishedName (an unordered SET of type/value pairs, compared as a
@@ -151,17 +166,25 @@ function escapeControlBytes(str) {
151
166
  // with the hexstring form, so the report misstates a subject/issuer name (CWE-116).
152
167
  // The one place a DN attribute value is made display-safe; pki.schema.pkix's DN
153
168
  // rendering composes it, and pki.inspect reuses that parser output (name.dn).
154
- // @enforced-by behavioral -- the RFC 4514 separator class carries a quote inside a
155
- // regex literal, which the codebase-patterns literal-stripper mis-tokenizes, so no
156
- // rename-proof shape is detectable; the guard-name RED vectors + the schema-pkix DN
169
+ var DN_SPECIAL = { 0x2c: 1, 0x2b: 1, 0x22: 1, 0x5c: 1, 0x3c: 1, 0x3e: 1, 0x3b: 1 }; // , + " \ < > ;
170
+ // RFC 4514 sec. 2.4's special set is the TABLE above and the walk is a single pass.
171
+ // The separator escape used to be a pattern replace feeding a second loop, which
172
+ // meant two passes over an attacker-supplied value and a character class holding a
173
+ // quote and a backslash inside a regex literal -- the form most easily misread by a
174
+ // human and, as it happened, by the codebase-patterns literal-stripper.
175
+ //
176
+ // @enforced-by behavioral -- the escaping has no rename-proof code shape distinct
177
+ // from ordinary string building; the guard-name RED vectors + the schema-pkix DN
157
178
  // round-trip vectors (a comma / plus / leading '#' renders backslash-escaped) are the guard.
158
179
  function escapeDnValue(v) {
159
- var s = String(v).replace(/([,+"\\<>;])/g, "\\$1"), out = "";
160
- // RFC 4514 sec. 2.4: a NUL / control octet -> '\' + two hex digits, so an embedded
161
- // CR / LF / NUL in a decoded DN value can never forge a report line when displayed.
180
+ var s = String(v), out = "";
181
+ // A NUL / control octet becomes '\' + two hex digits, so an embedded CR / LF / NUL
182
+ // in a decoded DN value can never forge a report line when displayed.
162
183
  for (var i = 0; i < s.length; i++) {
163
184
  var c = s.charCodeAt(i);
164
- out += (c < 0x20 || c === 0x7f) ? "\\" + (c < 16 ? "0" : "") + c.toString(16).toUpperCase() : s.charAt(i);
185
+ if (c < 0x20 || c === 0x7f) out += "\\" + (c < 16 ? "0" : "") + c.toString(16).toUpperCase();
186
+ else if (DN_SPECIAL[c] === 1) out += "\\" + s.charAt(i);
187
+ else out += s.charAt(i);
165
188
  }
166
189
  if (out.length && out.charAt(out.length - 1) === " ") out = out.slice(0, -1) + "\\ ";
167
190
  if (out.charAt(0) === "#" || out.charAt(0) === " ") out = "\\" + out;