@blamejs/core 0.16.21 → 0.16.23
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 +4 -0
- package/lib/audit.js +64 -3
- package/lib/auth/openid-federation.js +214 -23
- package/lib/cose.js +31 -16
- package/lib/mail-crypto-pgp.js +32 -6
- package/lib/vc.js +42 -3
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.23 (2026-07-12) — **Merge an OpenID-Federation trust chain's per-level metadata policies into one combined policy and validate the operator combination, so a subordinate can only narrow a superior's constraint and cannot downgrade it through a different operator.** b.auth.openidFederation.applyMetadataPolicy resolved a chain's metadata_policy by applying each level's block in sequence, anchor-first then leaf-ward, so a leaf-ward level could override a value the anchor pinned -- and even after that was closed, a subordinate could still escape a superior's constraint by expressing the override with a DIFFERENT operator, because the resolution never checked that the combined operator set was consistent. An attacker controlling an intermediate could pin token_endpoint_auth_method with value 'none' against an anchor's one_of ['private_key_jwt'], widen a scope set past an anchor's subset_of ceiling with add, or replace an anchor's superset_of-mandated grant with a weaker one via value -- each resolved to the downgraded metadata with no error. OpenID Federation 1.0 §6.1.3 defines a fixed operator processing order and a set of operator-combination rules (value combines only with essential; add must stay within subset_of and cover superset_of; default must satisfy a co-present one_of / subset_of / superset_of; one_of does not combine with the array operators; superset_of must be within subset_of). applyMetadataPolicy now gathers every superior-signed subordinate statement's policy for the metadata kind, merges them superior-first (value / default equal-or-conflict, one_of / subset_of intersect, add / superset_of union, essential OR), validates the merged operator combination for every claim -- refusing the chain when a subordinate's operator is inconsistent with a superior's -- and applies the combined policy once in the fixed operator order so the value-check operators validate the final value. Consistent narrowings (a subordinate tightening a one_of or subset_of, adding within a subset_of, defaulting within a one_of) are unaffected. **Security:** *b.auth.openidFederation resolves metadata policy by merging every chain level and validating the operator combination, blocking cross-operator trust downgrades* — applyMetadataPolicy walked the chain applying each level's metadata_policy block in turn, so the leaf-ward block won any disagreement -- a subordinate could override a value the anchor pinned. Merging the levels closes the same-operator case, but a subordinate could still escape a superior's constraint expressed with a different operator: an anchor's one_of ['private_key_jwt'] on token_endpoint_auth_method was overridden by a leaf-ward value 'none' (client authentication removed at the token endpoint), an anchor's subset_of scope ceiling was widened by a subordinate add, and an anchor's superset_of-mandated grant was dropped by a subordinate value substituting the implicit flow -- each resolved to the downgraded metadata with no error, because the resolution validated only same-operator value/default conflicts and applied operators in object-key order rather than the OpenID Federation 1.0 §6.1.3 fixed order, so the value-check operators ran before the value they were meant to guard was set. Policy resolution now gathers each superior-signed subordinate statement's policy for the metadata kind (the leaf's own self-published policy is never read), merges them superior-first (value / default must agree across levels or the chain is refused, one_of / subset_of intersect, add / superset_of union, essential OR), and applies the single combined policy in the OpenID Federation 1.0 §6.1.3 fixed operator order (value, add, default, then the one_of / subset_of / superset_of / essential checks) so the merged, most-restrictive constraints validate the final value. A value outside a merged one_of, an add that widens past a merged subset_of, or a value that drops a superset_of-mandated member is therefore refused; a subordinate cannot widen an exact pinned value by adding a value not already in it; and a one_of whose values are disjoint across chain levels leaves no admissible value and is refused. A subordinate can only narrow a superior's constraint, never override it through any operator; a value pinned inside a superior's allowed set, and consistent narrowings generally, are unaffected. Resolution also refuses a prototype-pollution key (__proto__, constructor, prototype) in a policy block's claim names or in the base metadata, since those are never valid metadata parameter names and merging them into the combined policy would write Object.prototype. · *b.auth.openidFederation enforces the array metadata-policy operators on a claim the entity declares as a space-delimited string* — The subset_of metadata-policy operator restricts an array-valued claim (grant_types, response_types, redirect_uris, and similar) to an allow-list, but the check only ran when the claim was already an array. Because an entity fully controls its own self-published metadata, a leaf could declare a subset_of-constrained claim as a single string and the array guard skipped, so the anchor's allow-list was silently not enforced and the entity kept a value the trust anchor forbade. The array operators (subset_of, superset_of, add) now process a space-delimited string as an array of strings, per OpenID Federation 1.0 §6.1.3.1.8 -- so an OAuth scope like "openid email" is checked (and each token in a leaf's string is validated against the allow-list, catching a smuggled value), while a genuine non-array, non-string value is refused. An absent claim under subset_of is still left absent, since subset_of constrains a value that is present rather than requiring presence.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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
|
-
|
|
1005
|
-
|
|
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
|
};
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
var lazyRequire = require("../lazy-require");
|
|
60
60
|
var validateOpts = require("../validate-opts");
|
|
61
61
|
var safeJson = require("../safe-json");
|
|
62
|
+
var pick = require("../pick");
|
|
62
63
|
var nodeCrypto = require("node:crypto");
|
|
63
64
|
var bCrypto = require("../crypto");
|
|
64
65
|
var C = require("../constants");
|
|
@@ -240,11 +241,151 @@ function verifyEntityStatement(jwt, jwks, vopts) {
|
|
|
240
241
|
return parsed.claims;
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
// ---- Metadata-policy set-ops + cross-level merge (OIDF 1.0 section 6.1.5) ----
|
|
245
|
+
|
|
246
|
+
// Union of two arrays (dedupe, first-seen order). The single array set-op the
|
|
247
|
+
// `add` / `superset_of` operators and the cross-level merge both compose,
|
|
248
|
+
// rather than hand-rolling the shape per call site.
|
|
249
|
+
function _arrayUnion(a, b) {
|
|
250
|
+
var out = Array.isArray(a) ? a.slice() : [];
|
|
251
|
+
if (Array.isArray(b)) {
|
|
252
|
+
b.forEach(function (v) { if (out.indexOf(v) === -1) out.push(v); });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Intersection of two arrays (values in both, a's order). The narrowing op the
|
|
258
|
+
// merge uses for `one_of` / `subset_of`: a subordinate can only narrow.
|
|
259
|
+
function _arrayIntersect(a, b) {
|
|
260
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return [];
|
|
261
|
+
return a.filter(function (v) { return b.indexOf(v) !== -1; });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// a is a subset of b: every element of `a` is present in `b`. The set-op the
|
|
265
|
+
// OIDF 1.0 section 6.1.3 combination-consistency rules compose (add within
|
|
266
|
+
// subset_of, default within subset_of, superset_of within subset_of, ...).
|
|
267
|
+
function _arraySubset(a, b) {
|
|
268
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
269
|
+
return a.every(function (v) { return b.indexOf(v) !== -1; });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// View a metadata value as an array for the array policy operators. OAuth
|
|
273
|
+
// `scope` (and similar parameters) is a space-delimited string that OIDF 1.0
|
|
274
|
+
// section 6.1.3.1.8 processes as a string array, so a string is split on
|
|
275
|
+
// whitespace; an array is returned as-is; anything else yields null (the
|
|
276
|
+
// caller decides whether an absent / malformed value is a violation).
|
|
277
|
+
function _coerceOperandArray(val) {
|
|
278
|
+
if (Array.isArray(val)) return val;
|
|
279
|
+
if (typeof val === "string") return val.split(/\s+/).filter(Boolean);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// OIDF 1.0 section 6.1.3 fixed operator processing order: the value-MODIFICATION
|
|
284
|
+
// operators (value, add, default) run first to produce the value, then the
|
|
285
|
+
// value-CHECK operators (one_of, subset_of, superset_of, essential) validate it.
|
|
286
|
+
var _OP_ORDER = ["value", "add", "default", "one_of", "subset_of", "superset_of", "essential"];
|
|
287
|
+
|
|
288
|
+
// Validate a merged claim's operator set for the two cross-level cases the
|
|
289
|
+
// fixed-order apply step cannot catch on its own. The apply step already runs
|
|
290
|
+
// every merged CHECK operator (one_of / subset_of / superset_of / essential)
|
|
291
|
+
// against the FINAL value the MODIFIER operators (value / add / default)
|
|
292
|
+
// produced -- coercing a space-delimited scope string to an array -- so a
|
|
293
|
+
// value, add, or default inconsistent with a co-present constraint is refused
|
|
294
|
+
// there against the merged (most-restrictive) constraint. This adds only what
|
|
295
|
+
// apply cannot see:
|
|
296
|
+
// (1) `value` + `add` where `add` would WIDEN the exact pin -- the union
|
|
297
|
+
// escapes `value`, and with no constraint operator present the apply step
|
|
298
|
+
// has nothing to reject it. `add` already within `value` is a no-op,
|
|
299
|
+
// allowed (OIDF 1.0 section 6.1.3.1.1).
|
|
300
|
+
// (2) a `one_of` whose cross-level intersection is EMPTY -- no value can ever
|
|
301
|
+
// satisfy the chain, a policy error per section 6.1.3.1.4 rather than a
|
|
302
|
+
// silent accept-when-absent.
|
|
303
|
+
// Same-operator value/default equality is checked during the merge itself.
|
|
304
|
+
function _assertPolicyCombination(claim, acc) {
|
|
305
|
+
function has(op) { return Object.prototype.hasOwnProperty.call(acc, op); }
|
|
306
|
+
function conflict(why) {
|
|
307
|
+
throw new AuthError("auth-openid-federation/policy-merge-conflict",
|
|
308
|
+
"metadata_policy['" + claim + "']: " + why +
|
|
309
|
+
" (a subordinate cannot widen a superior's constraint)");
|
|
310
|
+
}
|
|
311
|
+
if (has("value") && has("add")) {
|
|
312
|
+
var pinned = _coerceOperandArray(acc.value) || [acc.value];
|
|
313
|
+
if (!_arraySubset(acc.add, pinned)) {
|
|
314
|
+
conflict("`add` would widen the pinned `value`");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (has("one_of") && acc.one_of.length === 0) {
|
|
318
|
+
conflict("`one_of` values are disjoint across the chain (no allowed value remains)");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Combine the metadata_policy blocks from EVERY chain level into ONE policy per
|
|
323
|
+
// OIDF 1.0 section 6.1.5.3, so a subordinate can only NARROW a superior's
|
|
324
|
+
// constraint, never override it, and the combined policy is applied ONCE. Two
|
|
325
|
+
// levels pinning a DIFFERENT `value` (or `default`) is a cross-level conflict
|
|
326
|
+
// that makes the chain invalid and throws -- the pre-merge sequential apply let
|
|
327
|
+
// a leaf-ward `value` silently OVERWRITE the anchor's pinned value, a trust
|
|
328
|
+
// downgrade (e.g. token_endpoint_auth_method private_key_jwt -> none) the anchor
|
|
329
|
+
// forbade. `blocks` are ordered superior-first (anchor policy first).
|
|
330
|
+
function _mergeMetadataPolicies(blocks) {
|
|
331
|
+
var merged = Object.create(null);
|
|
332
|
+
blocks.forEach(function (block) {
|
|
333
|
+
Object.keys(block).forEach(function (claim) {
|
|
334
|
+
if (pick.isPoisonedKey(claim)) {
|
|
335
|
+
throw new AuthError("auth-openid-federation/poisoned-policy-key",
|
|
336
|
+
"metadata_policy claim name \"" + claim + "\" is a prototype-pollution vector");
|
|
337
|
+
}
|
|
338
|
+
var rules = block[claim];
|
|
339
|
+
if (!rules || typeof rules !== "object") {
|
|
340
|
+
throw new AuthError("auth-openid-federation/bad-policy-rules",
|
|
341
|
+
"metadata_policy['" + claim + "'] must be an object");
|
|
342
|
+
}
|
|
343
|
+
var acc = merged[claim] || (merged[claim] = {});
|
|
344
|
+
Object.keys(rules).forEach(function (op) {
|
|
345
|
+
var v = rules[op];
|
|
346
|
+
if (op === "value" || op === "default") {
|
|
347
|
+
if (Object.prototype.hasOwnProperty.call(acc, op) &&
|
|
348
|
+
JSON.stringify(acc[op]) !== JSON.stringify(v)) {
|
|
349
|
+
throw new AuthError("auth-openid-federation/policy-merge-conflict",
|
|
350
|
+
"metadata_policy['" + claim + "']." + op + " conflicts across the chain (" +
|
|
351
|
+
JSON.stringify(acc[op]) + " vs " + JSON.stringify(v) +
|
|
352
|
+
"): a subordinate cannot override a superior's pinned " + op);
|
|
353
|
+
}
|
|
354
|
+
acc[op] = v;
|
|
355
|
+
} else if (op === "add" || op === "superset_of") {
|
|
356
|
+
if (!Array.isArray(v)) {
|
|
357
|
+
throw new AuthError("auth-openid-federation/bad-policy-" + (op === "add" ? "add" : "superset-of"),
|
|
358
|
+
"metadata_policy['" + claim + "']." + op + " requires an array");
|
|
359
|
+
}
|
|
360
|
+
acc[op] = _arrayUnion(acc[op], v);
|
|
361
|
+
} else if (op === "one_of" || op === "subset_of") {
|
|
362
|
+
if (!Array.isArray(v)) {
|
|
363
|
+
throw new AuthError("auth-openid-federation/bad-policy-" + (op === "one_of" ? "one-of" : "subset-of"),
|
|
364
|
+
"metadata_policy['" + claim + "']." + op + " requires an array");
|
|
365
|
+
}
|
|
366
|
+
acc[op] = Object.prototype.hasOwnProperty.call(acc, op) ? _arrayIntersect(acc[op], v) : v.slice();
|
|
367
|
+
} else if (op === "essential") {
|
|
368
|
+
acc[op] = acc[op] === true || v === true;
|
|
369
|
+
} else {
|
|
370
|
+
throw new AuthError("auth-openid-federation/unknown-policy-op",
|
|
371
|
+
"metadata_policy['" + claim + "'] unknown operator \"" + op + "\"");
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
Object.keys(merged).forEach(function (claim) { _assertPolicyCombination(claim, merged[claim]); });
|
|
377
|
+
return merged;
|
|
378
|
+
}
|
|
379
|
+
|
|
243
380
|
/**
|
|
244
|
-
* Apply
|
|
245
|
-
* return the resulting object. Pure — never mutates input.
|
|
381
|
+
* Apply an already-merged metadata_policy block to a metadata object and
|
|
382
|
+
* return the resulting object. Pure — never mutates input. Operators are
|
|
383
|
+
* applied in the OIDF 1.0 §6.1.3 fixed order (value, add, default, one_of,
|
|
384
|
+
* subset_of, superset_of, essential) so the value-CHECK operators validate the
|
|
385
|
+
* value the value-MODIFICATION operators produced; the combined policy's
|
|
386
|
+
* operator combination was validated during the merge (_assertPolicyCombination).
|
|
246
387
|
*
|
|
247
|
-
* Operators per OIDF §6.
|
|
388
|
+
* Operators per OIDF 1.0 §6.1.3:
|
|
248
389
|
* value — set claim to a fixed value (overrides subordinate)
|
|
249
390
|
* add — array claim: append values not already present
|
|
250
391
|
* default — provide a default if the claim is absent
|
|
@@ -254,14 +395,34 @@ function verifyEntityStatement(jwt, jwks, vopts) {
|
|
|
254
395
|
* essential — claim must be present (else throw)
|
|
255
396
|
*/
|
|
256
397
|
function _applyOnePolicy(metadata, policy) {
|
|
257
|
-
|
|
398
|
+
// Build the working copy from own, non-prototype-polluting keys only -- a
|
|
399
|
+
// metadata object carrying a "__proto__" / "constructor" / "prototype" own
|
|
400
|
+
// key (e.g. from JSON.parse) would otherwise set out's prototype via
|
|
401
|
+
// assignment; the merged policy's claim names are already poison-guarded.
|
|
402
|
+
var out = {};
|
|
403
|
+
Object.keys(metadata).forEach(function (mk) {
|
|
404
|
+
if (!pick.isPoisonedKey(mk)) out[mk] = metadata[mk];
|
|
405
|
+
});
|
|
258
406
|
Object.keys(policy).forEach(function (claimName) {
|
|
407
|
+
if (pick.isPoisonedKey(claimName)) return;
|
|
259
408
|
var rules = policy[claimName];
|
|
260
409
|
if (!rules || typeof rules !== "object") {
|
|
261
410
|
throw new AuthError("auth-openid-federation/bad-policy-rules",
|
|
262
411
|
"metadata_policy['" + claimName + "'] must be an object");
|
|
263
412
|
}
|
|
413
|
+
// Apply operators in the OIDF 1.0 section 6.1.3 fixed order so the
|
|
414
|
+
// value-CHECK operators (one_of/subset_of/superset_of/essential) validate
|
|
415
|
+
// the value AFTER the value-MODIFICATION operators (value/add/default) have
|
|
416
|
+
// produced it -- Object.keys order let a check run before the value it must
|
|
417
|
+
// guard was set. Any operator outside the fixed set is refused first.
|
|
264
418
|
Object.keys(rules).forEach(function (op) {
|
|
419
|
+
if (_OP_ORDER.indexOf(op) === -1) {
|
|
420
|
+
throw new AuthError("auth-openid-federation/unknown-policy-op",
|
|
421
|
+
"metadata_policy['" + claimName + "'] unknown operator \"" + op + "\"");
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
_OP_ORDER.forEach(function (op) {
|
|
425
|
+
if (!Object.prototype.hasOwnProperty.call(rules, op)) return;
|
|
265
426
|
var v = rules[op];
|
|
266
427
|
switch (op) {
|
|
267
428
|
case "value":
|
|
@@ -275,8 +436,8 @@ function _applyOnePolicy(metadata, policy) {
|
|
|
275
436
|
throw new AuthError("auth-openid-federation/bad-policy-add",
|
|
276
437
|
"metadata_policy['" + claimName + "'].add requires an array");
|
|
277
438
|
}
|
|
278
|
-
|
|
279
|
-
|
|
439
|
+
var _addResult = _arrayUnion(_coerceOperandArray(out[claimName]) || [], v);
|
|
440
|
+
out[claimName] = typeof out[claimName] === "string" ? _addResult.join(" ") : _addResult;
|
|
280
441
|
break;
|
|
281
442
|
case "one_of":
|
|
282
443
|
if (!Array.isArray(v)) {
|
|
@@ -294,8 +455,14 @@ function _applyOnePolicy(metadata, policy) {
|
|
|
294
455
|
throw new AuthError("auth-openid-federation/bad-policy-subset-of",
|
|
295
456
|
"metadata_policy['" + claimName + "'].subset_of requires an array");
|
|
296
457
|
}
|
|
297
|
-
if (
|
|
298
|
-
out[claimName]
|
|
458
|
+
if (out[claimName] !== undefined && out[claimName] !== null) {
|
|
459
|
+
var _subArr = _coerceOperandArray(out[claimName]);
|
|
460
|
+
if (_subArr === null) {
|
|
461
|
+
throw new AuthError("auth-openid-federation/policy-subset-of-failed",
|
|
462
|
+
"metadata_policy['" + claimName + "']: value " + JSON.stringify(out[claimName]) +
|
|
463
|
+
" is neither an array nor a space-delimited string; subset_of constrains an array-valued claim");
|
|
464
|
+
}
|
|
465
|
+
_subArr.forEach(function (val) {
|
|
299
466
|
if (v.indexOf(val) === -1) {
|
|
300
467
|
throw new AuthError("auth-openid-federation/policy-subset-of-failed",
|
|
301
468
|
"metadata_policy['" + claimName + "']: value \"" + JSON.stringify(val) +
|
|
@@ -309,8 +476,9 @@ function _applyOnePolicy(metadata, policy) {
|
|
|
309
476
|
throw new AuthError("auth-openid-federation/bad-policy-superset-of",
|
|
310
477
|
"metadata_policy['" + claimName + "'].superset_of requires an array");
|
|
311
478
|
}
|
|
479
|
+
var _supArr = _coerceOperandArray(out[claimName]) || [];
|
|
312
480
|
v.forEach(function (req) {
|
|
313
|
-
if (
|
|
481
|
+
if (_supArr.indexOf(req) === -1) {
|
|
314
482
|
throw new AuthError("auth-openid-federation/policy-superset-of-failed",
|
|
315
483
|
"metadata_policy['" + claimName + "']: missing required value \"" + req + "\"");
|
|
316
484
|
}
|
|
@@ -350,9 +518,11 @@ function _applyOnePolicy(metadata, policy) {
|
|
|
350
518
|
* own self-config metadata_policy is therefore ignored.
|
|
351
519
|
*
|
|
352
520
|
* The chain is leaf-first; each `chain[i].subordinate` is the statement
|
|
353
|
-
* signed by the superior directly above entity `i
|
|
354
|
-
*
|
|
355
|
-
*
|
|
521
|
+
* signed by the superior directly above entity `i`; every level's policy
|
|
522
|
+
* is merged into ONE combined policy so a subordinate can only NARROW a
|
|
523
|
+
* superior's constraint (a leaf-ward level pinning a different
|
|
524
|
+
* value/default refuses the chain as a trust downgrade), then applied
|
|
525
|
+
* once (OIDF 1.0 §6.1.5.3).
|
|
356
526
|
*
|
|
357
527
|
* @example
|
|
358
528
|
* var effective = b.auth.openidFederation.applyMetadataPolicy(
|
|
@@ -371,21 +541,27 @@ function applyMetadataPolicy(metadata, chain, kind) {
|
|
|
371
541
|
throw new AuthError("auth-openid-federation/bad-chain",
|
|
372
542
|
"applyMetadataPolicy: chain must be an array");
|
|
373
543
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
544
|
+
Object.keys(metadata).forEach(function (mk) {
|
|
545
|
+
if (pick.isPoisonedKey(mk)) {
|
|
546
|
+
throw new AuthError("auth-openid-federation/poisoned-metadata-key",
|
|
547
|
+
"applyMetadataPolicy: metadata key \"" + mk + "\" is a prototype-pollution vector");
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
// Gather each SUPERIOR-SIGNED subordinate statement's policy for this kind,
|
|
551
|
+
// superior-first (anchor policy first; the anchor node terminates the chain
|
|
552
|
+
// with no .subordinate, and the leaf's own self-config policy is never read),
|
|
553
|
+
// then MERGE every level into ONE combined policy so a subordinate can only
|
|
554
|
+
// NARROW a superior's constraint (OIDF 1.0 section 6.1.5.3) -- a leaf-ward
|
|
555
|
+
// level pinning a different value/default is a conflict that refuses the
|
|
556
|
+
// chain -- and apply the merged policy ONCE.
|
|
557
|
+
var blocks = [];
|
|
381
558
|
for (var i = chain.length - 1; i >= 0; i--) {
|
|
382
559
|
var stmt = chain[i];
|
|
383
560
|
if (!stmt || !stmt.subordinate) continue;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
561
|
+
var mp = stmt.subordinate.metadata_policy;
|
|
562
|
+
if (mp && mp[kind]) blocks.push(mp[kind]);
|
|
387
563
|
}
|
|
388
|
-
return
|
|
564
|
+
return _applyOnePolicy(Object.assign({}, metadata), _mergeMetadataPolicies(blocks));
|
|
389
565
|
}
|
|
390
566
|
|
|
391
567
|
async function _defaultFetcher(url) {
|
|
@@ -593,6 +769,21 @@ async function buildTrustChain(opts) {
|
|
|
593
769
|
// The subordinate statement pins this entity's jwks — adopt the
|
|
594
770
|
// attested keys for the next link down, and reflect them on the node.
|
|
595
771
|
if (node.subordinate.jwks && Array.isArray(node.subordinate.jwks.keys)) {
|
|
772
|
+
// Bind the entity's OWN self-configuration to the superior-attested
|
|
773
|
+
// keys. resolveLeaf reads the effective metadata from the entity's
|
|
774
|
+
// self-config (chain[i].claims.metadata), and Phase 1 verified that
|
|
775
|
+
// config only against its OWN self-published jwks — which proves the
|
|
776
|
+
// document isn't garbled, NOT that a federation-attested key signed
|
|
777
|
+
// it. An attacker who controls the entity's .well-known endpoint (but
|
|
778
|
+
// not its attested signing key) can serve a self-signed config that
|
|
779
|
+
// carries forged metadata + attacker jwks and sails through the Phase 1
|
|
780
|
+
// self-verify; without this check the pinned keys never gate the very
|
|
781
|
+
// document whose metadata is trusted. Re-verify the config against the
|
|
782
|
+
// pinned keys so a config not signed by an attested key fails the whole
|
|
783
|
+
// chain (OIDF 1.0 §9 — the leaf/intermediate Entity Configuration is
|
|
784
|
+
// verified with the jwks pinned by its superior's Subordinate
|
|
785
|
+
// Statement, never with the config's self-declared jwks).
|
|
786
|
+
verifyEntityStatement(node.jwt, node.subordinate.jwks);
|
|
596
787
|
node.claims.jwks = node.subordinate.jwks;
|
|
597
788
|
attestedJwks = node.subordinate.jwks;
|
|
598
789
|
} 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
|
|
116
|
-
// verify
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"cose
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/mail-crypto-pgp.js
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !==
|
|
292
|
-
throw new VcError("vc/bad-typ",
|
|
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
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:45b7028c-dc33-4f08-92b5-8735beeb8354",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-12T22:51:20.079Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.16.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.16.23",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.16.
|
|
25
|
+
"version": "0.16.23",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.16.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.16.23",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.16.
|
|
57
|
+
"ref": "@blamejs/core@0.16.23",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|