@blamejs/core 0.18.35 → 0.18.37

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.
@@ -59,6 +59,7 @@ var pick = require("../pick");
59
59
  var boundedMap = require("../bounded-map");
60
60
  var numericBounds = require("../numeric-bounds");
61
61
  var safeBuffer = require("../safe-buffer");
62
+ var codepointClass = require("../codepoint-class");
62
63
  var { FrameworkError } = require("../framework-error");
63
64
 
64
65
  class SafeYamlError extends FrameworkError {
@@ -93,14 +94,187 @@ var DEFAULTS = {
93
94
  // YAML 1.2 core-schema scalar resolution. Order matters: null first
94
95
  // (covers ~ and empty), then bool, then int (with base prefixes), then
95
96
  // float, then string fallback.
96
- var NULL_RE = /^(null|Null|NULL|~|)$/;
97
- var BOOL_RE = /^(true|True|TRUE|false|False|FALSE)$/;
98
- var INT_RE = /^[-+]?(0|[1-9][0-9]*)$/;
99
- var INT_OCT = /^0o[0-7]+$/;
100
- var INT_HEX = /^0x[0-9a-fA-F]+$/;
101
- var FLOAT_RE = /^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$/;
102
- var FLOAT_INF = /^[-+]?\.(inf|Inf|INF)$/;
103
- var FLOAT_NAN = /^\.(nan|NaN|NAN)$/;
97
+ // The resolvers are exact-set membership or a left-to-right walk. YAML's core
98
+ // schema spells each type out, so none of these needs to search: the anchored
99
+ // alternations were only ever a compact way of writing a fixed vocabulary and a
100
+ // digit shape.
101
+ var NULL_TOKENS = { "": 1, "null": 1, "Null": 1, "NULL": 1, "~": 1 };
102
+ var BOOL_TRUE = { "true": 1, "True": 1, "TRUE": 1 };
103
+ var BOOL_FALSE = { "false": 1, "False": 1, "FALSE": 1 };
104
+ var INF_TOKENS = { ".inf": 1, ".Inf": 1, ".INF": 1 };
105
+ var NAN_TOKENS = { ".nan": 1, ".NaN": 1, ".NAN": 1 };
106
+ var OCTAL_DIGITS = "01234567";
107
+
108
+ function _isNull(s) { return Object.prototype.hasOwnProperty.call(NULL_TOKENS, s); }
109
+ function _isBool(s) {
110
+ return Object.prototype.hasOwnProperty.call(BOOL_TRUE, s) ||
111
+ Object.prototype.hasOwnProperty.call(BOOL_FALSE, s);
112
+ }
113
+
114
+ function _signOffset(s) { var c = s.charAt(0); return (c === "-" || c === "+") ? 1 : 0; }
115
+
116
+ // `^[-+]?(0|[1-9][0-9]*)$` — no leading zeros, because `010` is a string in the
117
+ // core schema rather than an octal.
118
+ function _isDecimalInt(s) {
119
+ var i = _signOffset(s);
120
+ var rest = s.slice(i);
121
+ if (rest.length === 0) return false;
122
+ if (rest === "0") return true;
123
+ if (rest.charAt(0) === "0") return false;
124
+ return codepointClass.isRunOf(rest, codepointClass.ASCII_DIGITS);
125
+ }
126
+
127
+ function _isPrefixedInt(s, marker, alphabet) {
128
+ if (s.length < 3) return false;
129
+ if (s.charAt(0) !== "0" || s.charAt(1) !== marker) return false;
130
+ return codepointClass.isRunOf(s.slice(2), alphabet);
131
+ }
132
+ function _isOctalInt(s) { return _isPrefixedInt(s, "o", OCTAL_DIGITS); }
133
+ function _isHexInt(s) { return _isPrefixedInt(s, "x", codepointClass.ASCII_HEX); }
134
+
135
+ // `^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$` — either a leading dot
136
+ // with digits after it, or digits with an optional dot and optional digits, then
137
+ // an optional exponent that must carry at least one digit.
138
+ function _isFloat(s) {
139
+ var i = _signOffset(s);
140
+ var n = s.length;
141
+ // Both branches below return early when they find no digit, so reaching the
142
+ // exponent means at least one was consumed — no separate flag needed.
143
+ if (s.charAt(i) === ".") {
144
+ i += 1;
145
+ var fracStart = i;
146
+ while (i < n && codepointClass.ASCII_DIGITS.indexOf(s.charAt(i)) !== -1) i += 1;
147
+ if (i === fracStart) return false; // `.` alone is not a float
148
+ } else {
149
+ var intStart = i;
150
+ while (i < n && codepointClass.ASCII_DIGITS.indexOf(s.charAt(i)) !== -1) i += 1;
151
+ if (i === intStart) return false;
152
+ if (s.charAt(i) === ".") {
153
+ i += 1;
154
+ while (i < n && codepointClass.ASCII_DIGITS.indexOf(s.charAt(i)) !== -1) i += 1;
155
+ }
156
+ }
157
+ if (i === n) return true;
158
+ var e = s.charAt(i);
159
+ if (e !== "e" && e !== "E") return false;
160
+ i += 1;
161
+ var expSign = s.charAt(i);
162
+ if (expSign === "+" || expSign === "-") i += 1;
163
+ if (i >= n) return false;
164
+ return codepointClass.isRunOf(s.slice(i), codepointClass.ASCII_DIGITS);
165
+ }
166
+
167
+ function _isInfinity(s) {
168
+ return Object.prototype.hasOwnProperty.call(INF_TOKENS, s.slice(_signOffset(s)));
169
+ }
170
+ function _isNotANumber(s) {
171
+ return Object.prototype.hasOwnProperty.call(NAN_TOKENS, s);
172
+ }
173
+
174
+ function _isWhitespaceChar(ch) {
175
+ return ch.length === 1 && codepointClass.inRanges(ch.charCodeAt(0), codepointClass.WHITESPACE_RANGES);
176
+ }
177
+
178
+ // The four characters `.` does not match without the `s` flag.
179
+ function _isLineTerminator(ch) {
180
+ return ch === "\n" || ch === "\r" || ch === "\u2028" || ch === "\u2029";
181
+ }
182
+
183
+ // `^---(\s|$)` / `^\.\.\.(\s|$)` — a document marker is the three characters and
184
+ // then either the end of the line or a space, so `---foo` is not one.
185
+ function _isDocumentMarker(line, marker) {
186
+ if (line.slice(0, marker.length) !== marker) return false;
187
+ if (line.length === marker.length) return true;
188
+ return _isWhitespaceChar(line.charAt(marker.length));
189
+ }
190
+
191
+ // `^[|>][-+]?[0-9]?\s*$` and its variant allowing a trailing `# comment`.
192
+ // A block-scalar header: the indicator, an optional chomping sign, an optional
193
+ // single explicit-indent digit, then nothing that matters.
194
+ function _isBlockScalarHeader(content, allowComment) {
195
+ var i = 0;
196
+ var lead = content.charAt(0);
197
+ if (lead !== "|" && lead !== ">") return false;
198
+ i += 1;
199
+ var sign = content.charAt(i);
200
+ if (sign === "-" || sign === "+") i += 1;
201
+ var digit = content.charAt(i);
202
+ if (digit.length === 1 && codepointClass.ASCII_DIGITS.indexOf(digit) !== -1) i += 1;
203
+ while (i < content.length && _isWhitespaceChar(content.charAt(i))) i += 1;
204
+ if (i === content.length) return true;
205
+ if (!allowComment) return false;
206
+ if (content.charAt(i) !== "#") return false;
207
+ // `.*` stops at a line terminator, and `$` without `m` is end-of-string, so a
208
+ // comment may not contain one. ECMAScript counts four, not just LF: U+2028 and
209
+ // U+2029 are line terminators to the grammar even though nothing else here
210
+ // treats them as breaks, and accepting them would admit a header the pattern
211
+ // refused.
212
+ var tail = content.slice(i);
213
+ for (var t = 0; t < tail.length; t += 1) {
214
+ if (_isLineTerminator(tail.charAt(t))) return false;
215
+ }
216
+ return true;
217
+ }
218
+
219
+ // `(^|\s)([&*])([A-Za-z0-9_][A-Za-z0-9_-]*)` — an anchor or alias sigil that
220
+ // opens a token. Returns the index of the match INCLUDING the leading separator,
221
+ // matching what the pattern captured, so the reported line is unchanged.
222
+ var _NAME_HEAD = codepointClass.ASCII_ALNUM + "_";
223
+ var _NAME_TAIL = codepointClass.ASCII_ALNUM + "_-";
224
+
225
+ function _findAnchorOrAlias(text) {
226
+ for (var i = 0; i < text.length; i += 1) {
227
+ var ch = text.charAt(i);
228
+ if (ch !== "&" && ch !== "*") continue;
229
+ var atStart = i === 0;
230
+ if (!atStart && !_isWhitespaceChar(text.charAt(i - 1))) continue;
231
+ if (_NAME_HEAD.indexOf(text.charAt(i + 1)) === -1 || text.charAt(i + 1) === "") continue;
232
+ return { index: atStart ? i : i - 1, sigil: ch };
233
+ }
234
+ return null;
235
+ }
236
+
237
+ // `(^|[\s-])(!{1,2}[A-Za-z<])` — one or two `!` opening a tag.
238
+ function _findTag(text) {
239
+ for (var i = 0; i < text.length; i += 1) {
240
+ if (text.charAt(i) !== "!") continue;
241
+ var atStart = i === 0;
242
+ var before = text.charAt(i - 1);
243
+ if (!atStart && !(before === "-" || _isWhitespaceChar(before))) continue;
244
+ // The pattern is greedy, so it takes two `!` when both are present.
245
+ var after = text.charAt(i + 1) === "!" ? text.charAt(i + 2) : text.charAt(i + 1);
246
+ if (after.length !== 1) continue;
247
+ // Digits count. The pattern this replaces used `[A-Za-z<]` while the comment
248
+ // above it said "alphanumeric or `<`", and the code was the weaker of the
249
+ // two: `a: !123` is a local tag and parsed as the plain string "!123"
250
+ // instead of being refused, so the documented ban had a hole in it. This is
251
+ // not a regression from the rewrite — `main` behaves the same way — but the
252
+ // promise is that tags are refused, so the stricter reading wins.
253
+ if (after !== "<" && codepointClass.ASCII_ALNUM.indexOf(after) === -1) continue;
254
+ return { index: atStart ? i : i - 1 };
255
+ }
256
+ return null;
257
+ }
258
+
259
+ // `(^|\n)%(YAML|TAG)\b` — a directive at column zero.
260
+ var _DIRECTIVE_NAMES = ["YAML", "TAG"];
261
+
262
+ function _findDirective(text) {
263
+ for (var i = 0; i < text.length; i += 1) {
264
+ if (text.charAt(i) !== "%") continue;
265
+ var atStart = i === 0;
266
+ if (!atStart && text.charAt(i - 1) !== "\n") continue;
267
+ for (var d = 0; d < _DIRECTIVE_NAMES.length; d += 1) {
268
+ var name = _DIRECTIVE_NAMES[d];
269
+ if (text.slice(i + 1, i + 1 + name.length) !== name) continue;
270
+ // `\b` — the character after the name must not continue a word.
271
+ var next = text.charAt(i + 1 + name.length);
272
+ if (next.length === 1 && (_NAME_HEAD.indexOf(next) !== -1)) continue;
273
+ return { index: atStart ? i : i - 1, precededByNewline: !atStart };
274
+ }
275
+ }
276
+ return null;
277
+ }
104
278
 
105
279
  function _resolveScalar(s) {
106
280
  // Fall back to string for any token whose length exceeds the scalar cap
@@ -109,31 +283,31 @@ function _resolveScalar(s) {
109
283
  // type-inference regexes never see a pathologically long string.
110
284
  if (typeof s !== "string" || s.length > MAX_SCALAR_BYTES) return s;
111
285
  // Below: every regex test sees an `s` whose s.length <= MAX_SCALAR_BYTES.
112
- if (NULL_RE.test(s)) return null;
113
- if (BOOL_RE.test(s)) return s.toLowerCase() === "true";
286
+ if (_isNull(s)) return null;
287
+ if (_isBool(s)) return s.toLowerCase() === "true";
114
288
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
115
- if (INT_RE.test(s)) {
289
+ if (_isDecimalInt(s)) {
116
290
  var n = parseInt(s, 10);
117
291
  if (Number.isSafeInteger(n)) return n;
118
292
  return s; // fallback to string for huge ints (don't lose precision silently)
119
293
  }
120
294
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
121
- if (INT_OCT.test(s)) {
295
+ if (_isOctalInt(s)) {
122
296
  var oct = parseInt(s.substring(2), RADIX_OCTAL);
123
297
  if (Number.isSafeInteger(oct)) return oct;
124
298
  return s;
125
299
  }
126
300
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
127
- if (INT_HEX.test(s)) {
301
+ if (_isHexInt(s)) {
128
302
  var hex = parseInt(s.substring(2), RADIX_HEX);
129
303
  if (Number.isSafeInteger(hex)) return hex;
130
304
  return s;
131
305
  }
132
306
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
133
- if (FLOAT_INF.test(s)) return s.charAt(0) === "-" ? -Infinity : Infinity;
134
- if (FLOAT_NAN.test(s)) return NaN;
307
+ if (_isInfinity(s)) return s.charAt(0) === "-" ? -Infinity : Infinity;
308
+ if (_isNotANumber(s)) return NaN;
135
309
  // s.length <= MAX_SCALAR_BYTES asserted at function entry above.
136
- if (FLOAT_RE.test(s)) {
310
+ if (_isFloat(s)) {
137
311
  var f = parseFloat(s);
138
312
  if (!isNaN(f)) return f;
139
313
  }
@@ -179,7 +353,9 @@ function parse(input, opts) {
179
353
  _preValidate(input);
180
354
 
181
355
  // Normalize line endings: CRLF / CR → LF for consistent line-based work.
182
- input = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
356
+ // Normalise CRLF and lone CR to LF. splitLinesAny treats all three as one
357
+ // break, so rejoining with "\n" is the same substitution in one pass.
358
+ input = codepointClass.splitLinesAny(input).join("\n");
183
359
 
184
360
  // Split into raw lines preserving line numbers.
185
361
  var rawLines = input.split("\n");
@@ -212,11 +388,11 @@ function parse(input, opts) {
212
388
  // Reject any later `---` or `...` (multi-document streams not supported).
213
389
  var idx = 0;
214
390
  while (idx < lines.length && (lines[idx].isBlank || lines[idx].isComment)) idx += 1;
215
- if (idx < lines.length && /^---(\s|$)/.test(lines[idx].content)) idx += 1;
391
+ if (idx < lines.length && _isDocumentMarker(lines[idx].content, "---")) idx += 1;
216
392
  // Subsequent doc markers anywhere = reject.
217
393
  for (var j = idx; j < lines.length; j++) {
218
394
  var c = lines[j].content;
219
- if (/^---(\s|$)/.test(c) || /^\.\.\.(\s|$)/.test(c)) {
395
+ if (_isDocumentMarker(c, "---") || _isDocumentMarker(c, "...")) {
220
396
  throw new SafeYamlError(
221
397
  "multi-document YAML streams are not supported",
222
398
  "yaml/multi-document", lines[j].lineNumber, 1
@@ -277,7 +453,7 @@ function parse(input, opts) {
277
453
  }
278
454
 
279
455
  // Block scalar header: `|` or `>` (with optional chomp/indent indicator)
280
- if (/^[|>][-+]?[0-9]?\s*(#.*)?$/.test(content)) {
456
+ if (_isBlockScalarHeader(content, true)) {
281
457
  return _parseBlockScalar(k, indent, content);
282
458
  }
283
459
 
@@ -398,7 +574,8 @@ function parse(input, opts) {
398
574
  // 1. if there's content after the colon, that's a flow value or
399
575
  // a plain scalar continuation on the same line.
400
576
  // 2. otherwise the value lives on subsequent more-indented lines.
401
- var afterColon = ln.content.substring(keyRange.valueStart).replace(/^[ \t]+/, "");
577
+ var afterColon = codepointClass.trimChars(
578
+ ln.content.substring(keyRange.valueStart), " \t", { trailing: false });
402
579
  // Strip end-of-line comment
403
580
  afterColon = _stripEolComment(afterColon);
404
581
  var value;
@@ -406,7 +583,7 @@ function parse(input, opts) {
406
583
  // Inline value on this line.
407
584
  if (afterColon.charAt(0) === "|" || afterColon.charAt(0) === ">") {
408
585
  // Block scalar header inline with key
409
- if (!/^[|>][-+]?[0-9]?\s*$/.test(afterColon)) {
586
+ if (!_isBlockScalarHeader(afterColon, false)) {
410
587
  throw new SafeYamlError("malformed block scalar header",
411
588
  "yaml/bad-block-scalar", ln.lineNumber, ln.indent + 1);
412
589
  }
@@ -506,7 +683,8 @@ function parse(input, opts) {
506
683
  // branch below) using _parseFlowValue's end position, then return the value
507
684
  // via the direct parser so the shape is identical to the pre-existing path.
508
685
  var fend = _parseFlowValue(t, 0, lineNumber, col, 0).nextPos;
509
- var afterFlow = t.slice(fend).replace(/^\s+/, "");
686
+ var afterFlow = codepointClass.trimRanges(
687
+ t.slice(fend), codepointClass.WHITESPACE_RANGES, { trailing: false });
510
688
  if (afterFlow.length > 0 && afterFlow.charAt(0) !== "#") {
511
689
  throw new SafeYamlError("unexpected content after flow collection",
512
690
  "yaml/trailing-content", lineNumber, col);
@@ -517,7 +695,8 @@ function parse(input, opts) {
517
695
  if (t.charAt(0) === '"') {
518
696
  var dq = _decodeDoubleQuoted(t, lineNumber, col);
519
697
  var afterDq = _trailingAfterQuoted(t, '"');
520
- if (afterDq.length > 0 && afterDq.replace(/^\s+/, "") !== "") {
698
+ if (afterDq.length > 0 &&
699
+ codepointClass.trimRanges(afterDq, codepointClass.WHITESPACE_RANGES, { trailing: false }) !== "") {
521
700
  throw new SafeYamlError("unexpected content after quoted string",
522
701
  "yaml/trailing-content", lineNumber, col);
523
702
  }
@@ -759,7 +938,7 @@ function parse(input, opts) {
759
938
  }
760
939
  case "U": {
761
940
  var hex8 = raw.substring(i + 2, i + 10);
762
- if (!/^[0-9a-fA-F]{8}$/.test(hex8)) {
941
+ if (hex8.length !== 8 || !codepointClass.isRunOf(hex8, codepointClass.ASCII_HEX)) {
763
942
  throw new SafeYamlError("bad \\U escape", "yaml/bad-escape", lineNumber, col + i);
764
943
  }
765
944
  var code = parseInt(hex8, RADIX_HEX);
@@ -904,7 +1083,7 @@ function parse(input, opts) {
904
1083
 
905
1084
  if (chomp === "-") {
906
1085
  // strip — remove trailing newline(s)
907
- body = body.replace(/\n+$/, "");
1086
+ body = codepointClass.trimRanges(body, [0x0A], { leading: false });
908
1087
  } else if (chomp === "+") {
909
1088
  // keep — restore trailing blanks we popped
910
1089
  body += "\n".repeat(trailingBlanks);
@@ -1026,14 +1205,12 @@ function _preValidate(input) {
1026
1205
  // (or start of value position). A simple heuristic: any unescaped `&`
1027
1206
  // that's followed by an identifier char and is preceded by space or
1028
1207
  // line start is an anchor. Same for `*`.
1029
- var anchorOrAliasRe = /(^|\s)([&*])([A-Za-z0-9_][A-Za-z0-9_-]*)/;
1030
- var m = safe.match(anchorOrAliasRe);
1208
+ var m = _findAnchorOrAlias(safe);
1031
1209
  if (m) {
1032
- var posIdx = safe.indexOf(m[0]);
1033
- var lineCount = safe.substring(0, posIdx).split("\n").length;
1210
+ var lineCount = safe.substring(0, m.index).split("\n").length;
1034
1211
  throw new SafeYamlError(
1035
- m[2] === "&" ? "anchors are not supported" : "aliases are not supported",
1036
- m[2] === "&" ? "yaml/anchors-banned" : "yaml/aliases-banned",
1212
+ m.sigil === "&" ? "anchors are not supported" : "aliases are not supported",
1213
+ m.sigil === "&" ? "yaml/anchors-banned" : "yaml/aliases-banned",
1037
1214
  lineCount, 1
1038
1215
  );
1039
1216
  }
@@ -1041,21 +1218,17 @@ function _preValidate(input) {
1041
1218
  // Tags: `!` at start of value or after `: ` / `- `. False-positive risk:
1042
1219
  // "key: !something" vs "key: ![bracket". We match `!` followed by
1043
1220
  // alphanumeric or `<`.
1044
- var tagRe = /(^|[\s-])(!{1,2}[A-Za-z<])/;
1045
- var mt = safe.match(tagRe);
1221
+ var mt = _findTag(safe);
1046
1222
  if (mt) {
1047
- var tagIdx = safe.indexOf(mt[0]);
1048
- var tagLine = safe.substring(0, tagIdx).split("\n").length;
1223
+ var tagLine = safe.substring(0, mt.index).split("\n").length;
1049
1224
  throw new SafeYamlError("tags are not supported",
1050
1225
  "yaml/tags-banned", tagLine, 1);
1051
1226
  }
1052
1227
 
1053
1228
  // Directives: `%YAML` or `%TAG` at column 0
1054
- var dirRe = /(^|\n)%(YAML|TAG)\b/;
1055
- var md = safe.match(dirRe);
1229
+ var md = _findDirective(safe);
1056
1230
  if (md) {
1057
- var dirIdx = safe.indexOf(md[0]);
1058
- var dirLine = safe.substring(0, dirIdx).split("\n").length + (md[1] === "\n" ? 1 : 0);
1231
+ var dirLine = safe.substring(0, md.index).split("\n").length + (md.precededByNewline ? 1 : 0);
1059
1232
  throw new SafeYamlError("directives are not supported",
1060
1233
  "yaml/directives-banned", dirLine, 1);
1061
1234
  }
@@ -158,17 +158,19 @@ function verify(opts) {
158
158
  var toSign = id + "." + ts + "." + bodyBuf.toString("utf8");
159
159
  var expected = nodeCrypto.createHmac("sha256", opts.secret).update(toSign).digest("base64");
160
160
  // Multi-version: signature header is `v1,<sig> v2,<sig>` etc.
161
+ // Every v1 signature in the header is compared, with no break: stopping at
162
+ // the first match let the response time report the POSITION of the matching
163
+ // signature within a multi-version header, which is a property of the
164
+ // sender's key rotation and not something a verifier should hand back.
161
165
  var parts = sigHeader.split(" ");
162
- var any = false;
166
+ var offered = [];
163
167
  for (var p = 0; p < parts.length; p += 1) {
164
168
  var pair = parts[p].split(",");
165
169
  if (pair.length !== 2) continue;
166
170
  if (pair[0] !== "v1") continue;
167
- if (bCrypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(pair[1], "utf8"))) {
168
- any = true;
169
- break;
170
- }
171
+ offered.push(Buffer.from(pair[1], "utf8"));
171
172
  }
173
+ var any = bCrypto.timingSafeEqualAny(Buffer.from(expected, "utf8"), offered);
172
174
  if (!any) {
173
175
  throw new StandardWebhooksError("standard-webhooks/bad-signature",
174
176
  "verify: no v1 signature matched");
package/lib/totp.js CHANGED
@@ -282,6 +282,12 @@ function verify(secret, code, opts) {
282
282
  var userCode = String(code).replace(/[\s.\-_]/g, "").padStart(resolved.digits, "0");
283
283
  var userBuf = Buffer.from(userCode);
284
284
 
285
+ // Every step in the drift window is compared, even after one matches. Returning
286
+ // from inside the loop would make the response time report WHICH step matched,
287
+ // and that is the clock offset between the authenticator and the server — a
288
+ // value derived from the shared secret's timeline that a caller submitting
289
+ // codes can otherwise only guess at.
290
+ var matchedStep = null;
285
291
  for (var d = -resolved.driftSteps; d <= resolved.driftSteps; d++) {
286
292
  var step = currentStep + d;
287
293
  if (lastUsedStep !== null && step <= lastUsedStep) continue; // reject replays at-or-below the last accepted step
@@ -293,11 +299,14 @@ function verify(secret, code, opts) {
293
299
  try { expected = _hotp(secret, step, resolved); }
294
300
  catch (_e) { return false; }
295
301
  var expectedBuf = Buffer.from(expected);
302
+ // The comparison is not guarded by `matchedStep === null` — that would
303
+ // short-circuit the call away once something matched, which is the early
304
+ // exit wearing a different syntax. The earliest match still wins.
296
305
  if (timingSafeEqual(expectedBuf, userBuf)) {
297
- return step;
306
+ if (matchedStep === null) matchedStep = step;
298
307
  }
299
308
  }
300
- return false;
309
+ return matchedStep === null ? false : matchedStep;
301
310
  }
302
311
 
303
312
  function uri(secret, account, opts) {
@@ -197,17 +197,19 @@ function verify(opts) {
197
197
  var signed = Buffer.concat([Buffer.from(tsRaw + ".", "utf8"), bodyBuf]);
198
198
  var expected = bCrypto.hmac(secretBuf, signed, nodeAlg);
199
199
  var expectedBuf = Buffer.from(expected, "utf8");
200
- var matched = false;
200
+ // Every offered signature is compared. The `break` this replaced ended the
201
+ // loop at the first match, so a header whose first signature matched
202
+ // answered sooner than one whose last did — the position of the sender's
203
+ // current key inside its rotation, reported by timing.
204
+ //
205
+ // timingSafeEqualAny applies the same length pre-check per candidate; a
206
+ // wrong-length candidate cannot be the digest (the hex length is fixed by
207
+ // the algorithm, and is not secret), so it leaks nothing.
208
+ var offeredBufs = [];
201
209
  for (var s = 0; s < sigs.length; s += 1) {
202
- // timingSafeEqual requires equal-length inputs; a wrong-length candidate
203
- // cannot be the digest (the hex length is fixed by the algorithm, and is
204
- // not secret), so the length pre-check leaks nothing.
205
- if (sigs[s].length === expected.length &&
206
- bCrypto.timingSafeEqual(expectedBuf, Buffer.from(sigs[s], "utf8"))) {
207
- matched = true;
208
- break;
209
- }
210
+ offeredBufs.push(Buffer.from(sigs[s], "utf8"));
210
211
  }
212
+ var matched = bCrypto.timingSafeEqualAny(expectedBuf, offeredBufs);
211
213
  if (!matched) {
212
214
  throw new WebhookHmacError("webhook-hmac/bad-signature",
213
215
  "verify: no '" + sigField + "' signature matched");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.35",
3
+ "version": "0.18.37",
4
4
  "description": "The Node framework that owns its stack.",
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:2105a3fd-0629-4778-a83f-39c23801d347",
5
+ "serialNumber": "urn:uuid:446f4ad2-1001-404f-a62c-c116788ed0b8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-18T15:12:45.318Z",
8
+ "timestamp": "2026-08-19T02:55:12.713Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.18.35",
22
+ "bom-ref": "@blamejs/core@0.18.37",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.35",
25
+ "version": "0.18.37",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.18.35",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.37",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.18.35",
57
+ "ref": "@blamejs/core@0.18.37",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]