@blamejs/core 0.16.21 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
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
  };
@@ -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/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);
@@ -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.21",
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:96b6222d-f898-4aa7-beb8-1b0c830965bd",
5
+ "serialNumber": "urn:uuid:8f653935-1cc6-4bc7-a5a6-a9916e85dd27",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-12T14:22:26.336Z",
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.21",
22
+ "bom-ref": "@blamejs/core@0.16.22",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.21",
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.21",
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.21",
57
+ "ref": "@blamejs/core@0.16.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]