Skip to content

Commit 2d2c533

Browse files
Harden unstable JOSE modules per security audit
Addresses the findings from the adversarial audit. No behaviour change for correctly-configured callers; adds fail-closed guards and opt-in hardening. Jwe: - Bound the attacker-controlled PBES2 iteration count on decrypt (maxPBES2Count, default 10000) to prevent a CPU-exhaustion DoS. - Decode all attacker-supplied base64url segments through a typed decoder so malformed input yields JweError("Malformed") instead of an unhandled defect. - Reject an unrecognized 'crit' header (RFC 7516 4.1.13). - Add keyManagementAlgorithms / contentEncryptionAlgorithms allowlists. - Convert key-management crypto rejections to typed errors (tryPromise). - Validate a 'dir' key length equals the content-encryption CEK size. - Bind apu/apv into the ECDH-ES Concat KDF (RFC 7518 4.6.2) and document the AES-GCM random-IV reuse bound. Jws: - Convert importKey/verify rejections to typed failures so an alg/key mismatch or malformed key material is skipped rather than crashing the verifying fiber. - On the jku/embedded-jwk paths, use a key only if it is an asymmetric public key compatible with the header algorithm (never symmetric or private). - Add an 'algorithms' allowlist and a 'maxSignatures' bound. Jwt / Jwk: - Add 'algorithms' and 'types' (typ) allowlists to Jwt.verify. - Extract the alg<->key-type compatibility gate to Jwk.isCompatibleWith and add Jwk.isSymmetric / Jwk.isPrivate guards, shared by Jws and Jwt. Adds test/unstable/jose/Security.test.ts with regression tests for each fix.
1 parent 3e1f8c1 commit 2d2c533

5 files changed

Lines changed: 434 additions & 65 deletions

File tree

