@blamejs/core 0.16.20 → 0.16.21
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 +2 -0
- package/lib/auth/jwt.js +34 -0
- package/lib/auth/saml.js +19 -1
- package/lib/guard-jwt.js +18 -2
- package/lib/http-message-signature.js +34 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.16.x
|
|
10
10
|
|
|
11
|
+
- v0.16.21 (2026-07-12) — **Bind the declared JWS/HTTP-signature algorithm to the key's real type across b.auth.jwt and b.crypto.httpSig (closing an algorithm-confusion allowlist bypass), refuse a JWT whose payload is not a JSON object in b.guardJwt, and enforce the SAML InResponseTo replay check on the holder-of-key confirmation.** Covering untested adversarial branches across the token and assertion verifiers surfaced a set of fail-open auth defects, fixed at the root. b.auth.jwt.verify gated its algorithm allowlist on the self-declared JWS alg header while node:crypto verified against the key's intrinsic type, so a token signed by an ML-DSA key but declaring a SLH-DSA alg passed a SLH-only allowlist and verified against the ML-DSA key -- a full algorithm-allowlist bypass (CWE-347). The declared alg is now bound to the key's real type at both sign and verify. The same class existed unenforced in b.crypto.httpSig (a peer could sign with a classical ed25519 key while declaring the PQC alg ml-dsa-65, emitting an authenticated but false PQC-signed label); it now binds the alg to the key's type at sign and verify too. b.guardJwt skipped its entire claim-sanity block (required-claims + exp/nbf/iat) when the payload did not decode to a JSON object, so a token carrying no readable claims set was admitted at strict; a non-object payload is now refused symmetrically with the header. And b.auth.saml validated InResponseTo on the Bearer subject-confirmation but not the holder-of-key one, silently dropping an operator's replay binding on every HoK assertion; the holder-of-key path now applies the identical check. **Security:** *b.auth.jwt binds the declared algorithm to the key's real type (algorithm-confusion bypass)* — verify() constrained the algorithm allowlist against the self-declared JWS `alg` header -- a value the token issuer controls -- while node:crypto.verify(null, ...) selects the actual algorithm from the KeyObject's intrinsic type. The two could diverge: a token signed by an ML-DSA-87 key but declaring alg="SLH-DSA-SHAKE-256f" passed an SLH-only allowlist and was verified against the ML-DSA public key, and sign() could emit a token whose header alg misstated its own signing algorithm -- the exact mislabeled artifact the verify bypass consumes (CWE-347 algorithm confusion). Both sign() and verify() now assert the declared alg matches the resolved key's asymmetricKeyType, so the allowlist genuinely constrains which algorithm authenticated the token; a matched alg/key round-trip is unaffected. · *b.crypto.httpSig binds the declared algorithm to the key's real type* — HTTP Message Signature sign() and verify() validated the declared `alg` against the supported set but never bound it to the key's type -- so a peer could sign with a classical ed25519 key while declaring the post-quantum alg ml-dsa-65 (an authenticated but false PQC-signed label a verifier trusting the label would honor), the same algorithm-confusion class. sign() now refuses to emit a token whose alg misstates the private key's type, and verify() refuses -- before the crypto check -- a resolved key whose type differs from the declared alg. Legitimate ed25519 and ml-dsa-65 round-trips are unaffected. · *b.guardJwt refuses a JWT whose payload is not a JSON object* — The guard skipped its entire payload claim-sanity block -- required-claims and exp/nbf/iat -- whenever the payload segment did not decode to a JSON object (undecodable base64url, non-JSON bytes, a JSON primitive, or a JSON array), so a token carrying no readable claims set was admitted at the strict profile (validate returned ok and gate returned serve), defeating the guard's advertised missing-claim refusals. A non-object payload is now refused with a high-severity payload-decode finding, symmetric with the header-decode path, at every profile. · *b.auth.saml enforces the InResponseTo replay check on holder-of-key confirmations* — verifyResponse validated the SubjectConfirmationData InResponseTo against the expected AuthnRequest ID on the Bearer confirmation path but not the holder-of-key one, so an operator that opted into InResponseTo binding (solicited-response / replay correlation) silently lost it on every holder-of-key assertion -- a HoK assertion with a mismatched or absent InResponseTo was accepted, and the returned value was hardcoded to null. The holder-of-key path now applies the identical constant-time InResponseTo check the Bearer path uses and echoes the validated value, so the replay binding holds on both confirmation methods.
|
|
12
|
+
|
|
11
13
|
- v0.16.20 (2026-07-12) — **Make the ClamAV scan-verdict classifier fail closed on a coalesced infected+clean reply, refuse a session under a strict device-binding threshold when the anomaly score can't be computed, guard the MessageFormat select renderer against prototype-chain keys, and reject a non-canonical webhook timestamp.** Covering untested adversarial branches across the mail scanner, the session device-binding verifier, the i18n message renderer, and the webhook verifier surfaced a set of fail-open and correctness defects, fixed at the root. The mail scanner's ClamAV INSTREAM reply classifier tested the benign OK token before the malign FOUND token, so a reply carrying both (a coalesced or stale-then-fresh reply on a reused connection) resolved to clean and delivered an infected message; the classifier now tests FOUND, then ERROR, then OK, so a malign signal always dominates and only an OK with no FOUND/ERROR is clean. Session verification under a strict maxAnomalyScore device-binding policy admitted a relocated device whenever no decisive anomaly score could be produced (no scorer supplied, or a scorer that threw or returned a non-finite value) -- it now fails closed, matching the unreadable-binding discipline. The ICU-style MessageFormat select renderer looked up an end-user-supplied select value with a bare object index, so a value naming an Object.prototype member (__proto__ / constructor / toString) returned an inherited member and either rendered garbage or threw a render-time DoS; every case lookup is now own-property only. And the webhook verifier accepted any string that Number() maps to the signed timestamp (scientific, trailing .0, hex, leading zero/plus), making the authenticated t= field malleable; it now requires the canonical decimal. **Fixed:** *b.webhook verifier rejects a non-canonical authenticated timestamp* — The verifier parsed the authenticated t= field with Number() and validated only the post-coercion integer, then re-composed the signed string from that integer -- so any string Number() maps to the same value (scientific 1.7e9, a trailing .0, hex 0x..., a leading zero/plus/whitespace) re-canonicalized into the identical signed string and verified, making the authenticated timestamp field malleable. The verifier now also requires the raw bytes to equal the canonical decimal the signer emits, the same strict digits-only parse the Stripe-compatible verifier already applied; the only legitimate producer (b.webhook.signer) emits the canonical form, so there is no interop cost. · *b.i18n.dir honors a custom RTL language configured in any case* — dir() folds the requested locale's primary subtag to lower case before the RTL-membership test, but a custom rtlLanguages list was stored verbatim, so an operator-supplied entry like "AR" or "CKB" never matched the lower-cased lookup and the language rendered left-to-right. The configured list is now folded to lower case the same way the lookup is, so a custom RTL entry matches regardless of case. · *b.i18n.t resolves a leaf shadowed by a namespace in a more-specific locale* — The fallback-chain lookup returned the first locale where the dotted key resolved to any non-undefined value, including a nested namespace object -- so a namespace at that path in the requested locale halted the chain and shadowed a real translation leaf defined in a fallback locale, leaking the raw key into the UI (and has() reported false). The lookup now treats only a renderable leaf (a string or a plural-shaped object) as a hit and keeps walking the chain past a namespace object, so a leaf in a fallback locale resolves. **Security:** *b.mail.scan fails closed on a coalesced infected+clean ClamAV reply* — The ClamAV INSTREAM reply classifier tested the benign `stream: OK` token before the malign `... FOUND` token, so a reply that carried both -- a coalesced or stale-then-fresh reply on a reused connection, or an intermediary that concatenates two responses -- resolved to a clean verdict and delivered an infected message (a scan bypass). The classifier is a security verdict, so it now tests FOUND (infected) first, then ERROR (do-not-deliver), then OK (clean); an unrecognized shape is still an error. A reply containing a FOUND signal can no longer be downgraded to clean by an adjacent OK token. · *b.session.verify fails closed under a strict anomaly threshold when the score can't be computed* — Under the strict maxAnomalyScore device-binding policy, verify() only assigned a fingerprint anomaly score when the supplied scorer returned a finite number. With no scorer (the option is documented as pairing with one but is omittable), or a scorer that threw or returned a non-finite value, the score stayed null and the refusal guard (score present AND above threshold) was false, so verify RETURNED a session bound to one device when presented from another. An uncomputable anomaly score on genuine fingerprint drift now fails closed (verify returns null), matching the fail-closed discipline the unreadable-binding branch already applied; the legitimate path (a finite score at or below the threshold admits, with the score surfaced) is unchanged. · *b.i18n MessageFormat select renderer guards against prototype-chain keys* — The ICU-style MessageFormat select renderer resolved a case with a bare object index on an end-user-supplied select value, so a value naming an Object.prototype member -- __proto__ / constructor / toString / hasOwnProperty / valueOf -- returned a truthy INHERITED member, bypassing the `other` fallback: an inherited value with no length rendered as empty (silent output corruption), and an inherited function threw when rendered as a non-array (a request-level DoS). Every select/plural case lookup is now own-property only, so an attacker-supplied select value that names a prototype member falls through to the `other` case. **Detectors:** *MessageFormat case lookups must be own-property* — A structural check asserts that the MessageFormat renderer resolves a select/plural case through the own-property helper rather than a bare object index, so an end-user-supplied select value can never reach the prototype chain and re-open the corruption/DoS class.
|
|
12
14
|
|
|
13
15
|
- v0.16.19 (2026-07-12) — **Close a family of entity- and whitespace-hidden dangerous-URL-scheme and CSS-injection bypasses across b.guardHtml / b.guardSvg / b.guardMarkdown behind one shared normalizer, bound a DNS decompression-pointer name bomb, refuse an ambiguous audit-anchor canonicalization, and stop an empty-string sealed field from crashing an AAD-table write.** Covering untested adversarial branches across the content guards, a DNS wire parser, the audit-log anchor, and the sealed-field codec surfaced a family of fail-open defects, fixed at the root. The most serious span the content-guard URL-scheme and CSS-injection denylists: a browser removes tab/lf/cr from anywhere in a URL and trims a leading/trailing control-or-space run before resolving the scheme, and character-reference-decodes a style attribute before the CSS parser sees it, so an entity-encoded tab, an entity-encoded leading space, an HTML named entity, or an entity-encoded CSS token could all navigate/execute while reading past a denylist that matched the raw bytes. b.guardHtml missed the whitespace normalization entirely and matched CSS against raw bytes; b.guardMarkdown decoded only numeric entities; b.guardSvg had a partial fix. All three now route their scheme and CSS-token checks through one pair of shared codepoint-class normalizers, so no guard can strip a different set of encodings than another. A DNS compression-pointer chain could decompress a single name past the RFC 1035 255-octet / label caps (a decompression-amplification DoS); the audit-log anchor's newline-delimited signed bytes let one signature validate for several different tip/previous-tip splits (defeating the linkage tamper-evidence); an SVG animation element permitted under a permissive profile lost its open tag; and an empty-string sealed column crashed an AAD-table insert. **Fixed:** *b.guardSvg preserves a profile-permitted safe animation element* — Under a permissive profile that allows animation, a safe animation element (for example an <animate> whose attributeName targets a visual property) had its open tag dropped while its end tag was still emitted, leaving an orphan close tag and silently stripping an element the profile permits. The sanitize path now affirmatively re-permits the safe-target case; an unsafe-target animation (attributeName=href and similar) is still dropped and neutralized. · *b.cryptoField.sealRow seals an empty-string field as a tamper-evident envelope* — sealRow dispatched every non-null value -- including an empty string -- to one of three envelope-seal branches that disagreed on empty plaintext: the plain and per-row-key branches handled it, but the AAD branch is fail-closed and threw, so a write to an AAD-sealed table whose sealed column held an empty string crashed the insert. An empty string now encodes to a non-empty typed marker before sealing, so it becomes a real authenticated envelope (never a bare plaintext empty string) that round-trips to an empty string on read. On the read side, a bare empty string found in an AAD-bound or per-row-key sealed column -- which after this change can only be an envelope-downgrade by a DB-write attacker -- fails closed (the cell is nulled and audited) instead of being accepted as a valid empty value, so the sealed-column tamper-evidence guarantee holds for empty values too. **Security:** *b.guardHtml / b.guardSvg / b.guardMarkdown refuse entity- and whitespace-hidden dangerous URL schemes* — The URL-scheme denylist on every URL-bearing attribute (href / xlink:href / markdown link, image, autolink, reference-definition) is resolved after normalizing the value the way a browser does: character-reference decoding (numeric AND the HTML5 named-entity ASCII subset browsers honor, e.g. 	 / :), removing tab/lf/cr from anywhere, and trimming a leading/trailing C0-control-or-space run. Previously b.guardHtml stripped neither tab/lf/cr nor an entity-encoded leading space, and b.guardMarkdown decoded only numeric entities, so payloads such as java	script:, java	script:, and  javascript: resolved to an empty scheme and were served/rendered as safe while a browser executed them as javascript:. The normalization now lives in two shared codepoint-class primitives (b.codepointClass.decodeMarkupEntities and b.codepointClass.stripUrlSchemeWhitespace) that all three guards compose, so no guard can fold away a different set of encodings than another. · *b.guardHtml / b.guardSvg detect entity-encoded and whitespace-hidden CSS injection in style attributes* — A style attribute is HTML/XML character-reference-decoded before the CSS parser sees it, so width:expression(, background:url(javascript:...), and behavior:url(...) reach CSS as expression( / url(javascript:...) / behavior: with no literal danger token in the raw bytes. The CSS-danger check matched the raw attribute value, so these bypassed the denylist and were served verbatim (a stored CSS-injection XSS). Both guards now match the entity-decoded value AND fold the URL-scheme whitespace a browser strips inside url(...) (tab / lf / cr), so an entity-hidden tab in a CSS URL scheme (url(java	script:) reaching CSS as url(java<TAB>script:), which navigates as javascript:) can no longer defeat the contiguous javascript: pattern either. A plain (unencoded) dangerous style is still caught and a benign style is untouched. · *b.safeDns bounds a decompressed name across the whole compression-pointer chain* — readName tracked its per-name octet and label budgets per stack frame, so each compression-pointer jump restarted a fresh RFC 1035 255-octet / label budget and a pointer was charged as only its 2 on-wire bytes. A crafted pointer chain therefore decompressed a single name well past the advertised 255-octet / label caps (a decompression-amplification DoS on untrusted DNS responses). The octet and label accountants now thread through the whole pointer chain, so the composite decompressed name is bounded by the same caps that bound a single in-line name; independent names are still measured independently and no name that decompresses within the caps is newly rejected. · *b.auditSign refuses an anchor whose fields carry the record delimiter* — The chain-anchor signed bytes are a newline-delimited layout of format / counter / tipHash / prevTipHash / createdAt. A literal newline inside any operator-influenced string field (format / tipHash / prevTipHash) let content migrate across a field boundary without changing the signed bytes, so one signature was valid for several different { tipHash, prevTipHash } splits -- an attacker could rewrite a stored anchor's apparent tip/linkage while keeping verify() happy, defeating the prevTipHash linkage tamper-evidence. anchor() now refuses a delimiter-bearing field at sign time and verifyAnchor refuses one at verify time, so an ambiguous anchor can neither be minted nor accepted; every legitimate (delimiter-free) anchor is byte-identical and unaffected. **Detectors:** *Guard scheme extractors and CSS-danger checks must compose the shared normalizers* — Two structural checks assert that a content guard which resolves a URL scheme for a denylist routes the decoded value through b.codepointClass.stripUrlSchemeWhitespace, and that a guard's CSS-danger check matches the entity-decoded style value via b.codepointClass.decodeMarkupEntities -- so a future guard cannot silently fold away a narrower set of encodings than the browser and re-open the bypass class.
|
package/lib/auth/jwt.js
CHANGED
|
@@ -126,6 +126,27 @@ function _resolveAlgorithm(alg) {
|
|
|
126
126
|
return ALGORITHM_TO_NODE[alg];
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Bind the declared `alg` to the key that will actually produce/verify the
|
|
130
|
+
// signature. node:crypto.sign/verify with a null digest select the algorithm
|
|
131
|
+
// from the KeyObject's intrinsic type — NOT from the JWS `alg` header — so the
|
|
132
|
+
// header value is only an unauthenticated label unless we cross-check it here.
|
|
133
|
+
// Without this, verify()'s algorithm allowlist gates on that label while the
|
|
134
|
+
// crypto runs against whatever the key is: an ML-DSA-signed token can declare
|
|
135
|
+
// alg="SLH-DSA-SHAKE-256f" and pass an SLH-only allowlist (CWE-347 algorithm
|
|
136
|
+
// confusion / allowlist bypass), and sign() can emit a token whose header
|
|
137
|
+
// misstates its own signature algorithm. `alg` MUST already be a registry key
|
|
138
|
+
// (callers validate via _resolveAlgorithm or the verify allowlist gate).
|
|
139
|
+
function _assertAlgMatchesKey(alg, key, kind) {
|
|
140
|
+
var expectedNodeType = ALGORITHM_TO_NODE[alg];
|
|
141
|
+
var actual = key && typeof key.asymmetricKeyType === "string" ? key.asymmetricKeyType : null;
|
|
142
|
+
if (actual !== expectedNodeType) {
|
|
143
|
+
throw new AuthError("auth-jwt/alg-key-mismatch",
|
|
144
|
+
"declared alg '" + alg + "' requires a '" + expectedNodeType + "' key but the " +
|
|
145
|
+
kind + " key is '" + (actual || "unknown") + "' — the JWS alg header must be bound to " +
|
|
146
|
+
"the key's actual algorithm (CWE-347 algorithm confusion)");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
// ---- sign ----
|
|
130
151
|
|
|
131
152
|
async function sign(claims, opts) {
|
|
@@ -136,6 +157,10 @@ async function sign(claims, opts) {
|
|
|
136
157
|
var alg = opts.algorithm || DEFAULT_ALGORITHM;
|
|
137
158
|
_resolveAlgorithm(alg);
|
|
138
159
|
var key = _toKeyObject(opts.privateKey, "private");
|
|
160
|
+
// Refuse to emit a token whose header alg misstates the signing key's real
|
|
161
|
+
// algorithm — that mislabeled token is exactly what the verify-side
|
|
162
|
+
// allowlist bypass consumes. Config-time throw (operator catches the typo).
|
|
163
|
+
_assertAlgMatchesKey(alg, key, "private");
|
|
139
164
|
|
|
140
165
|
var nowMs = opts.now || Date.now();
|
|
141
166
|
var nowSec = Math.floor(nowMs / C.TIME.seconds(1));
|
|
@@ -306,6 +331,15 @@ async function verify(token, opts) {
|
|
|
306
331
|
"token alg='" + decoded.header.alg + "' is not in the allowed list [" + allowed.join(", ") + "]");
|
|
307
332
|
}
|
|
308
333
|
|
|
334
|
+
// The allowlist gate above only constrains the SELF-DECLARED header.alg — a
|
|
335
|
+
// label the issuer controls. node:crypto.verify(null, ...) selects the real
|
|
336
|
+
// algorithm from the KeyObject, so a token can pass an SLH-only allowlist
|
|
337
|
+
// while being verified with an ML-DSA key (or vice versa). Bind the declared
|
|
338
|
+
// alg to the resolved key's actual algorithm before verifying, so the
|
|
339
|
+
// allowlist genuinely constrains which algorithm authenticated the token
|
|
340
|
+
// (CWE-347). decoded.header.alg is a registry key here (it passed the gate).
|
|
341
|
+
_assertAlgMatchesKey(decoded.header.alg, key, "public");
|
|
342
|
+
|
|
309
343
|
var verified = false;
|
|
310
344
|
try {
|
|
311
345
|
verified = nodeCrypto.verify(null, Buffer.from(decoded.signingInput, "ascii"), key, decoded.signature);
|
package/lib/auth/saml.js
CHANGED
|
@@ -704,6 +704,24 @@ function create(opts) {
|
|
|
704
704
|
}
|
|
705
705
|
var recipHok = _attr(scdHok, "Recipient");
|
|
706
706
|
if (!recipHok || recipHok !== opts.assertionConsumerServiceUrl) continue; // §3.1→§4.1.4.2 — Recipient is mandatory; absent fails the endpoint binding
|
|
707
|
+
// §3.1→§4.1.4.2 — a solicited response's SubjectConfirmationData
|
|
708
|
+
// InResponseTo MUST equal the stored AuthnRequest ID. The Bearer path
|
|
709
|
+
// below enforces this when the operator supplies expectedInResponseTo;
|
|
710
|
+
// holder-of-key is a sibling reader of the SAME SubjectConfirmationData
|
|
711
|
+
// and applies the identical replay check. Omitting it let an operator
|
|
712
|
+
// that opted into InResponseTo binding silently lose it on every HoK
|
|
713
|
+
// confirmation (possession-proof is a separate control, not a licence
|
|
714
|
+
// to skip the solicited-response correlation the operator requested).
|
|
715
|
+
var irtHok = _attr(scdHok, "InResponseTo");
|
|
716
|
+
if (vopts.expectedInResponseTo) {
|
|
717
|
+
if (irtHok === null || irtHok === undefined ||
|
|
718
|
+
!timingSafeEqual(irtHok, vopts.expectedInResponseTo)) {
|
|
719
|
+
throw new AuthError("auth-saml/bad-in-response-to",
|
|
720
|
+
"SubjectConfirmation InResponseTo does not match expected " +
|
|
721
|
+
"AuthnRequest ID (replay defense)");
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
matchedInResponseTo = irtHok;
|
|
707
725
|
hokOk = true;
|
|
708
726
|
break;
|
|
709
727
|
}
|
|
@@ -836,7 +854,7 @@ function create(opts) {
|
|
|
836
854
|
sessionIndex: sessionIndex,
|
|
837
855
|
attributes: attributes,
|
|
838
856
|
audience: audience,
|
|
839
|
-
inResponseTo: bearerOk ? matchedInResponseTo : null,
|
|
857
|
+
inResponseTo: (bearerOk || hokOk) ? matchedInResponseTo : null,
|
|
840
858
|
issuer: issuer,
|
|
841
859
|
};
|
|
842
860
|
}
|
package/lib/guard-jwt.js
CHANGED
|
@@ -312,9 +312,25 @@ function _detectIssues(input, opts) {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
// Payload claim sanity
|
|
315
|
+
// Payload claim sanity. A JWT claims set MUST be a JSON object (RFC 7519
|
|
316
|
+
// §7.2); a payload that does not decode to one — undecodable base64url,
|
|
317
|
+
// non-JSON bytes, a JSON primitive, or a JSON array — is refused
|
|
318
|
+
// symmetrically with the header-decode path above. Skipping it silently
|
|
319
|
+
// (the earlier behaviour) let the required-claims and exp/nbf/iat sanity
|
|
320
|
+
// checks never run, so a token carrying no readable claims set passed at
|
|
321
|
+
// strict with the guard's advertised required-claims enforcement bypassed.
|
|
316
322
|
var payload = _b64urlDecodeJson(payloadSeg);
|
|
317
|
-
if (payload
|
|
323
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
324
|
+
issues.push({
|
|
325
|
+
kind: "payload-decode", severity: "high",
|
|
326
|
+
ruleId: "jwt.payload-decode",
|
|
327
|
+
snippet: "JWT payload is not a decodable JSON object (a JWT claims " +
|
|
328
|
+
"set must be a JSON object per RFC 7519 §7.2) or contains " +
|
|
329
|
+
"prototype-pollution keys",
|
|
330
|
+
});
|
|
331
|
+
return issues;
|
|
332
|
+
}
|
|
333
|
+
{
|
|
318
334
|
var nowSec = Math.floor(Date.now() / 1000); // seconds-per-millisecond conversion
|
|
319
335
|
|
|
320
336
|
// exp in the past.
|
|
@@ -379,6 +379,24 @@ function sign(msg, opts) {
|
|
|
379
379
|
}
|
|
380
380
|
validateOpts.requireNonEmptyString(opts.privateKey,
|
|
381
381
|
"httpSig.sign: privateKey (PEM)", HttpSigError, "BAD_OPT");
|
|
382
|
+
// Bind the declared alg to the signing key's real type. SUPPORTED_ALGS names
|
|
383
|
+
// ARE node's asymmetricKeyType values ("ed25519" / "ml-dsa-65"), so signing
|
|
384
|
+
// an ed25519 key under alg="ml-dsa-65" would emit an authenticated `alg`
|
|
385
|
+
// label that misstates the real algorithm -- a false PQC-signed claim that a
|
|
386
|
+
// verifier trusting the label would honor. Refuse the mislabeled artifact at
|
|
387
|
+
// sign time (matching every other signature verifier in the framework and the
|
|
388
|
+
// verify-side check below; alg-confusion family, CWE-347).
|
|
389
|
+
// Parse only to read the type; an unparseable key is left for the sign step
|
|
390
|
+
// below to reject (SIGN_FAILED), preserving that contract. A parseable key
|
|
391
|
+
// whose type differs from the declared alg is the mislabel we refuse here.
|
|
392
|
+
var signKeyType = null;
|
|
393
|
+
try { signKeyType = nodeCrypto.createPrivateKey(opts.privateKey).asymmetricKeyType; }
|
|
394
|
+
catch (_e) { signKeyType = null; }
|
|
395
|
+
if (signKeyType !== null && signKeyType !== opts.alg) {
|
|
396
|
+
throw _err("BAD_OPT",
|
|
397
|
+
"httpSig.sign: alg '" + opts.alg + "' does not match the private key's type '" +
|
|
398
|
+
signKeyType + "' (the alg label must be bound to the key's real algorithm)");
|
|
399
|
+
}
|
|
382
400
|
if (!Array.isArray(opts.covered) || opts.covered.length === 0) {
|
|
383
401
|
throw _err("BAD_OPT", "httpSig.sign: covered must be a non-empty array");
|
|
384
402
|
}
|
|
@@ -614,6 +632,22 @@ function verify(msg, opts) {
|
|
|
614
632
|
if (typeof publicKeyPem !== "string" || publicKeyPem.length === 0) {
|
|
615
633
|
return { valid: false, reason: "unknown-keyid", keyid: p.keyid };
|
|
616
634
|
}
|
|
635
|
+
// Bind the (authenticated) declared alg to the resolved key's real type
|
|
636
|
+
// BEFORE the crypto check. nodeCrypto.verify(null, ...) selects the algorithm
|
|
637
|
+
// from the key, not from p.alg, so a key whose type differs from p.alg means
|
|
638
|
+
// the alg label misstates the real signature algorithm (e.g. a classical
|
|
639
|
+
// ed25519 key under a declared PQC alg="ml-dsa-65"). Refuse rather than verify
|
|
640
|
+
// under a mislabeled alg (alg-confusion family, CWE-347) -- SUPPORTED_ALGS
|
|
641
|
+
// names ARE the asymmetricKeyType values.
|
|
642
|
+
// Parse only to read the type; an unparseable key is left for the crypto
|
|
643
|
+
// step below to reject (verify-threw), preserving that contract. A parseable
|
|
644
|
+
// key whose type differs from the declared alg is the mislabel we refuse here.
|
|
645
|
+
var verifyKeyType = null;
|
|
646
|
+
try { verifyKeyType = nodeCrypto.createPublicKey(publicKeyPem).asymmetricKeyType; }
|
|
647
|
+
catch (_e) { verifyKeyType = null; }
|
|
648
|
+
if (verifyKeyType !== null && verifyKeyType !== p.alg) {
|
|
649
|
+
return { valid: false, reason: "alg-key-mismatch", alg: p.alg, keyType: verifyKeyType };
|
|
650
|
+
}
|
|
617
651
|
|
|
618
652
|
// If content-digest is covered, recompute and compare. RFC 9421 §B.2.5
|
|
619
653
|
// mandates that verifiers re-run the digest over the body — a stale
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:96b6222d-f898-4aa7-beb8-1b0c830965bd",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-12T14:22:26.336Z",
|
|
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.16.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.16.21",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
25
|
+
"version": "0.16.21",
|
|
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.16.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.16.21",
|
|
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.16.
|
|
57
|
+
"ref": "@blamejs/core@0.16.21",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|