@blamejs/core 0.16.20 → 0.16.22

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.22 (2026-07-12) — **Bind an OpenID-Federation entity's configuration to the keys its superior attests, bind the COSE null-digest signature algorithms (EdDSA / ML-DSA-87) to the key type, make the verifiable-credential COSE media-type header mandatory, and turn two PGP parse crashes into typed refusals.** Covering untested adversarial branches across the federation, COSE, verifiable-credential, and PGP verifiers surfaced a set of fail-open and crash defects, fixed at the root. b.auth.openidFederation.buildTrustChain verified each entity configuration only against its own self-published keys (integrity, not provenance) and never re-verified it against the keys the superior's subordinate statement attests, so an attacker controlling an entity's .well-known endpoint (but not its attested signing key) could serve forged metadata and have the chain accept it; each non-anchor entity's configuration is now bound to its superior-attested keys. b.cose.verify bound only the ECDSA algorithms to their curve and left the null-digest algorithms (EdDSA, ML-DSA-87) with no key binding, so node:crypto -- which dispatches on the key type, not the declared COSE alg -- accepted an EdDSA signature under an ML-DSA-only allowlist against an Ed25519 key (and the reverse); every signable COSE algorithm is now bound to its key type or curve, on both sign and verify. b.vc enforced the vc+cose / vp+cose media-type header only when present, so a COSE credential omitting it was accepted with no media-type binding; the header is now mandatory, symmetric with the JOSE path. And two PGP parsers (an rsa-pss key type that cannot express the module's signatures, and a truncated post-quantum decrypt envelope) crashed with an untyped Error instead of a typed refusal. **Fixed:** *b.mail.crypto.pgp turns an rsa-pss key and a truncated decrypt envelope into typed refusals* — Two adversarial inputs escaped as an untyped Error (which an operator catch keyed on b.mail.crypto.isMailCryptoError never catches) instead of the module's typed refusal. An rsa-pss key type was declared-accepted by sign() and verify() but cannot express the module's PKCS#1-v1.5 signatures and crashes in the shared JWK extraction before any crypto runs; it is now refused as a typed bad-key-type / key-alg-mismatch. And the post-quantum decrypt envelope parser advanced its offset using attacker-controlled length fields without bounds-checking, so a truncated envelope threw a RangeError; every read and slice in the envelope parser is now bounds-checked and a truncated envelope fails closed with a typed refusal. Valid signatures and envelopes are unaffected. · *b.vc rejects a non-XSD validity date* — The validFrom / validUntil parser used Date.parse, which is lenient -- a bare year, a slash-date, a date without a time, and locale strings all parsed to some instant and were given a heuristic validity window, weakening the expiry decision the verifier relies on (Verifiable Credentials Data Model 2.0 §4.9 requires an XSD dateTime). The parser now asserts an XSD dateTime shape before parsing, so a non-conformant validity value fails closed at both issue and verify. · *b.audit.checkpoint is idempotent on an already-anchored tip* — Anchoring a checkpoint read the audit-log tip, optionally skipped if unchanged, then inserted a checkpoint row whose atMonotonicCounter is unique -- but the read and the insert were not atomic, so two checkpoints of the same tip (concurrent anchors, or a second checkpoint() call without skipIfUnchanged) both computed the same counter and the loser threw a raw UNIQUE-constraint error instead of the documented no-op. Because two anchors of one tip sign the identical payload, the collision means the counter is already anchored: checkpoint() now returns null in that case (matching the skipIfUnchanged already-anchored path) and any unrecognized database error still surfaces. **Security:** *b.auth.openidFederation binds each entity configuration to its superior-attested keys* — buildTrustChain verified every entity configuration against its own self-published JWKS (integrity) and separately verified the superior-signed subordinate statement, but never re-verified an entity's own configuration -- the source of the effective metadata resolveLeaf returns -- against the keys the superior attests. An attacker who controls an entity's <entity_id>/.well-known/openid-federation endpoint but not its attested signing key could serve a self-signed configuration carrying forged metadata and attacker keys; the honest superior's subordinate statement pinned the entity's real keys, the chain was accepted, and the forged metadata was returned (OpenID Federation 1.0 §9 requires the leaf/intermediate configuration to be verified with the JWKS the superior pins). Each non-anchor entity's configuration is now verified against its superior-attested JWKS before those keys are adopted, so the operator-pinned trust anchor flows down to gate every entity's own configuration; the anchor is already verified against the operator-pinned keys. · *b.cose binds the null-digest signature algorithms (EdDSA / ML-DSA-87) to the key type* — verify() honored the declared COSE algorithm allowlist but bound only the ECDSA algorithms to their curve; the null-digest algorithms (EdDSA -8, ML-DSA-87 -50) had no key binding at all. node:crypto.verify(null, ...) dispatches on the key type and ignores the declared COSE alg, so an EdDSA signature was accepted and reported as ML-DSA-87 when the verifier's allowlist was PQC-only and it held an Ed25519 key (and the reverse), fully bypassing the algorithm allowlist; sign() had the mirror defect and could emit an EdDSA-labeled token signed with an ML-DSA key. Every signable algorithm is now bound to its key -- ECDSA by curve, EdDSA / ML-DSA-87 by key type -- on both sign and verify. The AEAD and HMAC paths bind the algorithm into the AAD / MAC structure, so no key-dispatch confusion exists there. · *b.vc requires the COSE media-type header on a verifiable credential and presentation* — The COSE verify path enforced the vc+cose / vp+cose type header (RFC 9596) only when it was present, while the JOSE path mandated it -- so an issuer-signed COSE credential or presentation that omitted the header was accepted with no binding to its media type (a cross-media-type-confusion fail-open on the verify seam, where two securing forms diverged on the same should-be-mandatory check). The COSE path now requires the type header unconditionally, symmetric with the JOSE path; b.vc.issue / b.vc.present always emit it, so no round-trip regresses.
12
+
13
+ - 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.
14
+
11
15
  - 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
16
 
13
17
  - 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. &Tab; / &colon;), 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&#9;script:, java&Tab;script:, and &#32;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:ex&#x70;ression(, background:url(&#x6A;avascript:...), and behavior&colon;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&Tab;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/audit.js CHANGED
