@blamejs/core 0.15.48 → 0.15.49

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
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.49 (2026-06-28) — **`b.crypto.httpSig` now canonicalizes `@query-param` names and values per RFC 9421 §2.2.8, so its HTTP Message Signatures interoperate with conformant peers.** b.crypto.httpSig built the signature base for a @query-param component from the raw on-wire query bytes — the name was matched with encodeURIComponent and the value was emitted verbatim, with no decode-then-reencode. RFC 9421 §2.2.8 requires both the name and the value to be canonicalized: parsed as application/x-www-form-urlencoded (so a '+' and a %20 both become a space, and hex case is normalized) and then re-encoded, with a space rendered as %20. Because the framework signed and verified with the same raw bytes, blamejs-to-blamejs round-trips still worked, but a signature covering a query parameter whose name or value required encoding (a space, a '+', mixed or lowercase percent-encoding) did not match the base a conformant peer constructs — and an emitted identifier could even carry a literal space that the verifier then could not parse. Sign now emits the canonical percent-encoded name and signs the canonical value, and both sign and verify resolve the value through the same canonicalizer; the framework's base now matches the RFC's own published §2.2.8 example vectors. The whole-query @query component (§2.2.7), which the RFC defines as the raw encoded query, is unchanged, and signatures over plain-ASCII parameter names and values are byte-identical to before. **Fixed:** *HTTP Message Signatures @query-param canonicalization (RFC 9421 §2.2.8)* — b.crypto.httpSig now canonicalizes a @query-param component's name and value per RFC 9421 §2.2.8 — decode as application/x-www-form-urlencoded then re-encode, so a '+'-encoded space becomes %20, a %20 and a '+' resolve identically, and percent-encoding hex case is normalized to uppercase. Previously the name was matched with encodeURIComponent and the value was emitted raw, so a signature covering a query parameter that required encoding did not match the signature base a conformant RFC 9421 peer builds, and an emitted ;name="..." identifier could carry a literal space the verifier could not parse. Sign emits the canonical name and signs the canonical value; verify resolves the value through the same canonicalizer and reproduces the received identifier per §2.5. The framework's signature base now matches the RFC's published §2.2.8 example vectors. The whole-query @query component (§2.2.7) stays the raw encoded query, and signatures over plain-ASCII parameters are byte-identical to before.
12
+
11
13
  - v0.15.48 (2026-06-28) — **`b.network.dns.tsig` now accepts a message it signed with a non-default Original ID, fixing a sign/verify digest mismatch that made the originalId option non-functional (RFC 8945).** b.network.dns.tsig.verify restores the Original ID carried in the TSIG RDATA into the DNS message header before computing the HMAC, so a signed message survives an on-wire ID rewrite by a forwarder. b.network.dns.tsig.sign digested the message's current header ID instead of the Original ID, so any message signed with the originalId option set to a value other than the message's header ID produced a MAC the framework's own verify rejected (BADSIG) — the advertised originalId option was effectively non-functional for any non-default value. sign() now digests the Original-ID form, matching verify(); when originalId equals the message's header ID (the default) the digest is byte-for-byte identical to before, so existing signatures and the reference vectors are unaffected. **Fixed:** *TSIG: a message signed with a non-default Original ID now verifies (RFC 8945)* — b.network.dns.tsig.verify restores the Original ID carried in the TSIG RDATA into the DNS message header before computing the HMAC, so a signed message survives an on-wire ID rewrite by a forwarder. b.network.dns.tsig.sign digested the message's current header ID instead of the Original ID, so any message signed with the originalId option set to a value other than the message's header ID produced a MAC the framework's own verify rejected (BADSIG). sign() now digests the Original-ID form, matching verify(); when originalId equals the message's header ID (the default) the digest is byte-for-byte identical to before, so existing signatures and the reference vectors are unaffected.
12
14
 
