@blamejs/core 0.9.49 → 0.10.2
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 +952 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +78 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/cli.js +13 -0
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-graphql.js +37 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-regex.js +138 -1
- package/lib/guard-smtp-command.js +58 -3
- package/lib/guard-xml.js +39 -1
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/otel-export.js +13 -4
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -58,8 +58,20 @@ var lazyRequire = require("../lazy-require");
|
|
|
58
58
|
var validateOpts = require("../validate-opts");
|
|
59
59
|
var safeJson = require("../safe-json");
|
|
60
60
|
var nodeCrypto = require("node:crypto");
|
|
61
|
+
var C = require("../constants");
|
|
62
|
+
// Shared JOSE defenses (CVE-2026-22817 alg/kty cross-check +
|
|
63
|
+
// CVE-2026-23552 constant-time iss compare). Top-of-file per project
|
|
64
|
+
// convention §3; no circular load — jwt-external requires nothing from
|
|
65
|
+
// openid-federation.
|
|
66
|
+
var jwtExternal = require("./jwt-external");
|
|
61
67
|
var { AuthError } = require("../framework-error");
|
|
62
68
|
|
|
69
|
+
// Default federation-statement clock-skew tolerance in seconds. OIDC
|
|
70
|
+
// Federation 1.0 doesn't fix a value; 60s matches the framework-wide
|
|
71
|
+
// default applied to ID tokens. Operators tune via
|
|
72
|
+
// verifyEntityStatement(jwt, jwks, { maxClockSkewSec }).
|
|
73
|
+
var DEFAULT_FEDERATION_CLOCK_SKEW_SEC = C.TIME.minutes(1) / C.TIME.seconds(1);
|
|
74
|
+
|
|
63
75
|
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
64
76
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
65
77
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
@@ -124,7 +136,7 @@ function parseEntityStatement(jwt) {
|
|
|
124
136
|
|
|
125
137
|
/**
|
|
126
138
|
* @primitive b.auth.openidFederation.verifyEntityStatement
|
|
127
|
-
* @signature b.auth.openidFederation.verifyEntityStatement(jwt, jwks)
|
|
139
|
+
* @signature b.auth.openidFederation.verifyEntityStatement(jwt, jwks, vopts)
|
|
128
140
|
* @since 0.8.62
|
|
129
141
|
*
|
|
130
142
|
* Verify a single entity statement's JWS signature using the
|
|
@@ -132,11 +144,16 @@ function parseEntityStatement(jwt) {
|
|
|
132
144
|
* any failure (malformed / wrong typ / unsupported alg / no
|
|
133
145
|
* matching kid / bad signature / iat-future / expired).
|
|
134
146
|
*
|
|
147
|
+
* @opts
|
|
148
|
+
* maxClockSkewSec: number // tolerance for iat / exp; default: 60
|
|
149
|
+
* now: number // override Date.now() for tests
|
|
150
|
+
*
|
|
135
151
|
* @example
|
|
136
152
|
* var claims = b.auth.openidFederation.verifyEntityStatement(jwt, anchorJwks);
|
|
137
153
|
* // → { iss, sub, iat, exp, jwks, metadata, authority_hints, ... }
|
|
138
154
|
*/
|
|
139
|
-
function verifyEntityStatement(jwt, jwks) {
|
|
155
|
+
function verifyEntityStatement(jwt, jwks, vopts) {
|
|
156
|
+
vopts = vopts || {};
|
|
140
157
|
var parsed = parseEntityStatement(jwt);
|
|
141
158
|
if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
|
|
142
159
|
throw new AuthError("auth-openid-federation/no-keys",
|
|
@@ -147,30 +164,36 @@ function verifyEntityStatement(jwt, jwks) {
|
|
|
147
164
|
for (var i = 0; i < jwks.keys.length; i++) {
|
|
148
165
|
if (jwks.keys[i].kid === parsed.header.kid) { key = jwks.keys[i]; break; }
|
|
149
166
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
167
|
+
if (!key) {
|
|
168
|
+
throw new AuthError("auth-openid-federation/no-matching-kid",
|
|
169
|
+
"verifyEntityStatement: no JWKS key matches kid \"" + parsed.header.kid + "\"");
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
// AUTH-10 — refuse kid-less entity statements unless the operator
|
|
173
|
+
// explicitly opts in. JWKS rotation creates a window where the
|
|
174
|
+
// rotated-out key is still cached but the rotated-in key is already
|
|
175
|
+
// published; a kid-less statement during that window gets the
|
|
176
|
+
// lone-key path silently. Modern federations always emit kid; the
|
|
177
|
+
// gap that oauth + jwt-external closed in v0.9.4 applies here too.
|
|
178
|
+
if (jwks.keys.length === 1 && vopts.allowKidlessJwks === true) {
|
|
179
|
+
key = jwks.keys[0];
|
|
180
|
+
} else {
|
|
181
|
+
throw new AuthError("auth-openid-federation/kid-required",
|
|
182
|
+
"verifyEntityStatement: header.kid absent — framework refuses kid-less " +
|
|
183
|
+
"entity statements to defend against JWKS-rotation key-pick attacks " +
|
|
184
|
+
"(pass vopts.allowKidlessJwks: true to opt out)");
|
|
185
|
+
}
|
|
156
186
|
}
|
|
157
187
|
|
|
158
|
-
//
|
|
159
|
-
// verifying. Without this an attacker-controlled entity-config
|
|
160
|
-
// declare `alg: "ES256"` while supplying an RSA `kty: "RSA"` JWK;
|
|
188
|
+
// CVE-2026-22817 — cross-check the JWK key type against the JWS alg
|
|
189
|
+
// BEFORE verifying. Without this an attacker-controlled entity-config
|
|
190
|
+
// can declare `alg: "ES256"` while supplying an RSA `kty: "RSA"` JWK;
|
|
161
191
|
// Node will silently use the RSA key with SHA-256 and the signature
|
|
162
|
-
// verify either always-fails (
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
else if (parsed.header.alg.indexOf("PS") === 0 || parsed.header.alg.indexOf("RS") === 0) expectedKty = "RSA";
|
|
168
|
-
else if (parsed.header.alg === "EdDSA") expectedKty = "OKP";
|
|
169
|
-
if (expectedKty && key.kty !== expectedKty) {
|
|
170
|
-
throw new AuthError("auth-openid-federation/alg-kty-mismatch",
|
|
171
|
-
"verifyEntityStatement: JWS header alg=\"" + parsed.header.alg + "\" requires " +
|
|
172
|
-
"JWK kty=\"" + expectedKty + "\" but the resolved JWK has kty=\"" + key.kty + "\"");
|
|
173
|
-
}
|
|
192
|
+
// verify either always-fails (PSS) or succeeds against a payload the
|
|
193
|
+
// attacker crafted (alg/key-type confusion). Routed through the
|
|
194
|
+
// shared helper so every JWT verifier in the framework enforces the
|
|
195
|
+
// same check.
|
|
196
|
+
jwtExternal._assertAlgKtyMatch(parsed.header.alg, key);
|
|
174
197
|
|
|
175
198
|
var keyObj;
|
|
176
199
|
try { keyObj = nodeCrypto.createPublicKey({ key: key, format: "jwk" }); }
|
|
@@ -194,8 +217,12 @@ function verifyEntityStatement(jwt, jwks) {
|
|
|
194
217
|
"verifyEntityStatement: signature verification failed");
|
|
195
218
|
}
|
|
196
219
|
|
|
197
|
-
var nowSec = Math.floor(Date.now() /
|
|
198
|
-
|
|
220
|
+
var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
|
|
221
|
+
// AUTH-30 — operator-tunable clock skew (sibling primitives accept
|
|
222
|
+
// tunable). Default matches the prior fixed 60s.
|
|
223
|
+
var skew = (typeof vopts.maxClockSkewSec === "number" && isFinite(vopts.maxClockSkewSec) && vopts.maxClockSkewSec >= 0)
|
|
224
|
+
? vopts.maxClockSkewSec
|
|
225
|
+
: DEFAULT_FEDERATION_CLOCK_SKEW_SEC;
|
|
199
226
|
if (typeof parsed.claims.iat !== "number" || parsed.claims.iat > nowSec + skew) {
|
|
200
227
|
throw new AuthError("auth-openid-federation/iat-future",
|
|
201
228
|
"verifyEntityStatement: iat is in the future or missing");
|
|
@@ -409,6 +436,16 @@ async function buildTrustChain(opts) {
|
|
|
409
436
|
var chain = [];
|
|
410
437
|
var current = opts.leafEntityId;
|
|
411
438
|
var depth = 0;
|
|
439
|
+
// AUTH-9 — visited-set cycle guard. The maxDepth cap alone caps the
|
|
440
|
+
// loop count but doesn't distinguish "long chain" from "cyclic
|
|
441
|
+
// chain"; a hostile authority that lists itself in authority_hints
|
|
442
|
+
// walks the verifier until depth runs out and then surfaces as
|
|
443
|
+
// chain-too-deep, masking the actual misuse pattern. Tracking
|
|
444
|
+
// visited entities + refusing on revisit surfaces the cycle as a
|
|
445
|
+
// distinct fault class so operators alerting on chain-cycle see
|
|
446
|
+
// hostile-federation probes immediately.
|
|
447
|
+
var visited = Object.create(null);
|
|
448
|
+
visited[current] = true;
|
|
412
449
|
while (depth < maxDepth) {
|
|
413
450
|
var entityConfigUrl = current.replace(/\/$/, "") + "/.well-known/openid-federation";
|
|
414
451
|
var entityConfigJwt = await fetcher(entityConfigUrl);
|
|
@@ -476,6 +513,15 @@ async function buildTrustChain(opts) {
|
|
|
476
513
|
chain[chain.length - 1].claims.jwks = parsedSub.claims.jwks || chain[chain.length - 1].claims.jwks;
|
|
477
514
|
chain[chain.length - 1].subordinateJwt = subordinateJwt;
|
|
478
515
|
chain[chain.length - 1].subordinate = parsedSub.claims;
|
|
516
|
+
// AUTH-9 — refuse revisit. A trust anchor terminates the loop
|
|
517
|
+
// before re-entry, so a revisit here ALWAYS means a cyclic
|
|
518
|
+
// authority_hints graph.
|
|
519
|
+
if (visited[authority]) {
|
|
520
|
+
throw new AuthError("auth-openid-federation/chain-cycle",
|
|
521
|
+
"buildTrustChain: authority \"" + authority + "\" already visited — " +
|
|
522
|
+
"cyclic authority_hints graph refused");
|
|
523
|
+
}
|
|
524
|
+
visited[authority] = true;
|
|
479
525
|
current = authority;
|
|
480
526
|
ascended = true;
|
|
481
527
|
break;
|
package/lib/auth/passkey.js
CHANGED
|
@@ -77,6 +77,50 @@ function _requireString(v, name) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// AUTH-28 — WebAuthn extensions allowlist. Pre-v0.9.x `opts.extensions`
|
|
81
|
+
// was forwarded verbatim to the vendor, letting an operator (or a
|
|
82
|
+
// caller threading user-input through opts) ship arbitrary extension
|
|
83
|
+
// keys to the authenticator. Restrict to the framework-supported
|
|
84
|
+
// extension surface (`prf` / `largeBlob` / `credBlob`) and route every
|
|
85
|
+
// value through the matching `extensions.<name>(args)` builder so the
|
|
86
|
+
// shape is validated. Operators with custom extensions opt in via
|
|
87
|
+
// { allowUnknownExtensions: true } with a documented reason.
|
|
88
|
+
var ALLOWED_EXTENSION_KEYS = Object.freeze({
|
|
89
|
+
prf: 1,
|
|
90
|
+
largeBlob: 1,
|
|
91
|
+
credBlob: 1,
|
|
92
|
+
});
|
|
93
|
+
function _validateExtensions(extensions, allowUnknown) {
|
|
94
|
+
if (extensions === undefined || extensions === null) return undefined;
|
|
95
|
+
if (typeof extensions !== "object" || Array.isArray(extensions)) {
|
|
96
|
+
throw new AuthError("auth-passkey/bad-extensions",
|
|
97
|
+
"opts.extensions must be a plain object");
|
|
98
|
+
}
|
|
99
|
+
var out = {};
|
|
100
|
+
var keys = Object.keys(extensions);
|
|
101
|
+
for (var i = 0; i < keys.length; i++) {
|
|
102
|
+
var k = keys[i];
|
|
103
|
+
if (!Object.prototype.hasOwnProperty.call(ALLOWED_EXTENSION_KEYS, k)) {
|
|
104
|
+
if (allowUnknown === true) {
|
|
105
|
+
out[k] = extensions[k];
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
throw new AuthError("auth-passkey/unknown-extension",
|
|
109
|
+
"opts.extensions['" + k + "'] not in the framework-supported set " +
|
|
110
|
+
"(allowed: " + Object.keys(ALLOWED_EXTENSION_KEYS).join(", ") +
|
|
111
|
+
"). Pass `allowUnknownExtensions: true` to opt out.");
|
|
112
|
+
}
|
|
113
|
+
// Route every recognised extension through its builder so the
|
|
114
|
+
// shape is validated (PRF eval salt length, largeBlob support
|
|
115
|
+
// values, credBlob ≤ 32 bytes). Builder output replaces the raw
|
|
116
|
+
// input so the wire shape is always the spec-correct one.
|
|
117
|
+
if (k === "prf") Object.assign(out, _prfExt(extensions.prf));
|
|
118
|
+
if (k === "largeBlob") Object.assign(out, _largeBlobExt(extensions.largeBlob));
|
|
119
|
+
if (k === "credBlob") Object.assign(out, _credBlobExt(extensions.credBlob));
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
80
124
|
// ---- Registration ----
|
|
81
125
|
|
|
82
126
|
async function startRegistration(opts) {
|
|
@@ -86,6 +130,7 @@ async function startRegistration(opts) {
|
|
|
86
130
|
_requireString(opts.userName, "userName");
|
|
87
131
|
|
|
88
132
|
var sel = opts.authenticatorSelection || {};
|
|
133
|
+
var safeExtensions = _validateExtensions(opts.extensions, opts.allowUnknownExtensions === true);
|
|
89
134
|
var options = await _vendor().generateRegistrationOptions({
|
|
90
135
|
rpName: opts.rpName,
|
|
91
136
|
rpID: opts.rpId,
|
|
@@ -100,7 +145,7 @@ async function startRegistration(opts) {
|
|
|
100
145
|
requireResidentKey: sel.requireResidentKey,
|
|
101
146
|
},
|
|
102
147
|
timeout: opts.timeout,
|
|
103
|
-
extensions:
|
|
148
|
+
extensions: safeExtensions,
|
|
104
149
|
});
|
|
105
150
|
// Hint the browser to surface platform + cross-device authenticators
|
|
106
151
|
// (Touch ID / Windows Hello AND 1Password / Bitwarden / phone-as-key).
|
|
@@ -202,12 +247,13 @@ async function startAuthentication(opts) {
|
|
|
202
247
|
"mediation must be one of silent/optional/required/conditional");
|
|
203
248
|
}
|
|
204
249
|
|
|
250
|
+
var safeAuthExtensions = _validateExtensions(opts.extensions, opts.allowUnknownExtensions === true);
|
|
205
251
|
var options = await _vendor().generateAuthenticationOptions({
|
|
206
252
|
rpID: opts.rpId,
|
|
207
253
|
userVerification: opts.userVerification || "preferred",
|
|
208
254
|
allowCredentials: opts.allowCredentials || [],
|
|
209
255
|
timeout: opts.timeout,
|
|
210
|
-
extensions:
|
|
256
|
+
extensions: safeAuthExtensions,
|
|
211
257
|
});
|
|
212
258
|
if (!opts.hints) {
|
|
213
259
|
options.hints = ["client-device", "hybrid"];
|
|
@@ -230,6 +276,7 @@ async function conditionalAuthOptions(opts) {
|
|
|
230
276
|
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
231
277
|
_requireString(opts.rpId, "rpId");
|
|
232
278
|
|
|
279
|
+
var safeCondExtensions = _validateExtensions(opts.extensions, opts.allowUnknownExtensions === true);
|
|
233
280
|
var options = await _vendor().generateAuthenticationOptions({
|
|
234
281
|
rpID: opts.rpId,
|
|
235
282
|
// For conditional UI the spec mandates an empty allowCredentials
|
|
@@ -238,7 +285,7 @@ async function conditionalAuthOptions(opts) {
|
|
|
238
285
|
allowCredentials: [],
|
|
239
286
|
userVerification: opts.userVerification || "preferred",
|
|
240
287
|
timeout: opts.timeout,
|
|
241
|
-
extensions:
|
|
288
|
+
extensions: safeCondExtensions,
|
|
242
289
|
});
|
|
243
290
|
options.mediation = "conditional";
|
|
244
291
|
if (!opts.hints) {
|
|
@@ -257,22 +304,46 @@ async function conditionalAuthOptions(opts) {
|
|
|
257
304
|
// contract. Validation tier: throw at config-time. Misuse here is a
|
|
258
305
|
// coding bug, not a request-shape thing.
|
|
259
306
|
|
|
260
|
-
|
|
307
|
+
// CTAP2.1 §6.5 — PRF eval inputs are 32-byte salts. Caps every
|
|
308
|
+
// extension input that ships through the binary normalizer.
|
|
309
|
+
var MAX_EXT_INPUT_BYTES = 32; // allow:raw-byte-literal — CTAP2.1 §6.5 PRF salt length
|
|
310
|
+
|
|
311
|
+
function _b64urlExtInput(value, name, maxBytes) {
|
|
261
312
|
// Accept a base64url string OR a Buffer / Uint8Array. Normalize the
|
|
262
313
|
// wire shape to base64url (the JSON descriptor ships base64url; the
|
|
263
314
|
// browser turns it into an ArrayBuffer before passing to the
|
|
264
315
|
// authenticator).
|
|
316
|
+
//
|
|
317
|
+
// AUTH-29 — when `maxBytes` is set, refuse decoded inputs longer than
|
|
318
|
+
// the cap. Per CTAP2.1 §6.5 PRF salts are 32 bytes; pre-v0.9.x the
|
|
319
|
+
// framework accepted arbitrary length, which is undefined behavior on
|
|
320
|
+
// authenticators that may truncate / reject / behave inconsistently.
|
|
265
321
|
if (typeof value === "string") {
|
|
266
322
|
if (value.length === 0 || !safeBuffer.BASE64URL_RE.test(value)) {
|
|
267
323
|
throw new AuthError("auth-passkey/bad-extension-input",
|
|
268
324
|
name + " must be base64url (no padding) when string");
|
|
269
325
|
}
|
|
326
|
+
if (typeof maxBytes === "number") {
|
|
327
|
+
var decoded = Buffer.from(value, "base64url");
|
|
328
|
+
if (decoded.length > maxBytes) {
|
|
329
|
+
throw new AuthError("auth-passkey/extension-input-too-large",
|
|
330
|
+
name + " decoded length " + decoded.length + " exceeds " + maxBytes + " bytes");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
270
333
|
return value;
|
|
271
334
|
}
|
|
272
335
|
if (Buffer.isBuffer(value)) {
|
|
336
|
+
if (typeof maxBytes === "number" && value.length > maxBytes) {
|
|
337
|
+
throw new AuthError("auth-passkey/extension-input-too-large",
|
|
338
|
+
name + " length " + value.length + " exceeds " + maxBytes + " bytes");
|
|
339
|
+
}
|
|
273
340
|
return value.toString("base64url");
|
|
274
341
|
}
|
|
275
342
|
if (value instanceof Uint8Array) {
|
|
343
|
+
if (typeof maxBytes === "number" && value.length > maxBytes) {
|
|
344
|
+
throw new AuthError("auth-passkey/extension-input-too-large",
|
|
345
|
+
name + " length " + value.length + " exceeds " + maxBytes + " bytes");
|
|
346
|
+
}
|
|
276
347
|
return Buffer.from(value).toString("base64url");
|
|
277
348
|
}
|
|
278
349
|
throw new AuthError("auth-passkey/bad-extension-input",
|
|
@@ -293,9 +364,10 @@ function _prfExt(args) {
|
|
|
293
364
|
throw new AuthError("auth-passkey/missing-prf-first",
|
|
294
365
|
"extensions.prf eval.first is required");
|
|
295
366
|
}
|
|
296
|
-
|
|
367
|
+
// AUTH-29 — CTAP2.1 §6.5 caps PRF salts at 32 bytes.
|
|
368
|
+
var out = { prf: { eval: { first: _b64urlExtInput(args.eval.first, "eval.first", MAX_EXT_INPUT_BYTES) } } };
|
|
297
369
|
if (args.eval.second !== undefined && args.eval.second !== null) {
|
|
298
|
-
out.prf.eval.second = _b64urlExtInput(args.eval.second, "eval.second");
|
|
370
|
+
out.prf.eval.second = _b64urlExtInput(args.eval.second, "eval.second", MAX_EXT_INPUT_BYTES);
|
|
299
371
|
}
|
|
300
372
|
return out;
|
|
301
373
|
}
|
|
@@ -448,6 +520,66 @@ async function verifyAuthentication(opts) {
|
|
|
448
520
|
return rv;
|
|
449
521
|
}
|
|
450
522
|
|
|
523
|
+
/**
|
|
524
|
+
* @primitive b.auth.passkey.compareBackupState
|
|
525
|
+
* @signature b.auth.passkey.compareBackupState(prev, current)
|
|
526
|
+
* @since 0.9.57
|
|
527
|
+
*
|
|
528
|
+
* AUTH-27 — WebAuthn L3 §6.1.3. Inspect the credential's persisted BE
|
|
529
|
+
* (backupEligible) + BS (backupState) flags against the values
|
|
530
|
+
* surfaced on a fresh assertion. Returns a normalized verdict the
|
|
531
|
+
* operator routes into audit / step-up decisions:
|
|
532
|
+
*
|
|
533
|
+
* - `ok` — flags unchanged
|
|
534
|
+
* - `be-flipped-on` — credential newly backup-eligible (the
|
|
535
|
+
* authenticator manufacturer enabled cloud-backup on a previously
|
|
536
|
+
* single-device credential; suspicious — operator surfaces
|
|
537
|
+
* step-up)
|
|
538
|
+
* - `be-flipped-off` — credential lost backup eligibility (rare;
|
|
539
|
+
* authenticator firmware downgrade or vendor policy change)
|
|
540
|
+
* - `bs-flipped-on` — credential is now backed up (user enrolled
|
|
541
|
+
* in cloud-sync after initial registration; legitimate but
|
|
542
|
+
* audit-worthy)
|
|
543
|
+
* - `bs-flipped-off` — credential no longer backed up (user
|
|
544
|
+
* disabled cloud-sync; legitimate but audit-worthy)
|
|
545
|
+
*
|
|
546
|
+
* Operators wire this against the credential row's persisted
|
|
547
|
+
* `backupEligible` / `backupState` fields and the corresponding
|
|
548
|
+
* fields on `verifyAuthentication`'s return value.
|
|
549
|
+
*
|
|
550
|
+
* @example
|
|
551
|
+
* var rv = await b.auth.passkey.verifyAuthentication(opts);
|
|
552
|
+
* var diff = b.auth.passkey.compareBackupState(stored, rv);
|
|
553
|
+
* if (diff.verdict !== "ok") {
|
|
554
|
+
* await audit.emit({ event: "passkey.backup-state-changed", metadata: diff });
|
|
555
|
+
* if (diff.verdict === "be-flipped-on") { requireStepUp(); }
|
|
556
|
+
* }
|
|
557
|
+
*/
|
|
558
|
+
function compareBackupState(prev, current) {
|
|
559
|
+
if (!prev || typeof prev !== "object") {
|
|
560
|
+
throw new AuthError("auth-passkey/bad-compare-backup",
|
|
561
|
+
"compareBackupState: prev must be an object with { backupEligible, backupState }");
|
|
562
|
+
}
|
|
563
|
+
if (!current || typeof current !== "object") {
|
|
564
|
+
throw new AuthError("auth-passkey/bad-compare-backup",
|
|
565
|
+
"compareBackupState: current must be an object with { backupEligible, backupState }");
|
|
566
|
+
}
|
|
567
|
+
var pBE = prev.backupEligible === true;
|
|
568
|
+
var pBS = prev.backupState === true;
|
|
569
|
+
var cBE = current.backupEligible === true;
|
|
570
|
+
var cBS = current.backupState === true;
|
|
571
|
+
var verdict = "ok";
|
|
572
|
+
if (pBE !== cBE) verdict = cBE ? "be-flipped-on" : "be-flipped-off";
|
|
573
|
+
else if (pBS !== cBS) verdict = cBS ? "bs-flipped-on" : "bs-flipped-off";
|
|
574
|
+
return {
|
|
575
|
+
verdict: verdict,
|
|
576
|
+
prevBackupEligible: pBE,
|
|
577
|
+
prevBackupState: pBS,
|
|
578
|
+
currentBackupEligible: cBE,
|
|
579
|
+
currentBackupState: cBS,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
451
583
|
// ---- WebAuthn Signal API (W3C draft, 2024) ----
|
|
452
584
|
//
|
|
453
585
|
// The signal* methods build the JSON descriptor that the operator
|
|
@@ -539,4 +671,6 @@ module.exports = {
|
|
|
539
671
|
signalUnknownCredential: signalUnknownCredential,
|
|
540
672
|
signalAllAcceptedCredentials: signalAllAcceptedCredentials,
|
|
541
673
|
signalCurrentUserDetails: signalCurrentUserDetails,
|
|
674
|
+
compareBackupState: compareBackupState,
|
|
675
|
+
ALLOWED_EXTENSION_KEYS: ALLOWED_EXTENSION_KEYS,
|
|
542
676
|
};
|
package/lib/auth/sd-jwt-vc.js
CHANGED
|
@@ -73,6 +73,11 @@ function _timingSafeEqStr(a, b) {
|
|
|
73
73
|
var disclosure = require("./sd-jwt-vc-disclosure");
|
|
74
74
|
var sdJwtVcIssuer = require("./sd-jwt-vc-issuer");
|
|
75
75
|
var sdJwtVcHolder = require("./sd-jwt-vc-holder");
|
|
76
|
+
// Shared JOSE defenses (CVE-2026-22817 alg/kty cross-check +
|
|
77
|
+
// CVE-2026-23552 constant-time iss compare). Top-of-file per project
|
|
78
|
+
// convention §3; no circular load — jwt-external requires nothing from
|
|
79
|
+
// sd-jwt-vc.
|
|
80
|
+
var jwtExternal = require("./jwt-external");
|
|
76
81
|
var { AuthError } = require("../framework-error");
|
|
77
82
|
|
|
78
83
|
var SUPPORTED_ALGS = Object.freeze([
|
|
@@ -296,6 +301,17 @@ function present(opts) {
|
|
|
296
301
|
// Hardcoded sha256 here previously diverged from the verifier when
|
|
297
302
|
// an issuer used a non-default hash, producing sd-hash-mismatch on
|
|
298
303
|
// valid presentations.
|
|
304
|
+
//
|
|
305
|
+
// Defense-in-depth: this pre-parse runs on the holder side
|
|
306
|
+
// (presentation builder) and reads `_sd_alg` from UNSIGNED bytes,
|
|
307
|
+
// because the holder needs to know which hash to use BEFORE the
|
|
308
|
+
// verifier sees the presentation. The presentation itself carries
|
|
309
|
+
// the JWS-signed issuer JWT verbatim; verify() re-parses the
|
|
310
|
+
// payload from the cryptographically-verified signing input. That
|
|
311
|
+
// post-verify decode is the source of truth — a holder who tampers
|
|
312
|
+
// with `_sd_alg` here only breaks their own KB-JWT digest, since
|
|
313
|
+
// the verifier recomputes from the signed bytes. No security
|
|
314
|
+
// boundary is crossed by reading the value here.
|
|
299
315
|
var _issuerPayload = null;
|
|
300
316
|
var _jwtParts = jwt.split(".");
|
|
301
317
|
if (_jwtParts.length === 3) {
|
|
@@ -425,14 +441,39 @@ async function verify(presentation, opts) {
|
|
|
425
441
|
"verify: malformed JWT header: " + e.message);
|
|
426
442
|
}
|
|
427
443
|
var alg = headerObj.alg;
|
|
428
|
-
|
|
444
|
+
// CVE-2026-23993 — refuse unknown / unsupported alg BEFORE any key
|
|
445
|
+
// resolution. The shared `_assertAlgKtyMatch` helper repeats this
|
|
446
|
+
// check after the issuer key is resolved; doing it here too closes
|
|
447
|
+
// the gap where an issuerKeyResolver with side effects (network
|
|
448
|
+
// fetch, audit emit) would run even when the alg is unsupported.
|
|
449
|
+
if (typeof alg !== "string" || SUPPORTED_ALGS.indexOf(alg) === -1) {
|
|
429
450
|
throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
|
|
430
|
-
"verify: header alg \"" + alg + "\" not in supported set"
|
|
431
|
-
|
|
451
|
+
"verify: header alg \"" + alg + "\" not in supported set " +
|
|
452
|
+
"(CVE-2026-23993 — refused before key lookup)");
|
|
453
|
+
}
|
|
454
|
+
// draft-ietf-oauth-sd-jwt-vc §3.1 — typ MUST be `vc+sd-jwt` (or
|
|
455
|
+
// `dc+sd-jwt` for digital-credential profile). Pre-v0.9.x the absent-
|
|
456
|
+
// typ short-circuit accepted any token without typ, contradicting
|
|
457
|
+
// the spec MUST. Refuse absent typ; drop the legacy JWT allowance —
|
|
458
|
+
// verifyExternal handles generic JWT, sd-jwt-vc handles only the
|
|
459
|
+
// typ'd shape.
|
|
432
460
|
var typ = headerObj.typ;
|
|
433
|
-
if (typ
|
|
461
|
+
if (typeof typ !== "string" || (typ !== "vc+sd-jwt" && typ !== "dc+sd-jwt")) {
|
|
434
462
|
throw new AuthError("auth-sd-jwt-vc/bad-typ",
|
|
435
|
-
"verify: header typ must be \"vc+sd-jwt\"
|
|
463
|
+
"verify: header typ must be \"vc+sd-jwt\" or \"dc+sd-jwt\" (got " +
|
|
464
|
+
(typ === undefined ? "<absent>" : "\"" + typ + "\"") +
|
|
465
|
+
") — draft-ietf-oauth-sd-jwt-vc §3.1 MUST");
|
|
466
|
+
}
|
|
467
|
+
// RFC 7515 §4.1.11 — refuse non-empty `crit` header. Every other
|
|
468
|
+
// verifier in the framework refuses critical extensions; sd-jwt-vc
|
|
469
|
+
// previously silently ignored, letting an attacker-controlled issuer
|
|
470
|
+
// declare critical extensions the verifier doesn't understand.
|
|
471
|
+
if (headerObj.crit !== undefined && headerObj.crit !== null) {
|
|
472
|
+
if (!Array.isArray(headerObj.crit) || headerObj.crit.length > 0) {
|
|
473
|
+
throw new AuthError("auth-sd-jwt-vc/unknown-crit",
|
|
474
|
+
"verify: header carries 'crit' extension list — sd-jwt-vc does not " +
|
|
475
|
+
"support any critical extensions and refuses per RFC 7515 §4.1.11");
|
|
476
|
+
}
|
|
436
477
|
}
|
|
437
478
|
|
|
438
479
|
var issuerKey = await opts.issuerKeyResolver(headerObj);
|
|
@@ -440,7 +481,27 @@ async function verify(presentation, opts) {
|
|
|
440
481
|
throw new AuthError("auth-sd-jwt-vc/key-not-found",
|
|
441
482
|
"verify: issuerKeyResolver returned no key");
|
|
442
483
|
}
|
|
484
|
+
// CVE-2026-22817 — when issuerKeyResolver returns a JWK object,
|
|
485
|
+
// cross-check alg/kty BEFORE handing it to node:crypto.verify.
|
|
486
|
+
// KeyObject / PEM shapes can't surface kty so the check happens at
|
|
487
|
+
// JWK resolution only.
|
|
488
|
+
if (typeof issuerKey === "object" &&
|
|
489
|
+
!(issuerKey instanceof nodeCrypto.KeyObject) &&
|
|
490
|
+
!Buffer.isBuffer(issuerKey) &&
|
|
491
|
+
typeof issuerKey.kty === "string") {
|
|
492
|
+
jwtExternal._assertAlgKtyMatch(alg, issuerKey);
|
|
493
|
+
}
|
|
443
494
|
var jwtParsed = _verifyJwt(jwt, issuerKey, alg);
|
|
495
|
+
// AUTH-25 — post-verify header compare. Pre-verify we parsed the
|
|
496
|
+
// header bytes to look up the key; _verifyJwt parses again from the
|
|
497
|
+
// cryptographically-verified signing input. Both decodes MUST yield
|
|
498
|
+
// the same JSON; a mismatch indicates a JWS-canonicalization or
|
|
499
|
+
// duplicate-key issue and refuses defense-in-depth.
|
|
500
|
+
if (safeJson.stringify(headerObj) !== safeJson.stringify(jwtParsed.header)) {
|
|
501
|
+
throw new AuthError("auth-sd-jwt-vc/header-roundtrip-mismatch",
|
|
502
|
+
"verify: pre-verify header bytes do not round-trip equal to post-verify " +
|
|
503
|
+
"header bytes — refusing potential JWS canonicalization smuggle");
|
|
504
|
+
}
|
|
444
505
|
|
|
445
506
|
// 2. Validate iss / iat / exp / vct
|
|
446
507
|
var nowSec = (typeof opts.now === "number" && isFinite(opts.now))
|
|
@@ -459,6 +520,18 @@ async function verify(presentation, opts) {
|
|
|
459
520
|
"verify: vct mismatch (got \"" + jwtParsed.payload.vct +
|
|
460
521
|
"\", expected \"" + opts.expectedVct + "\")");
|
|
461
522
|
}
|
|
523
|
+
// CVE-2026-23552 — optional explicit iss check with constant-time
|
|
524
|
+
// compare. Operators with a known-issuer trust scope pass
|
|
525
|
+
// `opts.expectedIssuer`; absence preserves the existing
|
|
526
|
+
// issuerKeyResolver-only trust model.
|
|
527
|
+
if (opts.expectedIssuer) {
|
|
528
|
+
if (typeof jwtParsed.payload.iss !== "string" ||
|
|
529
|
+
!jwtExternal._issuerMatches(jwtParsed.payload.iss, opts.expectedIssuer)) {
|
|
530
|
+
throw new AuthError("auth-sd-jwt-vc/iss-mismatch",
|
|
531
|
+
"verify: iss '" + jwtParsed.payload.iss + "' does not match expected '" +
|
|
532
|
+
opts.expectedIssuer + "' (CVE-2026-23552 — cross-realm refused)");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
462
535
|
|
|
463
536
|
// 3. Reconstruct disclosed claims from disclosures
|
|
464
537
|
// IETF SD-JWT default `_sd_alg` is `sha-256` (draft-ietf-oauth-
|
package/lib/circuit-breaker.js
CHANGED
|
@@ -78,9 +78,17 @@ function create(opts) {
|
|
|
78
78
|
// The factory's documented shape is `create({ name, ...opts })`;
|
|
79
79
|
// split the name out of opts before invoking the constructor.
|
|
80
80
|
// Caught by hermitstash-sync operator review against v0.9.12.
|
|
81
|
+
//
|
|
82
|
+
// CRYPTO-19 — the previous empty-string fallback was unreachable
|
|
83
|
+
// (retryHelper.CircuitBreaker validator throws on "" first) AND
|
|
84
|
+
// produced a confusing error message ("name must be a non-empty
|
|
85
|
+
// string, got string \"\"") that obscured the real opt-shape
|
|
86
|
+
// problem. Pass opts.name through directly so the validator's
|
|
87
|
+
// error message reports the exact opt-shape error (missing name,
|
|
88
|
+
// non-string name, etc.) — the operator can act on it without
|
|
89
|
+
// tracing through the factory.
|
|
81
90
|
opts = opts || {};
|
|
82
|
-
|
|
83
|
-
return new retryHelper.CircuitBreaker(name, opts);
|
|
91
|
+
return new retryHelper.CircuitBreaker(opts.name, opts);
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
module.exports = {
|
package/lib/cli.js
CHANGED
|
@@ -48,6 +48,7 @@ var C = require("./constants");
|
|
|
48
48
|
var bCrypto = require("./crypto");
|
|
49
49
|
var dev = require("./dev");
|
|
50
50
|
var fileType = require("./file-type");
|
|
51
|
+
var guardRegex = require("./guard-regex");
|
|
51
52
|
var migrations = require("./migrations");
|
|
52
53
|
var passwordModule = require("./auth/password");
|
|
53
54
|
var requestHelpers = require("./request-helpers");
|
|
@@ -357,6 +358,18 @@ async function _runDev(args, ctx) {
|
|
|
357
358
|
"blamejs dev: --ignore pattern exceeds max length " +
|
|
358
359
|
MAX_IGNORE_PATTERN_LENGTH + " (got " + str.length + ")");
|
|
359
360
|
}
|
|
361
|
+
// ReDoS / catastrophic-backtracking defense — refuses nested-quant
|
|
362
|
+
// (CVE-2024-21538 class), consecutive-* (CVE-2026-26996), nested
|
|
363
|
+
// extglob (CVE-2026-33671), and lookaround-quant shapes before the
|
|
364
|
+
// pattern reaches RegExp(). Operator typo / hostile-input identical
|
|
365
|
+
// shape from here on — both want the same refusal.
|
|
366
|
+
try {
|
|
367
|
+
guardRegex.sanitize(str, { profile: "strict" });
|
|
368
|
+
} catch (e) {
|
|
369
|
+
throw new CliError("cli/bad-ignore-pattern",
|
|
370
|
+
"blamejs dev: --ignore pattern refused by guardRegex: " +
|
|
371
|
+
((e && e.message) || String(e)));
|
|
372
|
+
}
|
|
360
373
|
return RegExp(str);
|
|
361
374
|
});
|
|
362
375
|
var graceMs = args.flags["grace-ms"] !== undefined ? Number(args.flags["grace-ms"]) : undefined;
|