@@ -218,6 +218,45 @@ async function _insertCheckpoint(values) {
218
218
  );
219
219
  }
220
220
 
221
+ // A concurrent anchor of the SAME tip loses the race to INSERT the
222
+ // atMonotonicCounter (UNIQUE): two anchors of one tip sign the identical
223
+ // payload, so the collision means the counter is ALREADY anchored, not a
224
+ // corruption. Recognize it BACKEND-AGNOSTICALLY so the loser returns null like
225
+ // the skipIfUnchanged "already anchored" path instead of throwing. Each driver
226
+ // surfaces the violation differently: SQLite names the column in the message
227
+ // (`... audit_checkpoints.atMonotonicCounter`); Postgres carries the unique
228
+ // INDEX name (idx_chkpt_counter) in `constraint`/`detail` with SQLSTATE 23505;
229
+ // MySQL reports the key name in `sqlMessage` with errno 1062. Gather every such
230
+ // field, and require BOTH a reference to the counter column OR its unique index
231
+ // AND a uniqueness signal, so no unrelated error is ever swallowed (anything
232
+ // unrecognized rethrows — fail-closed).
233
+ // Tokens that name the counter column (sqlite) or its unique index (pg/mysql).
234
+ var _DUP_COUNTER_REFS = ["atmonotoniccounter", "chkpt_counter"];
235
+ // A uniqueness-violation signal on any backend: sqlite message/code, postgres
236
+ // text/SQLSTATE 23505, mysql text/code-name/errno 1062.
237
+ var _DUP_UNIQUE_SIGNALS = [
238
+ "unique constraint failed", "sqlite_constraint", "duplicate key",
239
+ "23505", "duplicate entry", "er_dup_entry", "1062",
240
+ ];
241
+ function _errorTextContainsAny(text, needles) {
242
+ for (var i = 0; i < needles.length; i++) {
243
+ if (text.indexOf(needles[i]) !== -1) return true;
244
+ }
245
+ return false;
246
+ }
247
+ function _isDuplicateCheckpointCounter(e) {
248
+ if (!e) return false;
249
+ var parts = [e.message, e.detail, e.constraint, e.sqlMessage, e.table, e.code, e.errno];
250
+ var text = "";
251
+ for (var i = 0; i < parts.length; i++) {
252
+ if (parts[i] != null) text += " " + String(parts[i]);
253
+ }
254
+ if (typeof e.toString === "function") text += " " + e.toString();
255
+ text = text.toLowerCase();
256
+ return _errorTextContainsAny(text, _DUP_COUNTER_REFS) &&
257
+ _errorTextContainsAny(text, _DUP_UNIQUE_SIGNALS);
258
+ }
259
+
221
260
  async function _upsertAuditTip(counter, rowHash, signedAt, fencingToken) {
222
261
  // Cluster-mode only. Single atomic INSERT … ON CONFLICT … DO UPDATE
223
262
  // … WHERE … RETURNING. The WHERE clause is the canonical
@@ -1001,9 +1040,30 @@ async function checkpoint(opts) {
1001
1040
 
1002
1041
  var ckptId = generateToken(TRACE_ID_BYTES);
1003
1042
  var fencingToken = cluster.fencingToken();
1004
- await _insertCheckpoint(
1005
- [ckptId, createdAt, counter, tip.rowHash, signature, pubFp, fencingToken]
1006
- );
1043
+ try {
1044
+ await _insertCheckpoint(
1045
+ [ckptId, createdAt, counter, tip.rowHash, signature, pubFp, fencingToken]
1046
+ );
1047
+ } catch (e) {
1048
+ // Lost the INSERT race to another anchor of this same tip counter: the
1049
+ // counter is already anchored (both anchors sign the identical payload).
1050
+ // In SINGLE-NODE mode that is purely idempotent — return null like the
1051
+ // skipIfUnchanged "already anchored" path and let the winner own the
1052
+ // sidecar write below. In CLUSTER mode the duplicate can instead mean a
1053
+ // NEWER LEADER anchored this tip while we still hold an OLDER fencingToken,
1054
+ // so we must NOT silently swallow it: run the audit-tip fence first, whose
1055
+ // fencing-token WHERE guard throws FENCED_OUT when a higher token has been
1056
+ // stored — surfacing the leadership-loss step-down instead of a benign
1057
+ // null. If the fence passes (a same-node concurrent race), it is idempotent.
1058
+ // Any non-duplicate error rethrows.
1059
+ if (_isDuplicateCheckpointCounter(e)) {
1060
+ if (cluster.isClusterMode()) {
1061
+ await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
1062
+ }
1063
+ return null;
1064
+ }
1065
+ throw e;
1066
+ }
1007
1067
 
1008
1068
  // Update rollback-detection sidecar (single-node) or audit-tip row
1009
1069
  // (cluster mode).
@@ -1919,4 +1979,5 @@ module.exports = {
1919
1979
  CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
1920
1980
  FRAMEWORK_NAMESPACES: FRAMEWORK_NAMESPACES,
1921
1981
  _resetForTest: _resetForTest,
1982
+ _isDuplicateCheckpointCounter: _isDuplicateCheckpointCounter, // test seam: cross-backend dup-counter recognition
1922
1983
  };
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);
@@ -593,6 +593,21 @@ async function buildTrustChain(opts) {
593
593
  // The subordinate statement pins this entity's jwks — adopt the
594
594
  // attested keys for the next link down, and reflect them on the node.
595
595
  if (node.subordinate.jwks && Array.isArray(node.subordinate.jwks.keys)) {
596
+ // Bind the entity's OWN self-configuration to the superior-attested
597
+ // keys. resolveLeaf reads the effective metadata from the entity's
598
+ // self-config (chain[i].claims.metadata), and Phase 1 verified that
599
+ // config only against its OWN self-published jwks — which proves the
600
+ // document isn't garbled, NOT that a federation-attested key signed
601
+ // it. An attacker who controls the entity's .well-known endpoint (but
602
+ // not its attested signing key) can serve a self-signed config that
603
+ // carries forged metadata + attacker jwks and sails through the Phase 1
604
+ // self-verify; without this check the pinned keys never gate the very
605
+ // document whose metadata is trusted. Re-verify the config against the
606
+ // pinned keys so a config not signed by an attested key fails the whole
607
+ // chain (OIDF 1.0 §9 — the leaf/intermediate Entity Configuration is
608
+ // verified with the jwks pinned by its superior's Subordinate
609
+ // Statement, never with the config's self-declared jwks).
610
+ verifyEntityStatement(node.jwt, node.subordinate.jwks);
596
611
  node.claims.jwks = node.subordinate.jwks;
597
612
  attestedJwks = node.subordinate.jwks;
598
613
  } else {
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/cose.js CHANGED
@@ -112,21 +112,36 @@ function _algParamsFor(algId) {
112
112
  }
113
113
  }
114
114
 
115
- // RFC 9053 §2 binds each ECDSA alg to ONE curve. node:crypto will happily
116
- // verify a sha512 (ES512-shape) signature against a P-256 key as
117
- // self-consistent, so without this check a COSE_Sign1 declaring ES512 but
118
- // carrying a P-256 key passes when ES512 is allowlisted an alg/curve
119
- // confusion (the COSE sibling of jwt-external._assertAlgKtyMatch). EdDSA /
120
- // ML-DSA carry their parameters in the KeyObject and need no binding.
115
+ // RFC 9053 §2 binds each COSE signature alg to ONE key shape. node:crypto
116
+ // dispatches sign()/verify() on the KEY, never on the declared COSE alg —
117
+ // for ECDSA it will happily verify a sha512 (ES512-shape) signature against
118
+ // a P-256 key as self-consistent, and for the null-digest algorithms
119
+ // (EdDSA, ML-DSA-87) it selects the primitive purely from the key type.
120
+ // Without an alg→key binding a COSE_Sign1 declaring one algorithm but
121
+ // carrying/using another key type verifies — an alg/key confusion (the COSE
122
+ // sibling of jwt-external._assertAlgKtyMatch). Two live seams: a COSE_Sign1
123
+ // declaring ES512 but carrying a P-256 key passes when ES512 is allowlisted;
124
+ // and — because EdDSA/ML-DSA-87 both sign with a null digest — an EdDSA
125
+ // signature passes a PQC-only ("ML-DSA-87") allowlist against an Ed25519 key
126
+ // (and the reverse). EdDSA / ML-DSA carry no curve, so they are bound by
127
+ // key TYPE, not curve. Every SIGNABLE alg is bound here.
121
128
  var _ES_ALG_CURVE = { ES256: "prime256v1", ES384: "secp384r1", ES512: "secp521r1" };
122
- function _assertEcAlgCurve(algName, key) {
123
- var want = _ES_ALG_CURVE[algName];
124
- if (want === undefined) return; // not an ECDSA alg — nothing to bind
125
- var got = (key && key.asymmetricKeyDetails && key.asymmetricKeyDetails.namedCurve) || null;
126
- if (key.asymmetricKeyType !== "ec" || got !== want) {
127
- throw new CoseError("cose/alg-curve-mismatch",
128
- "cose: alg '" + algName + "' requires an EC key on " + want +
129
- ", got " + (key.asymmetricKeyType === "ec" ? got : key.asymmetricKeyType));
129
+ var _ALG_KEY_TYPE = { EdDSA: "ed25519", "ML-DSA-87": "ml-dsa-87" }; // null-digest algs → required key type
130
+ function _assertAlgKeyMatch(algName, key) {
131
+ var wantCurve = _ES_ALG_CURVE[algName];
132
+ if (wantCurve !== undefined) {
133
+ var got = (key && key.asymmetricKeyDetails && key.asymmetricKeyDetails.namedCurve) || null;
134
+ if (!key || key.asymmetricKeyType !== "ec" || got !== wantCurve) {
135
+ throw new CoseError("cose/alg-curve-mismatch",
136
+ "cose: alg '" + algName + "' requires an EC key on " + wantCurve +
137
+ ", got " + (key && key.asymmetricKeyType === "ec" ? got : (key && key.asymmetricKeyType)));
138
+ }
139
+ return;
140
+ }
141
+ var wantType = _ALG_KEY_TYPE[algName];
142
+ if (wantType !== undefined && (!key || key.asymmetricKeyType !== wantType)) {
143
+ throw new CoseError("cose/alg-key-mismatch",
144
+ "cose: alg '" + algName + "' requires a " + wantType + " key, got " + (key && key.asymmetricKeyType));
130
145
  }
131
146
  }
132
147
 
@@ -189,7 +204,7 @@ async function sign(payload, opts) {
189
204
  var algId = ALG_NAME_TO_ID[opts.alg];
190
205
  var params = _algParamsFor(algId);
191
206
  var key = _toKeyObject(opts.privateKey, "private");
192
- _assertEcAlgCurve(opts.alg, key); // RFC 9053 alg↔curve binding — refuse e.g. ES512 over a P-256 key
207
+ _assertAlgKeyMatch(opts.alg, key); // RFC 9053 alg↔key binding — refuse e.g. ES512 over a P-256 key, or EdDSA over an ML-DSA key
193
208
 
194
209
  var protMap = new Map();
195
210
  protMap.set(HDR_ALG, algId);
@@ -373,7 +388,7 @@ async function verify(coseSign1, opts) {
373
388
  var key = opts.publicKey
374
389
  ? _toKeyObject(opts.publicKey, "public")
375
390
  : _toKeyObject(opts.keyResolver(protMap, unprotected), "public");
376
- _assertEcAlgCurve(algName, key); // RFC 9053 alg↔curve binding
391
+ _assertAlgKeyMatch(algName, key); // RFC 9053 alg↔key binding — refuse alg/key-type confusion (EdDSA vs ML-DSA-87, ES* vs wrong curve)
377
392
 
378
393
  var externalAad = opts.externalAad == null ? Buffer.alloc(0) : _bstr(opts.externalAad);
379
394
  var toBeSigned = _toBeSigned(protectedBstr, externalAad, payload);
package/lib/guard-jwt.js CHANGED
@@ -312,9 +312,25 @@ function _detectIssues(input, opts) {
312
312
  }
313
313
  }
314
314
 
315
- // Payload claim sanity (only if payload is decodable).
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 && typeof payload === "object") {
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
@@ -456,7 +456,12 @@ function sign(opts) {
456
456
  hashAlg = HASH_ALG_SHA512;
457
457
  var rawPub = _extractEd25519PublicRaw(publicKey);
458
458
  publicPacketBody = _ed25519PublicPacketBody(rawPub, creationTime);
459
- } else if (keyType === "rsa" || keyType === "rsa-pss") {
459
+ } else if (keyType === "rsa") {
460
+ // Only rsaEncryption (keyType "rsa") keys sign here. An id-RSASSA-PSS
461
+ // key (keyType "rsa-pss") is padding-restricted to PSS by OpenSSL and
462
+ // cannot express the PKCS#1-v1.5 signature this module emits, so it is
463
+ // refused with a typed error below rather than crashing on the JWK
464
+ // export path (which refuses rsa-pss keys).
460
465
  pubAlg = PUB_ALG_RSA;
461
466
  hashAlg = HASH_ALG_SHA256;
462
467
  var rsaPub = _extractRsaPublicComponents(publicKey);
@@ -811,7 +816,12 @@ function verify(opts) {
811
816
  "publicKeyPem could not be parsed: " + ((e && e.message) || String(e))); }
812
817
 
813
818
  var keyType = publicKey.asymmetricKeyType;
814
- if (parsed.pubAlg === PUB_ALG_RSA && !(keyType === "rsa" || keyType === "rsa-pss")) {
819
+ // Only rsaEncryption (keyType "rsa") keys verify a PKCS#1-v1.5 signature.
820
+ // An id-RSASSA-PSS key (keyType "rsa-pss") is PSS-padding-restricted and
821
+ // cannot JWK-export for component extraction; treat it as a key/algorithm
822
+ // mismatch and return a typed verdict rather than letting the extraction
823
+ // throw a raw Error out of verify().
824
+ if (parsed.pubAlg === PUB_ALG_RSA && keyType !== "rsa") {
815
825
  return _fail("mail-crypto/pgp/key-alg-mismatch",
816
826
  "signature claims RSA but provided key is " + keyType);
817
827
  }
@@ -1028,6 +1038,18 @@ function experimentalEncrypt(opts) {
1028
1038
  return { armored: armored, envelope: envelope };
1029
1039
  }
1030
1040
 
1041
+ // Envelope length fields (recipientId / KEM-ciphertext / wrapped-key /
1042
+ // body lengths) are attacker-controlled: every multi-byte read and every
1043
+ // slice below must confirm the bytes exist before advancing `off`, or a
1044
+ // truncated envelope escapes as a raw RangeError instead of the typed
1045
+ // refusal decrypt() promises. Fail closed with mail-crypto/pgp/truncated.
1046
+ function _envNeed(buf, off, n) {
1047
+ if (off + n > buf.length) {
1048
+ throw new MailCryptoError("mail-crypto/pgp/truncated",
1049
+ "decrypt: envelope truncated (need " + n + " byte(s) at offset " + off + ")");
1050
+ }
1051
+ }
1052
+
1031
1053
  function experimentalDecrypt(opts) {
1032
1054
  opts = validateOpts.requireObject(opts, "mail.crypto.pgp.experimental.decrypt",
1033
1055
  MailCryptoError, "mail-crypto/pgp/bad-opts");
@@ -1064,15 +1086,17 @@ function experimentalDecrypt(opts) {
1064
1086
  var nRecips = envelope[off]; off += 1;
1065
1087
  var matchedSessionKey = null;
1066
1088
  for (var i = 0; i < nRecips; i += 1) {
1067
- if (off >= envelope.length) {
1068
- throw new MailCryptoError("mail-crypto/pgp/truncated",
1069
- "decrypt: envelope truncated at recipient " + i);
1070
- }
1089
+ _envNeed(envelope, off, 1);
1071
1090
  var ridLen = envelope[off]; off += 1;
1091
+ _envNeed(envelope, off, ridLen);
1072
1092
  var rid = envelope.slice(off, off + ridLen); off += ridLen;
1093
+ _envNeed(envelope, off, 2);
1073
1094
  var ctLen = envelope.readUInt16BE(off); off += 2; // u16-be width
1095
+ _envNeed(envelope, off, ctLen);
1074
1096
  var ct = envelope.slice(off, off + ctLen); off += ctLen;
1097
+ _envNeed(envelope, off, 2);
1075
1098
  var wkLen = envelope.readUInt16BE(off); off += 2; // u16-be width
1099
+ _envNeed(envelope, off, wkLen);
1076
1100
  var wrappedKey = envelope.slice(off, off + wkLen); off += wkLen;
1077
1101
  if (matchedSessionKey) continue;
1078
1102
  if (!rid.equals(opts.recipientId)) continue;
@@ -1096,7 +1120,9 @@ function experimentalDecrypt(opts) {
1096
1120
  throw new MailCryptoError("mail-crypto/pgp/no-matching-recipient",
1097
1121
  "decrypt: no recipient in envelope matches opts.recipientId");
1098
1122
  }
1123
+ _envNeed(envelope, off, 4);
1099
1124
  var bodyLen = envelope.readUInt32BE(off); off += 4; // u32-be width
1125
+ _envNeed(envelope, off, bodyLen);
1100
1126
  var body = envelope.slice(off, off + bodyLen);
1101
1127
  var plaintext;
1102
1128
  try { plaintext = bCrypto.decryptPacked(body, matchedSessionKey); }
package/lib/vc.js CHANGED
@@ -151,11 +151,41 @@ function _validateVcdm(cred, opts) {
151
151
  }
152
152
  }
153
153
 
154
+ // XSD dateTime (VCDM 2.0 section 4.9): [-]CCYY-MM-DDThh:mm:ss with an optional
155
+ // fractional second and an optional timezone (Z or +/-hh:mm). Date.parse alone
156
+ // is far too lenient in TWO ways: it accepts non-XSD shapes (a bare year, a
157
+ // slash date, a date with no time, locale forms), AND for an in-shape but
158
+ // IMPOSSIBLE calendar date it NORMALIZES rather than rejects (2024-02-31 ->
159
+ // 2024-03-02, a non-leap 2023-02-29 -> 2023-03-01, 2024-04-31 -> 2024-05-01),
160
+ // silently shifting the validity window. So the shape is asserted with a
161
+ // capturing regex AND the calendar fields are range-checked (month 1..12, day
162
+ // within the real length of that month, leap years honoured) BEFORE Date.parse.
163
+ var _XSD_DATETIME = /^(-?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
164
+
165
+ function _daysInMonth(year, month) {
166
+ if (month === 2) {
167
+ var leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
168
+ return leap ? 29 : 28;
169
+ }
170
+ return (month === 4 || month === 6 || month === 9 || month === 11) ? 30 : 31;
171
+ }
172
+
154
173
  function _parseValidityField(cred, name) {
155
174
  if (cred[name] === undefined) return null;
156
- if (typeof cred[name] !== "string") {
175
+ var m = typeof cred[name] === "string" ? _XSD_DATETIME.exec(cred[name]) : null;
176
+ if (!m) {
157
177
  throw new VcError("vc/bad-validity", "vc: " + name + " must be an XSD dateTime string");
158
178
  }
179
+ var year = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
180
+ // Reject an in-shape but impossible calendar DATE before Date.parse can
181
+ // normalize a day overflow into a different valid instant (2024-02-31 ->
182
+ // 2024-03-02, a non-leap 2023-02-29 -> 2023-03-01). Time-field overflow
183
+ // (hour/minute/second) is already rejected by Date.parse below (NaN), and
184
+ // 24:00:00 is the valid XSD end-of-day, so only the day-of-month needs a guard.
185
+ if (month < 1 || month > 12 || day < 1 || day > _daysInMonth(year, month)) {
186
+ throw new VcError("vc/bad-validity",
187
+ "vc: " + name + " is not a real calendar date: " + cred[name]);
188
+ }
159
189
  var ms = Date.parse(cred[name]);
160
190
  if (!isFinite(ms)) {
161
191
  throw new VcError("vc/bad-validity", "vc: " + name + " is not a valid dateTime: " + cred[name]);
@@ -287,9 +317,18 @@ async function _verifyCose(bytes, opts, expectedTyp) {
287
317
  publicKey: opts.publicKey,
288
318
  keyResolver: opts.keyResolver,
289
319
  });
320
+ // The typ header (RFC 9596 label 16) binds the COSE_Sign1 to the
321
+ // vc+cose / vp+cose media type. The JOSE path MANDATES its typ; the COSE
322
+ // path must too — an absent typ is not "unspecified, therefore ok" but an
323
+ // unbound statement that could be a signature made for another purpose
324
+ // (or the sibling securing form). Require it, symmetric with _verifyJose,
325
+ // rather than only checking it when present (which let an issuer-signed
326
+ // but typ-less COSE_Sign1 be accepted under a weaker contract).
290
327
  var typ = out.protectedHeaders.get(HDR_COSE_TYP);
291
- if (typ !== undefined && typ !== expectedTyp) {
292
- throw new VcError("vc/bad-typ", "vc.verify: COSE typ header is '" + typ + "', expected '" + expectedTyp + "'");
328
+ if (typ !== expectedTyp) {
329
+ throw new VcError("vc/bad-typ",
330
+ "vc.verify: COSE typ header is " + (typ === undefined ? "absent" : "'" + typ + "'") +
331
+ ", expected '" + expectedTyp + "'");
293
332
  }
294
333
  var payload;
295
334
  try { payload = safeJson.parse(out.payload.toString("utf8")); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.20",
3
+ "version": "0.16.22",
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:4eff6a21-428a-4198-9028-b4137d2c90c5",
5
+ "serialNumber": "urn:uuid:8f653935-1cc6-4bc7-a5a6-a9916e85dd27",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-12T13:00:38.123Z",
8
+ "timestamp": "2026-07-12T18:42:40.587Z",
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.20",
22
+ "bom-ref": "@blamejs/core@0.16.22",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.20",
25
+ "version": "0.16.22",
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.20",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.22",
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.20",
57
+ "ref": "@blamejs/core@0.16.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]