13
15
  - v0.15.47 (2026-06-28) — **`b.mail.arc.verify` now returns chainStatus=pass for a cryptographically valid ARC chain — three defects in the ARC-Message-Signature verification path that made every real chain fail are fixed.** ARC-Message-Signature (AMS) verification reuses the DKIM verifier against a synthetic message, and three independent defects in that seam caused b.mail.arc.verify to reject every cryptographically valid ARC chain — its own and those from upstream relays. First, the AMS i= tag is an RFC 8617 instance number (1..50), not a DKIM Agent/User Identifier, but it was run through the RFC 6376 §3.5 AUID-must-be-a-subdomain-of-d= check, which permerrored every chain. Second, the synthetic renames the AMS header to DKIM-Signature so the DKIM verifier can find it, but the signature header was then canonicalized under that renamed field name instead of ARC-Message-Signature, so the b= signature never matched what the relay signed. Third, when sealing hop i>=2 the signer canonicalized a prior hop's ARC-Authentication-Results into the AMS instead of the current hop's, so multi-hop chains failed verification past the first hop. All three are fixed: arc.verify now confirms valid single- and multi-hop chains as cv=pass. The RFC 6376 §3.5 AUID/d= binding check remains a non-opt-out default on the public b.mail.dkim.verify path — the ARC reuse signal that skips it is framework-internal and cannot be set through the public options. **Fixed:** *ARC chain verification now succeeds for valid chains (it previously failed every one)* — b.mail.arc.verify reused the DKIM verifier to check each ARC-Message-Signature, and three defects in that path made it reject all cryptographically valid ARC chains. (1) The AMS i= instance number (RFC 8617 §4.1.2) was treated as a DKIM AUID and rejected by the RFC 6376 §3.5 AUID/d= binding check (permerror). (2) The synthetic message renames the AMS header to DKIM-Signature so the verifier can locate it, but the signature header was canonicalized under the renamed name rather than ARC-Message-Signature, so the b= value could never match the relay's signature. (3) For hops at instance 2 and beyond, b.mail.arc.sign canonicalized a prior hop's ARC-Authentication-Results into the AMS rather than the current hop's, breaking verification past the first hop. arc.verify now returns chainStatus=pass for valid single- and multi-hop chains; the DKIM AUID check stays enforced on the public DKIM verify path.
@@ -152,6 +152,54 @@ function _resolveDerivedComponent(name, msg) {
152
152
  }
153
153
  }
154
154
 
155
+ // The WHATWG application/x-www-form-urlencoded percent-encode set leaves ONLY
156
+ // these (all ASCII) bytes UNescaped: ALPHA / DIGIT / "*" / "-" / "." / "_".
157
+ // Note "~" (0x7E) IS encoded here (unlike RFC 3986 unreserved) and "*" (0x2A)
158
+ // is NOT — which is why encodeURIComponent (survivor set differs by ! ' ( ) *
159
+ // ~) cannot be reused. Membership is a single-char lookup in this set string.
160
+ var _QP_SURVIVORS =
161
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._";
162
+
163
+ // _canonQueryParamPart — RFC 9421 §2.2.8 canonicalization of a single
164
+ // @query-param name or value. Two stages:
165
+ // (1) parse per WHATWG application/x-www-form-urlencoded PARSING (§5.1):
166
+ // "+" -> SP, "%XX" -> decoded byte. This collapses the "+"-for-space and
167
+ // "%20"-for-space wire forms to the same decoded byte, and normalizes
168
+ // hex case.
169
+ // (2) re-encode per byte over UTF-8 WITHOUT the form serializer's
170
+ // space-as-plus rule, so SP -> "%20" (NOT "+"). RFC 9421 §2.2.8's own
171
+ // worked example is the governing interop vector: a wire value
172
+ // "with+plus+whitespace" canonicalizes to "with%20plus%20whitespace".
173
+ // The form serializer (URLSearchParams.toString) emits "+" and is
174
+ // deliberately not used for output; encodeURIComponent has the wrong
175
+ // survivor set. Every non-survivor byte is percent-encoded UPPERCASE.
176
+ // Malformed input degrades to the raw token rather than throwing mid-base
177
+ // build (defensive request-shape reader — return default, don't throw).
178
+ function _canonQueryParamPart(rawToken) {
179
+ var decoded;
180
+ try {
181
+ // Parse the token as a single form value. The form parser splits pairs on
182
+ // "&" only (the first "=" is consumed by the "k=" prefix), so a literal "&"
183
+ // in a caller-supplied decoded name (e.g. "a&b") must be escaped first or
184
+ // it would split the token and silently drop everything after it. A "%26"
185
+ // already present (an encoded "&") is left as-is and decodes normally.
186
+ decoded = new URLSearchParams("k=" + rawToken.replace(/&/g, "%26")).get("k");
187
+ if (decoded === null) decoded = "";
188
+ } catch (_e) {
189
+ return rawToken;
190
+ }
191
+ var bytes = Buffer.from(decoded, "utf8");
192
+ var out = "";
193
+ for (var i = 0; i < bytes.length; i++) {
194
+ var b = bytes[i];
195
+ var ch = String.fromCharCode(b);
196
+ out += _QP_SURVIVORS.indexOf(ch) !== -1
197
+ ? ch
198
+ : "%" + b.toString(16).toUpperCase().padStart(2, "0");
199
+ }
200
+ return out;
201
+ }
202
+
155
203
  // _resolveQueryParam — RFC 9421 §2.2.8 — covered identifier of the
156
204
  // shape `"@query-param";name="k"` (the name parameter selects which
157
205
  // query-string parameter participates in the signature base).
@@ -162,21 +210,46 @@ function _resolveQueryParam(msg, paramName) {
162
210
  "httpSig: @query-param;name=" + JSON.stringify(paramName) + " but URL has no query");
163
211
  }
164
212
  var pairs = search.split("&");
