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
196204const 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: {
589642export 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
0 commit comments