@daloyjs/core 1.3.1 → 1.3.3

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.
@@ -167,7 +167,7 @@ async function importKey(alg, material, usage) {
167
167
  const spec = algSpec(alg);
168
168
  const c = getCrypto();
169
169
  if (isCryptoKey(material)) {
170
- assertRsaModulusFloor(alg, material);
170
+ assertKeyPolicy(alg, material);
171
171
  return material;
172
172
  }
173
173
  if (material instanceof Uint8Array) {
@@ -177,15 +177,31 @@ async function importKey(alg, material, usage) {
177
177
  if (material.byteLength < MIN_HMAC_KEY_BYTES) {
178
178
  throw new TypeError(`http-signatures: hmac-sha256 secret must be at least ${MIN_HMAC_KEY_BYTES} bytes (RFC 7518 §3.2); got ${material.byteLength}.`);
179
179
  }
180
- return c.subtle.importKey("raw", material, spec.importParams, false, [usage]);
180
+ return c.subtle.importKey("raw", material, spec.importParams, false, [
181
+ usage,
182
+ ]);
181
183
  }
182
184
  if (isJsonWebKey(material)) {
183
185
  const key = await c.subtle.importKey("jwk", material, spec.importParams, false, [usage]);
184
- assertRsaModulusFloor(alg, key);
186
+ assertKeyPolicy(alg, key);
185
187
  return key;
186
188
  }
187
189
  throw new TypeError("http-signatures: unsupported key material.");
188
190
  }
191
+ function assertKeyPolicy(alg, key) {
192
+ const expected = algSpec(alg).importParams;
193
+ const actual = key.algorithm;
194
+ if (actual.name !== expected.name ||
195
+ (expected.hash !== undefined && actual.hash?.name !== expected.hash) ||
196
+ (expected.namedCurve !== undefined && actual.namedCurve !== expected.namedCurve)) {
197
+ throw new TypeError(`http-signatures: key algorithm does not match ${alg}; family, hash and curve must match.`);
198
+ }
199
+ if (expected.name === "HMAC" &&
200
+ (!Number.isFinite(actual.length) || actual.length < MIN_HMAC_KEY_BYTES * 8)) {
201
+ throw new TypeError(`http-signatures: hmac-sha256 secret must be at least ${MIN_HMAC_KEY_BYTES} bytes (RFC 7518 §3.2).`);
202
+ }
203
+ assertRsaModulusFloor(alg, key);
204
+ }
189
205
  function serializeSfString(s) {
190
206
  for (let i = 0; i < s.length; i++) {
191
207
  const code = s.charCodeAt(i);
@@ -518,7 +534,8 @@ function parseComponentSpec(spec) {
518
534
  * @returns The `Signature-Input` / `Signature` header values plus the exact
519
535
  * signature base that was signed.
520
536
  * @throws {TypeError} for unsupported algorithms, weak HMAC keys, or
521
- * unserializable parameter values.
537
+ * unserializable parameter values. Imported keys must match the selected
538
+ * algorithm's family, hash and curve and meet its strength floor.
522
539
  * @throws {Error} when a covered component cannot be resolved (e.g. a covered
523
540
  * header is missing) or WebCrypto is unavailable.
524
541
  * @since 0.37.0
@@ -585,6 +602,7 @@ function fail(reason) {
585
602
  * Verify an HTTP Message Signature (RFC 9421) on a received message. Returns a
586
603
  * structured result and never throws on a bad/forged signature — only on a
587
604
  * programming error (e.g. WebCrypto unavailable).
605
+ * Imported keys that violate the algorithm or strength policy return invalid_key.
588
606
  *
589
607
  * @param opts - Received message plus verification policy (algorithm
590
608
  * allowlist, key resolver, freshness / replay checks); see
@@ -671,12 +689,8 @@ export async function verifyMessage(opts) {
671
689
  return fail("signature_expired");
672
690
  }
673
691
  // Replay check.
674
- if (opts.isReplay) {
675
- if (params.nonce === undefined)
676
- return fail("missing_nonce");
677
- if (await opts.isReplay(params.nonce, info))
678
- return fail("replay_detected");
679
- }
692
+ if (opts.isReplay && params.nonce === undefined)
693
+ return fail("missing_nonce");
680
694
  // Resolve the key + effective algorithm (defeating algorithm-confusion).
681
695
  const resolved = await opts.resolveKey(info);
682
696
  if (resolved === undefined)
@@ -730,6 +744,9 @@ export async function verifyMessage(opts) {
730
744
  }
731
745
  if (!ok)
732
746
  return fail("invalid_signature");
747
+ if (opts.isReplay && (await opts.isReplay(params.nonce, info))) {
748
+ return fail("replay_detected");
749
+ }
733
750
  return {
734
751
  valid: true,
735
752
  label,
@@ -765,6 +782,8 @@ export function verifyRequest(request, opts) {
765
782
  * requests. On success the {@link VerifySuccess} is stamped on `ctx.state`; on
766
783
  * a missing (unless `optional`) or invalid signature it throws
767
784
  * {@link UnauthorizedError} (`401` + `Cache-Control: no-store`).
785
+ * Verification runs before body I/O and before stored-response middleware,
786
+ * so cache hits and idempotency replays cannot skip signature authentication.
768
787
  *
769
788
  * @param opts - Verification policy plus middleware knobs; see
770
789
  * {@link HttpSignatureAuthOptions}.
@@ -775,7 +794,7 @@ export function httpSignatureAuth(opts) {
775
794
  const stateKey = opts.stateKey ?? "httpSignature";
776
795
  const message = opts.message ?? "Valid HTTP message signature required";
777
796
  const authHooks = {
778
- async beforeHandle(ctx) {
797
+ async preBody(ctx) {
779
798
  const headers = ctx.request.headers;
780
799
  if (opts.optional && !headers.has("signature"))
781
800
  return undefined;
@@ -214,6 +214,8 @@ export interface IdempotencyOptions {
214
214
  * alone is not a bound — it only drops records that have *expired*, so a stream
215
215
  * of unique keys inside the TTL grew the map linearly no matter how often it ran,
216
216
  * with each entry pinning a stored response body.
217
+ * Late completions of evicted reservations use the same capacity policy;
218
+ * completing a retained entry updates it without evicting another record.
217
219
  *
218
220
  * Evicting a live record can only cost exactly-once semantics for a retry that
219
221
  * arrives after the eviction — it re-executes rather than replaying. That is the
@@ -235,7 +237,14 @@ export declare class MemoryIdempotencyStore implements IdempotencyStore {
235
237
  * the in-memory store derives expiry from `record.expiresAt`.
236
238
  */
237
239
  reserve(key: string, record: IdempotencyRecord, _ttlMs?: number): IdempotencyRecord | null;
238
- /** @inheritDoc */
240
+ /**
241
+ * Persist a completed response, enforcing capacity even if its reservation
242
+ * was evicted while the handler ran.
243
+ * @param key - Reservation key to complete.
244
+ * @param record - Completed record with response data and expiry.
245
+ * @param _ttlMs - Unused; expiry is derived from record.expiresAt.
246
+ * @returns Nothing. A new entry may evict the oldest retained record.
247
+ */
239
248
  complete(key: string, record: IdempotencyRecord, _ttlMs?: number): void;
240
249
  /** @inheritDoc */
241
250
  release(key: string): void;
@@ -278,6 +287,12 @@ export declare const IDEMPOTENCY_HOOK_MARKER: unique symbol;
278
287
  * Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
279
288
  * by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
280
289
  * cached and the reservation is released so the client can retry.
290
+ * The byte cap is enforced while reading the response clone; exceeding it
291
+ * stops capture without waiting for EOF or consuming the client's branch.
292
+ * Replays require every scope aggregated from the route's requireScopes hooks;
293
+ * callers without those scopes continue to the normal authorization chain.
294
+ * Empty Authorization headers do not resolve a principal and cannot bypass
295
+ * the default cookie-bearing-request scope guard.
281
296
  *
282
297
  * @example
283
298
  * ```ts
@@ -33,6 +33,8 @@
33
33
  */
34
34
  import { BadRequestError, ConflictError, HttpError } from "./errors.js";
35
35
  import { markSchemaValidatedResponse } from "./internal-response.js";
36
+ import { readResponseBodyUpTo } from "./internal-body.js";
37
+ import { hasReplayScopes } from "./internal-replay.js";
36
38
  const enc = new TextEncoder();
37
39
  /** Internal `ctx.state` key carrying the reservation between hooks. */
38
40
  const PENDING_STATE_KEY = "__idempotencyPending";
@@ -73,6 +75,8 @@ const DEFAULT_MAX_IDEMPOTENCY_ENTRIES = 10_000;
73
75
  * alone is not a bound — it only drops records that have *expired*, so a stream
74
76
  * of unique keys inside the TTL grew the map linearly no matter how often it ran,
75
77
  * with each entry pinning a stored response body.
78
+ * Late completions of evicted reservations use the same capacity policy;
79
+ * completing a retained entry updates it without evicting another record.
76
80
  *
77
81
  * Evicting a live record can only cost exactly-once semantics for a retry that
78
82
  * arrives after the eviction — it re-executes rather than replaying. That is the
@@ -117,8 +121,19 @@ export class MemoryIdempotencyStore {
117
121
  this.map.set(key, record);
118
122
  return null;
119
123
  }
120
- /** @inheritDoc */
124
+ /**
125
+ * Persist a completed response, enforcing capacity even if its reservation
126
+ * was evicted while the handler ran.
127
+ * @param key - Reservation key to complete.
128
+ * @param record - Completed record with response data and expiry.
129
+ * @param _ttlMs - Unused; expiry is derived from record.expiresAt.
130
+ * @returns Nothing. A new entry may evict the oldest retained record.
131
+ */
121
132
  complete(key, record, _ttlMs) {
133
+ if (!this.map.has(key)) {
134
+ this.reserve(key, record, _ttlMs);
135
+ return;
136
+ }
122
137
  this.map.set(key, record);
123
138
  }
124
139
  /** @inheritDoc */
@@ -266,8 +281,8 @@ const NEVER_REPLAYED_HEADERS = new Set([
266
281
  "x-request-id",
267
282
  ]);
268
283
  async function captureResponse(res, maxBytes) {
269
- const buf = new Uint8Array(await res.clone().arrayBuffer());
270
- if (buf.byteLength > maxBytes)
284
+ const buf = await readResponseBodyUpTo(res.clone(), maxBytes);
285
+ if (buf === null)
271
286
  return null;
272
287
  const headers = [];
273
288
  res.headers.forEach((value, name) => {
@@ -326,6 +341,12 @@ export const IDEMPOTENCY_HOOK_MARKER = Symbol.for("daloyjs.idempotency.hook");
326
341
  * Responses that fail {@link IdempotencyOptions.cacheableStatus} (server errors
327
342
  * by default) or exceed {@link IdempotencyOptions.maxResponseBytes} are not
328
343
  * cached and the reservation is released so the client can retry.
344
+ * The byte cap is enforced while reading the response clone; exceeding it
345
+ * stops capture without waiting for EOF or consuming the client's branch.
346
+ * Replays require every scope aggregated from the route's requireScopes hooks;
347
+ * callers without those scopes continue to the normal authorization chain.
348
+ * Empty Authorization headers do not resolve a principal and cannot bypass
349
+ * the default cookie-bearing-request scope guard.
329
350
  *
330
351
  * @example
331
352
  * ```ts
@@ -376,6 +397,8 @@ export function idempotency(opts = {}) {
376
397
  const keyPrefix = opts.groupId ? `${opts.groupId}:` : "";
377
398
  const hooks = {
378
399
  async beforeHandle(ctx) {
400
+ if (!hasReplayScopes(ctx))
401
+ return undefined;
379
402
  const method = ctx.request.method.toUpperCase();
380
403
  if (!methods.has(method))
381
404
  return undefined;
@@ -423,11 +446,11 @@ export function idempotency(opts = {}) {
423
446
  // is never stored or replayed (see {@link NEVER_REPLAYED_HEADERS}), so a
424
447
  // coarse namespace cannot escalate into handing over a live session.
425
448
  if (!opts.scope &&
426
- scopeRaw === undefined &&
449
+ !scopeRaw &&
427
450
  !allowUnscopedCallers &&
428
451
  ctx.request.headers.has("cookie")) {
429
452
  throw new Error("idempotency(): cannot determine the calling principal for a cookie-bearing request. " +
430
- "The default scope reads the Authorization header, which this request does not carry, " +
453
+ "The default scope reads the Authorization header, which this request does not carry with a non-empty value, " +
431
454
  "so every cookie-authenticated caller would share one idempotency namespace and could " +
432
455
  "replay another caller's stored response (CWE-524). Pass " +
433
456
  "`scope: (ctx) => ctx.state.session?.id` (or another stable per-caller id), or set " +
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Read a response clone while enforcing an inclusive byte limit.
3
+ *
4
+ * @param response - A clone whose body may be consumed or cancelled.
5
+ * @param maxBytes - Maximum retained response bytes, validated by the caller.
6
+ * @returns Captured bytes, or null as soon as the stream exceeds the limit.
7
+ * Cancelling a tee branch is not awaited because the other branch may still
8
+ * be waiting for its consumer. The original response remains readable.
9
+ * @throws Propagates stream read errors without converting them to cached data.
10
+ * @internal
11
+ */
12
+ export declare function readResponseBodyUpTo(response: Response, maxBytes: number): Promise<Uint8Array | null>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Read a response clone while enforcing an inclusive byte limit.
3
+ *
4
+ * @param response - A clone whose body may be consumed or cancelled.
5
+ * @param maxBytes - Maximum retained response bytes, validated by the caller.
6
+ * @returns Captured bytes, or null as soon as the stream exceeds the limit.
7
+ * Cancelling a tee branch is not awaited because the other branch may still
8
+ * be waiting for its consumer. The original response remains readable.
9
+ * @throws Propagates stream read errors without converting them to cached data.
10
+ * @internal
11
+ */
12
+ export async function readResponseBodyUpTo(response, maxBytes) {
13
+ if (!response.body)
14
+ return new Uint8Array(0);
15
+ const reader = response.body.getReader();
16
+ const chunks = [];
17
+ let total = 0;
18
+ try {
19
+ while (true) {
20
+ const { done, value } = await reader.read();
21
+ if (done)
22
+ break;
23
+ if (value.byteLength === 0)
24
+ continue;
25
+ total += value.byteLength;
26
+ if (total > maxBytes) {
27
+ void reader.cancel().catch(() => undefined);
28
+ return null;
29
+ }
30
+ chunks.push(value);
31
+ }
32
+ }
33
+ finally {
34
+ reader.releaseLock();
35
+ }
36
+ if (chunks.length === 1)
37
+ return chunks[0];
38
+ const bytes = new Uint8Array(total);
39
+ let offset = 0;
40
+ for (const chunk of chunks) {
41
+ bytes.set(chunk, offset);
42
+ offset += chunk.byteLength;
43
+ }
44
+ return bytes;
45
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Check the scope requirements aggregated by App before a stored-response hook.
3
+ *
4
+ * @param context - Current request state, including requirements installed by
5
+ * App.mergeBeforeHandle and the user resolved by upstream authentication.
6
+ * @returns True when no scope guard applies or the current user owns every
7
+ * required scope. False tells replay middleware to defer to the normal hook
8
+ * chain without reading, reserving, or capturing a stored response. The
9
+ * downstream requireScopes hook remains responsible for rejecting access.
10
+ * @internal
11
+ */
12
+ export declare function hasReplayScopes(context: {
13
+ readonly state: Readonly<Record<string, unknown>>;
14
+ }): boolean;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Check the scope requirements aggregated by App before a stored-response hook.
3
+ *
4
+ * @param context - Current request state, including requirements installed by
5
+ * App.mergeBeforeHandle and the user resolved by upstream authentication.
6
+ * @returns True when no scope guard applies or the current user owns every
7
+ * required scope. False tells replay middleware to defer to the normal hook
8
+ * chain without reading, reserving, or capturing a stored response. The
9
+ * downstream requireScopes hook remains responsible for rejecting access.
10
+ * @internal
11
+ */
12
+ export function hasReplayScopes(context) {
13
+ const required = context.state.__daloyRequiredScopes;
14
+ if (!Array.isArray(required) || required.length === 0)
15
+ return true;
16
+ const user = context.state.user;
17
+ if (!user || typeof user !== "object")
18
+ return false;
19
+ const owned = user.scopes;
20
+ return (Array.isArray(owned) && required.every((scope) => owned.includes(scope)));
21
+ }
package/dist/jwk.d.ts CHANGED
@@ -9,7 +9,8 @@ export interface JwkSet {
9
9
  }
10
10
  /**
11
11
  * Source for the verifier's public keys. Either an in-memory JWKS, a
12
- * `https://` URL (fetched with TTL caching), or a custom async resolver.
12
+ * `https://` URL (fetched directly with TTL caching; redirects are refused),
13
+ * or a custom async resolver.
13
14
  */
14
15
  export type JwkSource = JwkSet | string | (() => JwkSet | Promise<JwkSet>);
15
16
  /**
@@ -61,7 +62,8 @@ export interface JwkOptions {
61
62
  maxStaleSeconds?: number;
62
63
  /**
63
64
  * Optional `fetch` implementation override (mainly for tests). Defaults
64
- * to global `fetch`.
65
+ * to global `fetch`. Overrides must honor `redirect: "error"` to preserve
66
+ * the configured HTTPS signing-key authority.
65
67
  */
66
68
  fetch?: typeof fetch;
67
69
  /**
@@ -79,6 +81,8 @@ export interface JwkOptions {
79
81
  * algorithms at construction time, requires every token to carry a `kid`
80
82
  * header, matches that `kid` against the JWKS, and cross-checks the JWT
81
83
  * header `alg` against the JWK's own `alg` (when present).
84
+ * URL sources are fetched with redirects disabled; redirecting refreshes
85
+ * follow the existing last-good-key grace policy, never installing new keys.
82
86
  *
83
87
  * @example
84
88
  * ```ts
package/dist/jwk.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * `alg`, the two MUST agree (RFC 7517 §4.4 cross-check).
17
17
  * - `exp` / `nbf` / `iat` validated on every verify.
18
18
  * - JWKS URLs MUST be `https://` (refused at construction otherwise).
19
+ * Redirects are refused so the HTTPS/key-authority boundary is not bypassed.
19
20
  * - Optional `verify(payload, ctx)` revalidation hook for
20
21
  * revocation lists, token-version counters, etc.
21
22
  *
@@ -93,6 +94,7 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
93
94
  try {
94
95
  const res = await fetchImpl(source, {
95
96
  headers: { accept: "application/json" },
97
+ redirect: "error",
96
98
  });
97
99
  if (!res.ok) {
98
100
  throw new Error(`jwk(): JWKS fetch failed with status ${res.status}.`);
@@ -144,6 +146,8 @@ function makeJwksLoader(source, fetchImpl, ttlSeconds, maxStaleSeconds) {
144
146
  * algorithms at construction time, requires every token to carry a `kid`
145
147
  * header, matches that `kid` against the JWKS, and cross-checks the JWT
146
148
  * header `alg` against the JWK's own `alg` (when present).
149
+ * URL sources are fetched with redirects disabled; redirecting refreshes
150
+ * follow the existing last-good-key grace policy, never installing new keys.
147
151
  *
148
152
  * @example
149
153
  * ```ts
package/dist/jwt.d.ts CHANGED
@@ -34,7 +34,11 @@ export interface JwtVerified {
34
34
  /** Decoded claims payload after signature and time-claim checks passed. */
35
35
  readonly payload: Record<string, unknown>;
36
36
  }
37
- /** Key material accepted by the signer/verifier. */
37
+ /**
38
+ * Key material accepted by the signer/verifier. Imported keys must match the
39
+ * selected algorithm's family, hash and curve. Every HMAC key format must meet
40
+ * the 32-byte minimum, and every RSA key must have a 2048-bit modulus or larger.
41
+ */
38
42
  export type JwtKeyMaterial = CryptoKey | Uint8Array | JsonWebKey;
39
43
  /** Options for {@link createJwtSigner}. */
40
44
  export interface JwtSignerOptions {
@@ -116,11 +120,14 @@ export interface JwtVerifierOptions {
116
120
  * function refuses payloads without an `exp` claim (unless
117
121
  * `acknowledgeNoExp: true` was set at construction outside production) and
118
122
  * refuses payloads whose `exp - (iat | now)` exceeds `maxLifetimeSeconds`.
123
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
124
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
119
125
  *
120
126
  * @param opts - Algorithm, key, and lifetime policy; see {@link JwtSignerOptions}.
121
127
  * @returns An object whose `sign(payload)` resolves to the compact JWS string.
122
128
  * @throws {JwtError} for `alg: "none"`, unknown algorithms, weak keys, a
123
129
  * missing/invalid `maxLifetimeSeconds`, or `acknowledgeNoExp` in production.
130
+ * `sign()` also rejects imported keys that violate algorithm or strength policy.
124
131
  * @since 0.21.0
125
132
  */
126
133
  export declare function createJwtSigner(opts: JwtSignerOptions): {
@@ -132,12 +139,15 @@ export declare function createJwtSigner(opts: JwtSignerOptions): {
132
139
  * `alg: "none"` and any token whose header `alg` is not in the allowlist;
133
140
  * refuses-at-construction when a symmetric algorithm (`HS*`) is mixed with
134
141
  * a JWK / JWKS-shaped key source (the documented confused-deputy attack).
142
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
143
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
135
144
  *
136
145
  * @param opts - Allowlist, key source, and claim checks; see {@link JwtVerifierOptions}.
137
146
  * @returns An object whose `verify(token)` resolves to the decoded
138
147
  * {@link JwtVerified} or rejects with {@link JwtError}.
139
148
  * @throws {JwtError} at construction for an empty/invalid allowlist, `"none"`
140
149
  * in the allowlist, weak HS* secrets, or HS* mixed with a JWK source.
150
+ * `verify()` also rejects imported keys that violate algorithm or strength policy.
141
151
  * @since 0.21.0
142
152
  */
143
153
  export declare function createJwtVerifier(opts: JwtVerifierOptions): {
package/dist/jwt.js CHANGED
@@ -158,7 +158,7 @@ async function importKey(alg, material, usage) {
158
158
  const params = algParams(alg);
159
159
  const c = getCrypto();
160
160
  if (isCryptoKey(material)) {
161
- assertRsaModulusFloor(alg, material);
161
+ assertKeyPolicy(alg, material);
162
162
  return material;
163
163
  }
164
164
  if (material instanceof Uint8Array) {
@@ -185,11 +185,24 @@ async function importKey(alg, material, usage) {
185
185
  ? { name: "RSASSA-PKCS1-v1_5", hash: params.hash }
186
186
  : { name: "Ed25519" };
187
187
  const imported = await c.subtle.importKey("jwk", material, importAlgorithm, false, [usage]);
188
- assertRsaModulusFloor(alg, imported);
188
+ assertKeyPolicy(alg, imported);
189
189
  return imported;
190
190
  }
191
191
  throw new JwtError("invalid_key", "jwt(): unsupported key material.");
192
192
  }
193
+ function assertKeyPolicy(alg, key) {
194
+ const params = algParams(alg);
195
+ const algorithm = key.algorithm;
196
+ if (algorithm.name !== params.name ||
197
+ (params.hash !== undefined && params.name !== "ECDSA" && algorithm.hash?.name !== params.hash) ||
198
+ (params.namedCurve !== undefined && algorithm.namedCurve !== params.namedCurve)) {
199
+ throw new JwtError("key_algorithm_mismatch", `jwt(): key algorithm does not match ${alg}; the key family, hash and curve must match the declared JWT algorithm.`);
200
+ }
201
+ if (params.name === "HMAC" && (!Number.isFinite(algorithm.length) || algorithm.length < MIN_HS_KEY_BYTES * 8)) {
202
+ throw new JwtError("weak_hs_secret", `jwt(): ${alg} secret must be at least ${MIN_HS_KEY_BYTES} bytes (RFC 7518 §3.2).`);
203
+ }
204
+ assertRsaModulusFloor(alg, key);
205
+ }
193
206
  /**
194
207
  * Refuse RSA keys whose modulus is shorter than {@link MIN_RSA_KEY_BITS}.
195
208
  * Only applies to `RS*` / `PS*` algorithms — non-RSA keys are ignored. The
@@ -224,11 +237,14 @@ function buildSignAlgorithm(alg) {
224
237
  * function refuses payloads without an `exp` claim (unless
225
238
  * `acknowledgeNoExp: true` was set at construction outside production) and
226
239
  * refuses payloads whose `exp - (iat | now)` exceeds `maxLifetimeSeconds`.
240
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
241
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
227
242
  *
228
243
  * @param opts - Algorithm, key, and lifetime policy; see {@link JwtSignerOptions}.
229
244
  * @returns An object whose `sign(payload)` resolves to the compact JWS string.
230
245
  * @throws {JwtError} for `alg: "none"`, unknown algorithms, weak keys, a
231
246
  * missing/invalid `maxLifetimeSeconds`, or `acknowledgeNoExp` in production.
247
+ * `sign()` also rejects imported keys that violate algorithm or strength policy.
232
248
  * @since 0.21.0
233
249
  */
234
250
  export function createJwtSigner(opts) {
@@ -338,12 +354,15 @@ function normalizeStringSet(value) {
338
354
  * `alg: "none"` and any token whose header `alg` is not in the allowlist;
339
355
  * refuses-at-construction when a symmetric algorithm (`HS*`) is mixed with
340
356
  * a JWK / JWKS-shaped key source (the documented confused-deputy attack).
357
+ * Imported keys must match the algorithm's family, hash and curve; HMAC and
358
+ * RSA strength floors apply to CryptoKey and JWK inputs as well as raw bytes.
341
359
  *
342
360
  * @param opts - Allowlist, key source, and claim checks; see {@link JwtVerifierOptions}.
343
361
  * @returns An object whose `verify(token)` resolves to the decoded
344
362
  * {@link JwtVerified} or rejects with {@link JwtError}.
345
363
  * @throws {JwtError} at construction for an empty/invalid allowlist, `"none"`
346
364
  * in the allowlist, weak HS* secrets, or HS* mixed with a JWK source.
365
+ * `verify()` also rejects imported keys that violate algorithm or strength policy.
347
366
  * @since 0.21.0
348
367
  */
349
368
  export function createJwtVerifier(opts) {
package/dist/logger.d.ts CHANGED
@@ -58,7 +58,7 @@ export interface LoggerRedactionOptions {
58
58
  * @since 0.69.0
59
59
  */
60
60
  redactCredentialLikeStrings?: boolean;
61
- /** Maximum recursion depth when walking nested objects. Default: 6. */
61
+ /** Maximum recursion depth when walking nested objects. Deeper objects and arrays are censored in full. Default: 6. */
62
62
  maxDepth?: number;
63
63
  }
64
64
  /**
@@ -98,6 +98,8 @@ interface ResolvedRedaction {
98
98
  * matches `cfg.keys` and any string value shaped like a JWT (when
99
99
  * `cfg.redactJwt` is on) with `cfg.censor`. Exported for direct use by
100
100
  * custom logger implementations that want the same defaults.
101
+ * Objects and arrays beyond the depth budget are replaced with the censor,
102
+ * rather than serialized without inspecting their contents.
101
103
  *
102
104
  * @param record - Log record to redact. Mutated in place (cycle-safe, depth-capped).
103
105
  * @param cfg - Resolved redaction settings (key set, censor, JWT/credential toggles, max depth).
package/dist/logger.js CHANGED
@@ -133,6 +133,8 @@ function redactString(value, cfg) {
133
133
  * matches `cfg.keys` and any string value shaped like a JWT (when
134
134
  * `cfg.redactJwt` is on) with `cfg.censor`. Exported for direct use by
135
135
  * custom logger implementations that want the same defaults.
136
+ * Objects and arrays beyond the depth budget are replaced with the censor,
137
+ * rather than serialized without inspecting their contents.
136
138
  *
137
139
  * @param record - Log record to redact. Mutated in place (cycle-safe, depth-capped).
138
140
  * @param cfg - Resolved redaction settings (key set, censor, JWT/credential toggles, max depth).
@@ -159,6 +161,9 @@ function walkRedact(node, cfg, depth, seen) {
159
161
  if (replaced !== v)
160
162
  node[i] = replaced;
161
163
  }
164
+ else if (v !== null && typeof v === "object" && depth >= cfg.maxDepth) {
165
+ node[i] = cfg.censor;
166
+ }
162
167
  else {
163
168
  walkRedact(v, cfg, depth + 1, seen);
164
169
  }
@@ -178,6 +183,9 @@ function walkRedact(node, cfg, depth, seen) {
178
183
  if (replaced !== v)
179
184
  obj[key] = replaced;
180
185
  }
186
+ else if (v !== null && typeof v === "object" && depth >= cfg.maxDepth) {
187
+ obj[key] = cfg.censor;
188
+ }
181
189
  else {
182
190
  walkRedact(v, cfg, depth + 1, seen);
183
191
  }
package/dist/mtls.d.ts CHANGED
@@ -128,6 +128,8 @@ export interface PeerCertificateLike {
128
128
  * Normalize a Node `getPeerCertificate(true)` result into a
129
129
  * {@link ClientCertificate}. Returns `undefined` for the empty object Node
130
130
  * returns when the peer presented no certificate.
131
+ * Quoted SAN values are decoded without treating their embedded commas as
132
+ * identity separators. Malformed SAN lists yield no identities for allowlists.
131
133
  *
132
134
  * @param raw - The structured peer-certificate object from the TLS socket.
133
135
  * @param verified - Whether the socket reported `authorized === true` (the
@@ -142,9 +144,10 @@ export declare function normalizePeerCertificate(raw: PeerCertificateLike | null
142
144
  * Parse an Envoy `X-Forwarded-Client-Cert` (XFCC) header value into a
143
145
  * {@link ClientCertificate}. XFCC is a comma-separated list of proxy elements,
144
146
  * each a `;`-delimited set of `Key=Value` pairs (`Hash`, `Subject`, `URI`,
145
- * `DNS`, `Cert`, …). The **first** element is the client closest to the origin
146
- * and is the one returned. Because Envoy only emits XFCC for connections it
147
- * mutually authenticated, the result is marked `verified: true`.
147
+ * `DNS`, `Cert`, …). The **first** element is returned. The `verified: true`
148
+ * result is a trusted-proxy assertion, not cryptographic verification by this
149
+ * parser. The terminator must verify client certificates, strip incoming XFCC,
150
+ * and replace it with its own value. Append-only forwarding is insufficient.
148
151
  *
149
152
  * @param headerValue Raw XFCC header value; `null`/`undefined` are tolerated.
150
153
  * @returns The certificate parsed from the first XFCC element, or `undefined`
@@ -220,6 +223,8 @@ export interface ClientCertAuthOptions {
220
223
  /**
221
224
  * If set, the certificate's SHA-256 fingerprint must match one of these
222
225
  * (compared in constant time; colons/spaces and case are ignored).
226
+ * An empty list denies every certificate; omit this option to disable
227
+ * fingerprint restrictions while retaining the other certificate checks.
223
228
  */
224
229
  allowFingerprints?: readonly string[];
225
230
  /**
@@ -254,6 +259,9 @@ export interface ClientCertAuthOptions {
254
259
  * parsed from a trusted-proxy header, enforces verification + optional
255
260
  * allow-lists + validity window + a custom hook, and stamps the accepted
256
261
  * certificate on `ctx.state` for downstream handlers.
262
+ * Header mode requires an origin reachable only through a trusted terminator
263
+ * that strips and replaces identity headers after certificate verification.
264
+ * Fingerprint allowlists do not authenticate client-supplied header values.
257
265
  *
258
266
  * Rejection semantics:
259
267
  * - **No certificate presented** → `401` `application/problem+json` with