165
- // RFC 9421 §2.2.8 — names compare without percent-decoding (server
166
- // and signer must agree on the literal bytes). The framework follows
167
- // the spec strictly: literal compare on encoded names.
168
- var encName = encodeURIComponent(paramName);
213
+ // RFC 9421 §2.2.8 — canonicalize BOTH the requested name and each wire name
214
+ // token (decode then re-encode) and compare canonical forms, so "+"/"%20"/
215
+ // hex-case/"*"-vs-"%2A" wire variations all match; return the canonicalized
216
+ // value (§2.2.8 step 2).
217
+ var wantName = _canonQueryParamPart(paramName);
169
218
  for (var i = 0; i < pairs.length; i++) {
170
219
  var eq = pairs[i].indexOf("=");
171
220
  var rawName = eq === -1 ? pairs[i] : pairs[i].slice(0, eq);
172
- if (rawName === encName || rawName === paramName) {
173
- return eq === -1 ? "" : pairs[i].slice(eq + 1);
221
+ if (_canonQueryParamPart(rawName) === wantName) {
222
+ return _canonQueryParamPart(eq === -1 ? "" : pairs[i].slice(eq + 1));
174
223
  }
175
224
  }
176
225
  throw _err("MISSING_QUERY_PARAM",
177
226
  "httpSig: @query-param;name=" + JSON.stringify(paramName) + " not present in URL");
178
227
  }
179
228
 
229
+ // _canonicalizeQueryParamIdentifiers — rewrite each covered identifier of the
230
+ // shape `@query-param;name="X"` so the name is the canonical RFC 9421 §2.2.8
231
+ // form. Applied once at sign() intake so the SAME canonical name appears in
232
+ // both the signed base (component line + @signature-params terminator) and the
233
+ // emitted Signature-Input header. Other components and other params (e.g. ;req)
234
+ // pass through verbatim. The verifier does NOT re-canonicalize the identifier —
235
+ // it reproduces Signature-Input byte-for-byte per §2.5 — but the shared
236
+ // _resolveQueryParam canonicalizes the value on both sides.
237
+ // Match the `;name="<sf-string body>"` parameter within a covered identifier.
238
+ // The body is a tempered quoted-string run ([^"\\] | \\.) so an escaped quote
239
+ // inside the name does not end it; linear, no backtracking. Only the name
240
+ // parameter is rewritten — any other params (e.g. ;req) are left untouched.
241
+ var _QP_NAME_PARAM_RE = /;name="((?:[^"\\]|\\.)*)"/;
242
+
243
+ function _canonicalizeQueryParamIdentifiers(covered) {
244
+ return covered.map(function (raw) {
245
+ if (raw.indexOf("@query-param;") !== 0) return raw;
246
+ return raw.replace(_QP_NAME_PARAM_RE, function (_m, body) {
247
+ var nameVal = structuredFields.unescapeSfStringBody(body);
248
+ return ";name=" + _sfQuotedString(_canonQueryParamPart(nameVal));
249
+ });
250
+ });
251
+ }
252
+
180
253
  // _resolveHeader — case-insensitive header lookup. RFC 9421 §2.1
181
254
  // requires obs-fold normalization (concat multi-values with ", ").
182
255
  function _resolveHeader(headers, name) {
@@ -331,7 +404,11 @@ function sign(msg, opts) {
331
404
  m.headers = Object.assign({}, m.headers, { "content-digest": digest });
332
405
  }
333
406
 
334
- var base = _buildSignatureBase(opts.covered, params, m);
407
+ // Canonicalize @query-param identifier names (RFC 9421 §2.2.8) once, so the
408
+ // identical canonical name appears in BOTH the signed base and the emitted
409
+ // Signature-Input header below.
410
+ var covered = _canonicalizeQueryParamIdentifiers(opts.covered);
411
+ var base = _buildSignatureBase(covered, params, m);
335
412
  var sig;
336
413
  try {
337
414
  sig = nodeCrypto.sign(null, base, opts.privateKey);
@@ -340,7 +417,7 @@ function sign(msg, opts) {
340
417
  }
341
418
  var sigB64 = sig.toString("base64");
342
419
 
343
- emittedHeaders["Signature-Input"] = label + "=" + _serializeCovered(opts.covered) +
420
+ emittedHeaders["Signature-Input"] = label + "=" + _serializeCovered(covered) +
344
421
  _serializeSigParams(params);
345
422
  emittedHeaders["Signature"] = label + "=:" + sigB64 + ":";
346
423
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.48",
3
+ "version": "0.15.49",
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:90ecb6b7-5585-460c-8b63-abf23c45b39f",
5
+ "serialNumber": "urn:uuid:6cc185bb-1bd0-499d-9ebe-d97e6e5d7471",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-28T21:46:48.435Z",
8
+ "timestamp": "2026-06-28T23:40:14.484Z",
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.15.48",
22
+ "bom-ref": "@blamejs/core@0.15.49",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.48",
25
+ "version": "0.15.49",
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.15.48",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.49",
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.15.48",
57
+ "ref": "@blamejs/core@0.15.49",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]