packages/effect/src/unstable/jose/Jwe.ts

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
* `RSA1_5` key management is intentionally unsupported — the Web Crypto API
1111
* does not implement RSAES-PKCS1-v1_5 encryption and RFC 8725 discourages it.
1212
*
13+
* Security note: AES-GCM (content encryption and `A*GCMKW` key wrapping) uses
14+
* a fresh random 96-bit IV per operation. Random 96-bit nonces are only safe
15+
* up to roughly 2^32 encryptions under a single fixed key before the
16+
* birthday-bound collision risk becomes non-negligible; this matters for
17+
* `dir` with a reused Content Encryption Key and for a reused `A*GCMKW`
18+
* key-encryption key. Rotate long-lived symmetric keys well before that
19+
* bound, or prefer a key-management mode that derives a fresh CEK per message.
20+
*
1321
* @since 4.0.0
1422
* @see https://www.rfc-editor.org/rfc/rfc7516 - JSON Web Encryption (JWE)
1523
* @see https://www.rfc-editor.org/rfc/rfc7518 - JSON Web Algorithms (JWA)
@@ -195,6 +203,12 @@ const timingSafeEqual = (a: Uint8Array, b: Uint8Array): boolean => {
195203

196204
const die = (reason: JweErrorReason) => (cause: unknown) => new JweError({ reason, cause })
197205

206+
/** @internal Decodes attacker-supplied base64url, mapping `atob` throws to a typed Malformed error. */
207+
const decodeB64 = (value: string) => Effect.try({ try: () => fromBase64Url(value), catch: die("Malformed") })
208+
209+
/** Default cap on the PBES2 iteration count accepted on decrypt (DoS guard, per RFC 8725). */
210+
const defaultMaxPBES2Count = 10_000
211+
198212
// -------------------------------------------------------------------------------------
199213
// Content encryption
200214
// -------------------------------------------------------------------------------------
@@ -345,11 +359,16 @@ const keyManagementEncrypt = Effect.fnUntraced(function*(
345359
enc: (typeof JweEncryption)["Type"],
346360
key: CryptoKey,
347361
cekBytes: number,
348-
options: { readonly p2c: number }
362+
options: { readonly p2c: number; readonly apu: Uint8Array; readonly apv: Uint8Array }
349363
) {
364+
const agreementExtras = {
365+
...(options.apu.length > 0 ? { apu: base64Url(options.apu) } : {}),
366+
...(options.apv.length > 0 ? { apv: base64Url(options.apv) } : {})
367+
}
350368
switch (alg) {
351369
case "dir": {
352370
const cek = new Uint8Array(yield* Effect.promise(() => crypto.subtle.exportKey("raw", key)))
371+
if (cek.length !== cekBytes) return yield* new JweError({ reason: "KeyManagementFailed" })
353372
return { cek, encryptedKey: new Uint8Array(0), headerExtras: {} }
354373
}
355374
case "RSA-OAEP":
@@ -402,16 +421,18 @@ const keyManagementEncrypt = Effect.fnUntraced(function*(
402421
const epk = yield* Effect.promise(() => crypto.subtle.exportKey("jwk", ephemeral.publicKey))
403422
const publicEpk = { kty: epk.kty, crv: epk.crv, x: epk.x, y: epk.y }
404423
if (alg === "ECDH-ES") {
405-
const cek = yield* concatKdf(sharedSecret, cekBytes * 8, enc, new Uint8Array(0), new Uint8Array(0))
406-
return { cek, encryptedKey: new Uint8Array(0), headerExtras: { epk: publicEpk } }
424+
// ECDH-ES direct: algId is the content-encryption algorithm.
425+
const cek = yield* concatKdf(sharedSecret, cekBytes * 8, enc, options.apu, options.apv)
426+
return { cek, encryptedKey: new Uint8Array(0), headerExtras: { epk: publicEpk, ...agreementExtras } }
407427
}
408-
const kekRaw = yield* concatKdf(sharedSecret, aesKwBits(alg), alg, new Uint8Array(0), new Uint8Array(0))
428+
// ECDH-ES+AKW: algId is the key-management algorithm; derived bits are the KEK.
429+
const kekRaw = yield* concatKdf(sharedSecret, aesKwBits(alg), alg, options.apu, options.apv)
409430
const kek = yield* Effect.promise(() =>
410431
crypto.subtle.importKey("raw", u8(kekRaw), "AES-KW", false, ["wrapKey", "unwrapKey"])
411432
)
412433
const cek = randomBytes(cekBytes)
413434
const encryptedKey = yield* aesKwWrap(kek, cek)
414-
return { cek, encryptedKey, headerExtras: { epk: publicEpk } }
435+
return { cek, encryptedKey, headerExtras: { epk: publicEpk, ...agreementExtras } }
415436
}
416437
case "PBES2-HS256+A128KW":
417438
case "PBES2-HS384+A192KW":
@@ -444,12 +465,18 @@ const keyManagementDecrypt = Effect.fnUntraced(function*(
444465
header: (typeof ProtectedHeader)["Type"],
445466
key: CryptoKey,
446467
encryptedKey: Uint8Array,
447-
cekBytes: number
468+
cekBytes: number,
469+
options: { readonly maxPBES2Count: number }
448470
) {
449471
const alg = header.alg
472+
const apu = header.apu === undefined ? new Uint8Array(0) : yield* decodeB64(header.apu)
473+
const apv = header.apv === undefined ? new Uint8Array(0) : yield* decodeB64(header.apv)
450474
switch (alg) {
451-
case "dir":
452-
return new Uint8Array(yield* Effect.promise(() => crypto.subtle.exportKey("raw", key)))
475+
case "dir": {
476+
const cek = new Uint8Array(yield* Effect.promise(() => crypto.subtle.exportKey("raw", key)))
477+
if (cek.length !== cekBytes) return yield* new JweError({ reason: "KeyManagementFailed" })
478+
return cek
479+
}
453480
case "RSA-OAEP":
454481
case "RSA-OAEP-256":
455482
return new Uint8Array(
@@ -466,8 +493,8 @@ const keyManagementDecrypt = Effect.fnUntraced(function*(
466493
case "A192GCMKW":
467494
case "A256GCMKW": {
468495
if (header.iv === undefined || header.tag === undefined) return yield* new JweError({ reason: "Malformed" })
469-
const iv = fromBase64Url(header.iv)
470-
const tag = fromBase64Url(header.tag)
496+
const iv = yield* decodeB64(header.iv)
497+
const tag = yield* decodeB64(header.tag)
471498
const cek = yield* Effect.tryPromise({
472499
try: () =>
473500
crypto.subtle.decrypt(
@@ -484,18 +511,25 @@ const keyManagementDecrypt = Effect.fnUntraced(function*(
484511
case "ECDH-ES+A192KW":
485512
case "ECDH-ES+A256KW": {
486513
if (header.epk === undefined) return yield* new JweError({ reason: "Malformed" })
514+
// The recipient's own curve is used for import. WebCrypto's EC "jwk"
515+
// import rejects an epk whose "crv" does not match (and validates the
516+
// point lies on the curve), which is what defeats invalid-curve attacks;
517+
// a mismatch surfaces here as a typed KeyManagementFailed, not a defect.
487518
const { bitLength, namedCurve } = ecKeyInfo(key)
488519
const ephemeralPublic = yield* Effect.tryPromise({
489520
try: () => crypto.subtle.importKey("jwk", header.epk as JsonWebKey, { name: "ECDH", namedCurve }, false, []),
490521
catch: die("KeyManagementFailed")
491522
})
492523
const sharedSecret = new Uint8Array(
493-
yield* Effect.promise(() => crypto.subtle.deriveBits({ name: "ECDH", public: ephemeralPublic }, key, bitLength))
524+
yield* Effect.tryPromise({
525+
try: () => crypto.subtle.deriveBits({ name: "ECDH", public: ephemeralPublic }, key, bitLength),
526+
catch: die("KeyManagementFailed")
527+
})
494528
)
495529
if (alg === "ECDH-ES") {
496-
return yield* concatKdf(sharedSecret, cekBytes * 8, header.enc, new Uint8Array(0), new Uint8Array(0))
530+
return yield* concatKdf(sharedSecret, cekBytes * 8, header.enc, apu, apv)
497531
}
498-
const kekRaw = yield* concatKdf(sharedSecret, aesKwBits(alg), alg, new Uint8Array(0), new Uint8Array(0))
532+
const kekRaw = yield* concatKdf(sharedSecret, aesKwBits(alg), alg, apu, apv)
499533
const kek = yield* Effect.promise(() =>
500534
crypto.subtle.importKey("raw", u8(kekRaw), "AES-KW", false, ["wrapKey", "unwrapKey"])
501535
)
@@ -505,16 +539,24 @@ const keyManagementDecrypt = Effect.fnUntraced(function*(
505539
case "PBES2-HS384+A192KW":
506540
case "PBES2-HS512+A256KW": {
507541
if (header.p2s === undefined || header.p2c === undefined) return yield* new JweError({ reason: "Malformed" })
542+
// The iteration count is attacker-controlled; bound it to prevent a
543+
// CPU-exhaustion DoS (RFC 8725). The expensive derivation only runs
544+
// after this check passes.
545+
if (!Number.isInteger(header.p2c) || header.p2c < 1000 || header.p2c > options.maxPBES2Count) {
546+
return yield* new JweError({ reason: "Malformed" })
547+
}
508548
const hash = alg.startsWith("PBES2-HS256") ? "SHA-256" : alg.startsWith("PBES2-HS384") ? "SHA-384" : "SHA-512"
509-
const salt = concatBytes(textEncoder.encode(alg), new Uint8Array([0]), fromBase64Url(header.p2s))
549+
const salt = concatBytes(textEncoder.encode(alg), new Uint8Array([0]), yield* decodeB64(header.p2s))
510550
const kekBits = new Uint8Array(
511-
yield* Effect.promise(() =>
512-
crypto.subtle.deriveBits(
513-
{ name: "PBKDF2", salt: u8(salt), iterations: header.p2c, hash },
514-
key,
515-
aesKwBits(alg)
516-
)
517-
)
551+
yield* Effect.tryPromise({
552+
try: () =>
553+
crypto.subtle.deriveBits(
554+
{ name: "PBKDF2", salt: u8(salt), iterations: header.p2c!, hash },
555+
key,
556+
aesKwBits(alg)
557+
),
558+
catch: die("KeyManagementFailed")
559+
})
518560
)
519561
const kek = yield* Effect.promise(() =>
520562
crypto.subtle.importKey("raw", u8(kekBits), "AES-KW", false, ["wrapKey", "unwrapKey"])
@@ -545,12 +587,23 @@ export const encrypt = Effect.fnUntraced(function*(options: {
545587
readonly algorithm: (typeof JweAlgorithm)["Type"]
546588
readonly encryption: (typeof JweEncryption)["Type"]
547589
readonly protectedHeader?: Record<string, unknown> | undefined
548-
/** PBES2 iteration count (defaults to 2048). */
590+
/**
591+
* PBES2 iteration count (defaults to 2048). Keep it at or below the
592+
* recipient's `maxPBES2Count` on decrypt (default 10000). PBES2 is a
593+
* password-based mode and its iteration count is bounded for DoS reasons,
594+
* not a substitute for a high-entropy key.
595+
*/
549596
readonly p2c?: number | undefined
597+
/** ECDH-ES Agreement PartyUInfo (`apu`), bound into the Concat KDF. */
598+
readonly apu?: Uint8Array | undefined
599+
/** ECDH-ES Agreement PartyVInfo (`apv`), bound into the Concat KDF. */
600+
readonly apv?: Uint8Array | undefined
550601
}) {
551602
const params = encryptionParameters(options.encryption)
552603
const km = yield* keyManagementEncrypt(options.algorithm, options.encryption, options.key, params.cekBytes, {
553-
p2c: options.p2c ?? 2048
604+
p2c: options.p2c ?? 2048,
605+
apu: options.apu ?? new Uint8Array(0),
606+
apv: options.apv ?? new Uint8Array(0)
554607
})
555608

556609
const header = {
@@ -589,26 +642,47 @@ export const encrypt = Effect.fnUntraced(function*(options: {
589642
export const decrypt = Effect.fnUntraced(function*(options: {
590643
readonly jwe: string
591644
readonly key: CryptoKey
645+
/** When set, only these key-management (`alg`) values are accepted. */
646+
readonly keyManagementAlgorithms?: ReadonlyArray<(typeof JweAlgorithm)["Type"]> | undefined
647+
/** When set, only these content-encryption (`enc`) values are accepted. */
648+
readonly contentEncryptionAlgorithms?: ReadonlyArray<(typeof JweEncryption)["Type"]> | undefined
649+
/** Maximum PBES2 iteration count accepted (defaults to 10000; DoS guard). */
650+
readonly maxPBES2Count?: number | undefined
592651
}) {
593652
const parts = yield* Schema.decodeUnknownEffect(Compact)(options.jwe).pipe(
594653
Effect.mapError((cause) => new JweError({ reason: "Malformed", cause }))
595654
)
596655

597-
const headerJson = new TextDecoder().decode(fromBase64Url(parts.protected))
656+
const headerBytes = yield* decodeB64(parts.protected)
598657
const header = yield* Schema.decodeUnknownEffect(ProtectedHeader)(
599-
yield* Effect.try({ try: () => JSON.parse(headerJson), catch: die("Malformed") })
658+
yield* Effect.try({ try: () => JSON.parse(new TextDecoder().decode(headerBytes)), catch: die("Malformed") })
600659
).pipe(Effect.mapError((cause) => new JweError({ reason: "Malformed", cause })))
601660

661+
// RFC 7516 §4.1.13: any `crit` extension we do not understand MUST be
662+
// rejected. This implementation understands no critical extensions.
663+
if ((header as Record<string, unknown>).crit !== undefined) {
664+
return yield* new JweError({ reason: "UnsupportedAlgorithm" })
665+
}
666+
if (options.keyManagementAlgorithms !== undefined && !options.keyManagementAlgorithms.includes(header.alg)) {
667+
return yield* new JweError({ reason: "UnsupportedAlgorithm" })
668+
}
669+
if (options.contentEncryptionAlgorithms !== undefined && !options.contentEncryptionAlgorithms.includes(header.enc)) {
670+
return yield* new JweError({ reason: "UnsupportedAlgorithm" })
671+
}
672+
602673
const params = encryptionParameters(header.enc)
603-
const cek = yield* keyManagementDecrypt(header, options.key, fromBase64Url(parts.encryptedKey), params.cekBytes)
674+
const encryptedKey = yield* decodeB64(parts.encryptedKey)
675+
const cek = yield* keyManagementDecrypt(header, options.key, encryptedKey, params.cekBytes, {
676+
maxPBES2Count: options.maxPBES2Count ?? defaultMaxPBES2Count
677+
})
604678

605679
const aad = textEncoder.encode(parts.protected)
606680
const plaintext = yield* contentDecrypt(
607681
params,
608682
cek,
609-
fromBase64Url(parts.iv),
610-
fromBase64Url(parts.ciphertext),
611-
fromBase64Url(parts.tag),
683+
yield* decodeB64(parts.iv),
684+
yield* decodeB64(parts.ciphertext),
685+
yield* decodeB64(parts.tag),
612686
aad
613687
)
614688

packages/effect/src/unstable/jose/Jwk.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,61 @@ export const JwkSet = Schema.Struct({
333333
expected: "a JSON object with a 'keys' member containing an array of JWKs",
334334
description: "A set of JSON Web Keys as defined in RFC 7517 Section 5"
335335
})
336+
337+
/**
338+
* Returns whether a JWK may be used to verify a signature under the given JWS
339+
* algorithm: the key type (and EC curve) must match the algorithm family, and
340+
* a key explicitly marked for encryption (`use: "enc"`) is rejected. Gate key
341+
* selection with this so a token cannot steer a key of one family into an
342+
* incompatible algorithm — e.g. verifying an `RS256` token against an `oct`
343+
* HMAC key (the classic asymmetric/symmetric confusion), which WebCrypto's
344+
* import step alone does not prevent when the key set is attacker-influenced.
345+
*
346+
* @category Compatibility
347+
* @since 4.0.0
348+
*/
349+
export const isCompatibleWith = (
350+
alg: (typeof JwsAlgorithm)["Type"],
351+
jwk: (typeof Jwk)["Type"]
352+
): boolean => {
353+
if (jwk.use === "enc") return false
354+
switch (alg) {
355+
case "ES256":
356+
return jwk.kty === "EC" && jwk.crv === "P-256"
357+
case "ES384":
358+
return jwk.kty === "EC" && jwk.crv === "P-384"
359+
case "ES512":
360+
return jwk.kty === "EC" && jwk.crv === "P-521"
361+
case "RS256":
362+
case "RS384":
363+
case "RS512":
364+
case "PS256":
365+
case "PS384":
366+
case "PS512":
367+
return jwk.kty === "RSA"
368+
case "HS256":
369+
case "HS384":
370+
case "HS512":
371+
return jwk.kty === "oct"
372+
}
373+
}
374+
375+
/**
376+
* Returns whether a JWK is a symmetric (secret) key. Such keys must never be
377+
* accepted from an untrusted source (e.g. a token's `jku`/`jwk` header) as a
378+
* signature-verification key, as that enables signature forgery.
379+
*
380+
* @category Compatibility
381+
* @since 4.0.0
382+
*/
383+
export const isSymmetric = (jwk: (typeof Jwk)["Type"]): boolean => jwk.kty === "oct"
384+
385+
/**
386+
* Returns whether a JWK carries private key material (`d` for EC/RSA). A
387+
* public verification key never does; presence of private material in a key
388+
* pulled from an untrusted source indicates misuse and should be rejected.
389+
*
390+
* @category Compatibility
391+
* @since 4.0.0
392+
*/
393+
export const isPrivate = (jwk: (typeof Jwk)["Type"]): boolean => (jwk.kty === "EC" || jwk.kty === "RSA") && "d" in jwk

0 commit comments

Comments
 (0)