diff --git a/.changeset/jwe-module.md b/.changeset/jwe-module.md new file mode 100644 index 0000000..bda1fe4 --- /dev/null +++ b/.changeset/jwe-module.md @@ -0,0 +1,5 @@ +--- +"effect-oidc": minor +--- + +Add a `Jwe` module implementing JSON Web Encryption (RFC 7516) in Compact Serialization, ported from Effect-TS/effect#6566: WebCrypto-backed authenticated encryption with the AES-GCM and AES-CBC-HMAC-SHA2 content encryption families and the `dir`, RSA-OAEP, AES key wrap, AES-GCM key wrap, ECDH-ES (direct and key-wrap), and PBES2 key management families. The `Jwa` module gains the corresponding `JweAlgorithm`, `JweEncryption`, and `encryptionParameters` definitions. Decryption fails closed with typed `JweError`s, rejects unrecognized `crit` extensions, bounds the attacker-controlled PBES2 iteration count, validates the unwrapped CEK length, and supports `alg`/`enc` allowlists. `RSA1_5` is intentionally unsupported per RFC 8725. diff --git a/src/Jwa.ts b/src/Jwa.ts index 8259ad7..c10e1cc 100644 --- a/src/Jwa.ts +++ b/src/Jwa.ts @@ -7,6 +7,13 @@ * algorithm. Those two parameter sets differ (e.g. ECDSA import needs * `namedCurve` while signing needs `hash`), so they are exposed separately. * + * It also defines the JWE algorithm identifiers: the "alg" key management + * algorithms (RFC 7518 Section 4) used to encrypt or derive the Content + * Encryption Key, the "enc" content encryption algorithms (RFC 7518 + * Section 5) that perform authenticated encryption on the plaintext, and the + * structural parameters (key/IV/tag sizes) each content encryption algorithm + * requires. + * * @since 1.0.0 * @see https://www.rfc-editor.org/rfc/rfc7518 - JSON Web Algorithms (JWA) */ @@ -88,3 +95,125 @@ export const signatureParameters = Match.type<(typeof JwsAlgorithm)["Type"]>().p Match.when("PS512", () => ({ name: "RSA-PSS", saltLength: 64 }) as RsaPssParams), Match.exhaustive ); + +/** + * JWE "alg" (key management) algorithm values as defined in RFC 7518 Section + * 4.1. These determine how the Content Encryption Key (CEK) is encrypted or + * derived. `RSA1_5` is intentionally omitted: the Web Crypto API does not + * implement RSAES-PKCS1-v1_5 encryption, and RFC 8725 discourages its use. + * + * @since 1.0.0 + * @category JWE + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.1 + */ +export const JweAlgorithm = Schema.Literals([ + // Key Encryption with RSAES OAEP + "RSA-OAEP", // RSAES OAEP using default (SHA-1) parameters - Recommended- + "RSA-OAEP-256", // RSAES OAEP using SHA-256 and MGF1 with SHA-256 - Optional + + // Key Wrapping with AES Key Wrap + "A128KW", // AES Key Wrap using 128-bit key - Recommended + "A192KW", // AES Key Wrap using 192-bit key - Optional + "A256KW", // AES Key Wrap using 256-bit key - Recommended + + // Direct Encryption with a Shared Symmetric Key + "dir", // Direct use of a shared symmetric key - Recommended + + // Key Agreement with ECDH-ES + "ECDH-ES", // ECDH-ES using Concat KDF, direct - Recommended+ + "ECDH-ES+A128KW", // ECDH-ES using Concat KDF and CEK wrapped with A128KW - Recommended + "ECDH-ES+A192KW", // ECDH-ES using Concat KDF and CEK wrapped with A192KW - Optional + "ECDH-ES+A256KW", // ECDH-ES using Concat KDF and CEK wrapped with A256KW - Recommended + + // Key Encryption with AES GCM + "A128GCMKW", // Key wrapping with AES GCM using 128-bit key - Optional + "A192GCMKW", // Key wrapping with AES GCM using 192-bit key - Optional + "A256GCMKW", // Key wrapping with AES GCM using 256-bit key - Optional + + // Key Encryption with PBES2 + "PBES2-HS256+A128KW", // PBES2 with HMAC SHA-256 and A128KW wrapping - Optional + "PBES2-HS384+A192KW", // PBES2 with HMAC SHA-384 and A192KW wrapping - Optional + "PBES2-HS512+A256KW", // PBES2 with HMAC SHA-512 and A256KW wrapping - Optional +]).annotate({ + title: "JWE Key Management Algorithm", + expected: "a JWE key management algorithm identifier string", + description: "Algorithm used to encrypt or determine the CEK as defined in RFC 7518 Section 4.1", +}); + +/** + * JWE "enc" (content encryption) algorithm values as defined in RFC 7518 + * Section 5.1. These perform authenticated encryption on the plaintext. + * + * @since 1.0.0 + * @category JWE + * @see https://www.rfc-editor.org/rfc/rfc7518#section-5.1 + */ +export const JweEncryption = Schema.Literals([ + // Authenticated Encryption with AES_CBC_HMAC_SHA2 + "A128CBC-HS256", // AES 128 CBC + HMAC SHA-256 (32-byte CEK) - Required + "A192CBC-HS384", // AES 192 CBC + HMAC SHA-384 (48-byte CEK) - Optional + "A256CBC-HS512", // AES 256 CBC + HMAC SHA-512 (64-byte CEK) - Required + + // Authenticated Encryption with AES GCM + "A128GCM", // AES GCM using 128-bit key - Recommended + "A192GCM", // AES GCM using 192-bit key - Optional + "A256GCM", // AES GCM using 256-bit key - Recommended +]).annotate({ + title: "JWE Content Encryption Algorithm", + expected: "a JWE content encryption algorithm identifier string", + description: "Authenticated encryption algorithm as defined in RFC 7518 Section 5.1", +}); + +/** + * Structural parameters for a JWE content encryption algorithm: the Content + * Encryption Key size, IV size, and — for the composite AES-CBC-HMAC family — + * the split key sizes, authentication tag size, and HMAC hash. + * + * @since 1.0.0 + * @category JWE + */ +export const encryptionParameters = Match.type<(typeof JweEncryption)["Type"]>().pipe( + Match.when("A128GCM", () => ({ kind: "gcm", cekBytes: 16, ivBytes: 12 }) as const), + Match.when("A192GCM", () => ({ kind: "gcm", cekBytes: 24, ivBytes: 12 }) as const), + Match.when("A256GCM", () => ({ kind: "gcm", cekBytes: 32, ivBytes: 12 }) as const), + Match.when( + "A128CBC-HS256", + () => + ({ + kind: "cbc", + cekBytes: 32, + ivBytes: 16, + macBytes: 16, + encBytes: 16, + tagBytes: 16, + hash: "SHA-256", + }) as const + ), + Match.when( + "A192CBC-HS384", + () => + ({ + kind: "cbc", + cekBytes: 48, + ivBytes: 16, + macBytes: 24, + encBytes: 24, + tagBytes: 24, + hash: "SHA-384", + }) as const + ), + Match.when( + "A256CBC-HS512", + () => + ({ + kind: "cbc", + cekBytes: 64, + ivBytes: 16, + macBytes: 32, + encBytes: 32, + tagBytes: 32, + hash: "SHA-512", + }) as const + ), + Match.exhaustive +); diff --git a/src/Jwe.ts b/src/Jwe.ts new file mode 100644 index 0000000..63e9b15 --- /dev/null +++ b/src/Jwe.ts @@ -0,0 +1,792 @@ +/** + * JSON Web Encryption (JWE) based on RFC 7516. + * + * This module provides the JWE Compact Serialization together with WebCrypto + * backed authenticated encryption and decryption. It supports the AES-GCM and + * AES-CBC-HMAC-SHA2 content encryption families and the `dir`, RSA-OAEP, + * AES key wrap, AES-GCM key wrap, ECDH-ES (direct and key-wrap), and PBES2 + * key management families. + * + * `RSA1_5` key management is intentionally unsupported — the Web Crypto API + * does not implement RSAES-PKCS1-v1_5 encryption and RFC 8725 discourages it. + * + * Security note: AES-GCM (content encryption and `A*GCMKW` key wrapping) uses + * a fresh random 96-bit IV per operation. Random 96-bit nonces are only safe + * up to roughly 2^32 encryptions under a single fixed key before the + * birthday-bound collision risk becomes non-negligible; this matters for + * `dir` with a reused Content Encryption Key and for a reused `A*GCMKW` + * key-encryption key. Rotate long-lived symmetric keys well before that + * bound, or prefer a key-management mode that derives a fresh CEK per message. + * + * @since 1.0.0 + * @see https://www.rfc-editor.org/rfc/rfc7516 - JSON Web Encryption (JWE) + * @see https://www.rfc-editor.org/rfc/rfc7518 - JSON Web Algorithms (JWA) + */ + +import { Data, Effect, Schema, SchemaGetter } from "effect"; + +import { encryptionParameters, JweAlgorithm, JweEncryption } from "./Jwa.ts"; +import { Jwk } from "./Jwk.ts"; + +const textEncoder = new TextEncoder(); + +/** + * Copies bytes into a fresh `ArrayBuffer`-backed view so they satisfy the + * `BufferSource` parameter type of the Web Crypto API (a `Uint8Array` may be + * backed by a `SharedArrayBuffer`, which those signatures reject). + * + * @internal + */ +const u8 = (data: Uint8Array): Uint8Array => Uint8Array.from(data); + +/** + * The JWE Protected Header (RFC 7516 Section 4). Carries the required `alg` + * and `enc` parameters plus the optional shared and algorithm-specific + * parameters, and is extensible with additional public/private parameters. + * + * @since 1.0.0 + * @category Schema + * @see https://www.rfc-editor.org/rfc/rfc7516#section-4 + */ +export const ProtectedHeader = Schema.StructWithRest( + Schema.Struct({ + /** @see https://www.rfc-editor.org/rfc/rfc7516#section-4.1.1 */ + alg: JweAlgorithm, + + /** @see https://www.rfc-editor.org/rfc/rfc7516#section-4.1.2 */ + enc: JweEncryption, + + /** @see https://www.rfc-editor.org/rfc/rfc7516#section-4.1.6 */ + kid: Schema.String.pipe(Schema.optional), + + /** @see https://www.rfc-editor.org/rfc/rfc7516#section-4.1.11 */ + typ: Schema.String.pipe(Schema.optional), + + /** @see https://www.rfc-editor.org/rfc/rfc7516#section-4.1.12 */ + cty: Schema.String.pipe(Schema.optional), + + /** + * Ephemeral public key for ECDH-ES. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.6.1.1 + */ + epk: Jwk.pipe(Schema.optional), + + /** + * Agreement PartyUInfo for ECDH-ES (base64url). + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.6.1.2 + */ + apu: Schema.String.pipe(Schema.optional), + + /** + * Agreement PartyVInfo for ECDH-ES (base64url). + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.6.1.3 + */ + apv: Schema.String.pipe(Schema.optional), + + /** + * Initialization Vector for AES-GCM key wrap (base64url). + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.7.1.1 + */ + iv: Schema.String.pipe(Schema.optional), + + /** + * Authentication Tag for AES-GCM key wrap (base64url). + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.7.1.2 + */ + tag: Schema.String.pipe(Schema.optional), + + /** + * PBES2 Salt Input (base64url). + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.8.1.1 + */ + p2s: Schema.String.pipe(Schema.optional), + + /** + * PBES2 iteration Count. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-4.8.1.2 + */ + p2c: Schema.Number.pipe(Schema.optional), + }), + [Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Unknown))] +).annotate({ + title: "JWE Protected Header", + description: "The integrity-protected JWE header as defined in RFC 7516 Section 4", +}); + +/** + * The parsed parts of a JWE Compact Serialization (RFC 7516 Section 7.1): + * + * BASE64URL(UTF8(Protected Header)) . BASE64URL(Encrypted Key) . + * BASE64URL(IV) . BASE64URL(Ciphertext) . BASE64URL(Authentication Tag) + * + * @since 1.0.0 + * @category Schema + * @see https://www.rfc-editor.org/rfc/rfc7516#section-7.1 + */ +export const Compact = Schema.TemplateLiteralParser([ + Schema.String, + Schema.Literal("."), + Schema.String, + Schema.Literal("."), + Schema.String, + Schema.Literal("."), + Schema.String, + Schema.Literal("."), + Schema.String, +]).pipe( + Schema.decodeTo( + Schema.Struct({ + protected: Schema.String, + encryptedKey: Schema.String, + iv: Schema.String, + ciphertext: Schema.String, + tag: Schema.String, + }), + { + decode: SchemaGetter.transform((parts) => ({ + protected: parts[0], + encryptedKey: parts[2], + iv: parts[4], + ciphertext: parts[6], + tag: parts[8], + })), + encode: SchemaGetter.transform( + (parts) => + [ + parts.protected, + ".", + parts.encryptedKey, + ".", + parts.iv, + ".", + parts.ciphertext, + ".", + parts.tag, + ] as const + ), + } + ) +); + +/** + * The reasons a JWE operation can fail. + * + * @since 1.0.0 + * @category Errors + */ +export type JweErrorReason = "Malformed" | "UnsupportedAlgorithm" | "KeyManagementFailed" | "DecryptionFailed"; + +/** + * @since 1.0.0 + * @category Errors + */ +export class JweError extends Data.TaggedError("JweError")<{ + readonly reason: JweErrorReason; + readonly cause?: unknown; +}> {} + +/** + * Encodes bytes as an unpadded base64url string. + * + * @internal + */ +const base64Url = (bytes: Uint8Array): string => { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +}; + +/** + * Decodes an unpadded base64url string to bytes. + * + * @internal + */ +const fromBase64Url = (value: string): Uint8Array => { + const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +}; + +/** @internal */ +const randomBytes = (length: number): Uint8Array => crypto.getRandomValues(new Uint8Array(length)); + +/** @internal */ +const concatBytes = (...arrays: ReadonlyArray): Uint8Array => { + const total = arrays.reduce((sum, a) => sum + a.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const a of arrays) { + out.set(a, offset); + offset += a.length; + } + return out; +}; + +/** + * 64-bit big-endian encoding of a bit length. + * + * @internal + */ +const uint64BE = (value: number): Uint8Array => { + const out = new Uint8Array(8); + new DataView(out.buffer).setBigUint64(0, BigInt(value), false); + return out; +}; + +/** + * Constant-time byte comparison (length is not treated as secret). + * + * @internal + */ +const timingSafeEqual = (a: Uint8Array, b: Uint8Array): boolean => { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +}; + +/** @internal */ +const die = (reason: JweErrorReason) => (cause: unknown) => new JweError({ reason, cause }); + +/** + * Decodes attacker-supplied base64url, mapping `atob` throws to a typed + * Malformed error. + * + * @internal + */ +const decodeB64 = (value: string) => Effect.try({ try: () => fromBase64Url(value), catch: die("Malformed") }); + +/** Default cap on the PBES2 iteration count accepted on decrypt (DoS guard, per RFC 8725). */ +const defaultMaxPBES2Count = 10_000; + +type EncParams = ReturnType; + +/** @internal */ +const contentEncrypt = Effect.fnUntraced(function* ( + params: EncParams, + cek: Uint8Array, + iv: Uint8Array, + plaintext: Uint8Array, + aad: Uint8Array +) { + if (params.kind === "gcm") { + const key = yield* Effect.promise(() => crypto.subtle.importKey("raw", u8(cek), "AES-GCM", false, ["encrypt"])); + const combined = new Uint8Array( + yield* Effect.promise(() => + crypto.subtle.encrypt( + { name: "AES-GCM", iv: u8(iv), additionalData: u8(aad), tagLength: 128 }, + key, + u8(plaintext) + ) + ) + ); + return { ciphertext: combined.slice(0, -16), tag: combined.slice(-16) }; + } + + const macKey = cek.slice(0, params.macBytes); + const encKey = cek.slice(params.macBytes); + const aesKey = yield* Effect.promise(() => + crypto.subtle.importKey("raw", u8(encKey), "AES-CBC", false, ["encrypt"]) + ); + const ciphertext = new Uint8Array( + yield* Effect.promise(() => crypto.subtle.encrypt({ name: "AES-CBC", iv: u8(iv) }, aesKey, u8(plaintext))) + ); + const macInput = concatBytes(aad, iv, ciphertext, uint64BE(aad.length * 8)); + const hmacKey = yield* Effect.promise(() => + crypto.subtle.importKey("raw", u8(macKey), { name: "HMAC", hash: params.hash }, false, ["sign"]) + ); + const mac = new Uint8Array(yield* Effect.promise(() => crypto.subtle.sign("HMAC", hmacKey, u8(macInput)))); + return { ciphertext, tag: mac.slice(0, params.tagBytes) }; +}); + +/** @internal */ +const contentDecrypt = Effect.fnUntraced(function* ( + params: EncParams, + cek: Uint8Array, + iv: Uint8Array, + ciphertext: Uint8Array, + tag: Uint8Array, + aad: Uint8Array +) { + if (params.kind === "gcm") { + const key = yield* Effect.tryPromise({ + try: () => crypto.subtle.importKey("raw", u8(cek), "AES-GCM", false, ["decrypt"]), + catch: die("DecryptionFailed"), + }); + const plaintext = yield* Effect.tryPromise({ + try: () => + crypto.subtle.decrypt( + { name: "AES-GCM", iv: u8(iv), additionalData: u8(aad), tagLength: 128 }, + key, + u8(concatBytes(ciphertext, tag)) + ), + catch: die("DecryptionFailed"), + }); + return new Uint8Array(plaintext); + } + + const macKey = cek.slice(0, params.macBytes); + const encKey = cek.slice(params.macBytes); + const macInput = concatBytes(aad, iv, ciphertext, uint64BE(aad.length * 8)); + const hmacKey = yield* Effect.tryPromise({ + try: () => crypto.subtle.importKey("raw", u8(macKey), { name: "HMAC", hash: params.hash }, false, ["sign"]), + catch: die("DecryptionFailed"), + }); + const mac = new Uint8Array( + yield* Effect.tryPromise({ + try: () => crypto.subtle.sign("HMAC", hmacKey, u8(macInput)), + catch: die("DecryptionFailed"), + }) + ); + if (!timingSafeEqual(mac.slice(0, params.tagBytes), tag)) { + return yield* new JweError({ reason: "DecryptionFailed" }); + } + const aesKey = yield* Effect.tryPromise({ + try: () => crypto.subtle.importKey("raw", u8(encKey), "AES-CBC", false, ["decrypt"]), + catch: die("DecryptionFailed"), + }); + const plaintext = yield* Effect.tryPromise({ + try: () => crypto.subtle.decrypt({ name: "AES-CBC", iv: u8(iv) }, aesKey, u8(ciphertext)), + catch: die("DecryptionFailed"), + }); + return new Uint8Array(plaintext); +}); + +/** + * RFC 7518 Section 4.6.2 Concat KDF specialised to SHA-256. + * + * @internal + */ +const concatKdf = Effect.fnUntraced(function* ( + sharedSecret: Uint8Array, + keyDataLenBits: number, + algId: string, + apu: Uint8Array, + apv: Uint8Array +) { + const encodeLengthPrefixed = (bytes: Uint8Array) => concatBytes(uint64BE(bytes.length).slice(4), bytes); + const otherInfo = concatBytes( + encodeLengthPrefixed(textEncoder.encode(algId)), + encodeLengthPrefixed(apu), + encodeLengthPrefixed(apv), + uint64BE(keyDataLenBits).slice(4) + ); + const hashLenBits = 256; + const reps = Math.ceil(keyDataLenBits / hashLenBits); + const derived = new Uint8Array((reps * hashLenBits) / 8); + for (let i = 1; i <= reps; i++) { + const counter = uint64BE(i).slice(4); + const digest = new Uint8Array( + yield* Effect.promise(() => + crypto.subtle.digest("SHA-256", u8(concatBytes(counter, sharedSecret, otherInfo))) + ) + ); + derived.set(digest, (i - 1) * (hashLenBits / 8)); + } + return derived.slice(0, keyDataLenBits / 8); +}); + +/** @internal */ +const aesKwWrap = (kek: CryptoKey, cek: Uint8Array) => + Effect.gen(function* () { + const cekKey = yield* Effect.promise(() => + crypto.subtle.importKey("raw", u8(cek), { name: "HMAC", hash: "SHA-256" }, true, ["sign"]) + ); + return new Uint8Array(yield* Effect.promise(() => crypto.subtle.wrapKey("raw", cekKey, kek, "AES-KW"))); + }); + +/** @internal */ +const aesKwUnwrap = (kek: CryptoKey, wrapped: Uint8Array) => + Effect.gen(function* () { + const cekKey = yield* Effect.tryPromise({ + try: () => + crypto.subtle.unwrapKey("raw", u8(wrapped), kek, "AES-KW", { name: "HMAC", hash: "SHA-256" }, true, [ + "sign", + ]), + catch: die("KeyManagementFailed"), + }); + return new Uint8Array( + yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("raw", cekKey), + catch: die("KeyManagementFailed"), + }) + ); + }); + +/** @internal */ +const ecKeyInfo = (key: CryptoKey) => { + const namedCurve = (key.algorithm as EcKeyAlgorithm).namedCurve; + // deriveBits length must be byte-aligned; P-521 shared secrets are 66 bytes. + const bitLength = namedCurve === "P-256" ? 256 : namedCurve === "P-384" ? 384 : 528; + return { namedCurve, bitLength }; +}; + +/** @internal */ +const aesKwBits = (alg: (typeof JweAlgorithm)["Type"]): 128 | 192 | 256 => + alg.includes("128") ? 128 : alg.includes("192") ? 192 : 256; + +/** @internal */ +const keyManagementEncrypt = Effect.fnUntraced(function* ( + alg: (typeof JweAlgorithm)["Type"], + enc: (typeof JweEncryption)["Type"], + key: CryptoKey, + cekBytes: number, + options: { readonly p2c: number; readonly apu: Uint8Array; readonly apv: Uint8Array } +) { + const agreementExtras = { + ...(options.apu.length > 0 ? { apu: base64Url(options.apu) } : {}), + ...(options.apv.length > 0 ? { apv: base64Url(options.apv) } : {}), + }; + switch (alg) { + case "dir": { + const cek = new Uint8Array(yield* Effect.promise(() => crypto.subtle.exportKey("raw", key))); + if (cek.length !== cekBytes) return yield* new JweError({ reason: "KeyManagementFailed" }); + return { cek, encryptedKey: new Uint8Array(0), headerExtras: {} }; + } + case "RSA-OAEP": + case "RSA-OAEP-256": { + const cek = randomBytes(cekBytes); + const encryptedKey = new Uint8Array( + yield* Effect.tryPromise({ + try: () => crypto.subtle.encrypt({ name: "RSA-OAEP" }, key, u8(cek)), + catch: die("KeyManagementFailed"), + }) + ); + return { cek, encryptedKey, headerExtras: {} }; + } + case "A128KW": + case "A192KW": + case "A256KW": { + const cek = randomBytes(cekBytes); + const encryptedKey = yield* aesKwWrap(key, cek); + return { cek, encryptedKey, headerExtras: {} }; + } + case "A128GCMKW": + case "A192GCMKW": + case "A256GCMKW": { + const cek = randomBytes(cekBytes); + const iv = randomBytes(12); + const combined = new Uint8Array( + yield* Effect.promise(() => + crypto.subtle.encrypt({ name: "AES-GCM", iv: u8(iv), tagLength: 128 }, key, u8(cek)) + ) + ); + return { + cek, + encryptedKey: combined.slice(0, -16), + headerExtras: { iv: base64Url(iv), tag: base64Url(combined.slice(-16)) }, + }; + } + case "ECDH-ES": + case "ECDH-ES+A128KW": + case "ECDH-ES+A192KW": + case "ECDH-ES+A256KW": { + const { bitLength, namedCurve } = ecKeyInfo(key); + const ephemeral = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDH", namedCurve }, true, ["deriveBits"]) + ); + const sharedSecret = new Uint8Array( + yield* Effect.promise(() => + crypto.subtle.deriveBits({ name: "ECDH", public: key }, ephemeral.privateKey, bitLength) + ) + ); + const epk = yield* Effect.promise(() => crypto.subtle.exportKey("jwk", ephemeral.publicKey)); + const publicEpk = { kty: epk.kty, crv: epk.crv, x: epk.x, y: epk.y }; + if (alg === "ECDH-ES") { + // ECDH-ES direct: algId is the content-encryption algorithm. + const cek = yield* concatKdf(sharedSecret, cekBytes * 8, enc, options.apu, options.apv); + return { cek, encryptedKey: new Uint8Array(0), headerExtras: { epk: publicEpk, ...agreementExtras } }; + } + // ECDH-ES+AKW: algId is the key-management algorithm; derived bits are the KEK. + const kekRaw = yield* concatKdf(sharedSecret, aesKwBits(alg), alg, options.apu, options.apv); + const kek = yield* Effect.promise(() => + crypto.subtle.importKey("raw", u8(kekRaw), "AES-KW", false, ["wrapKey", "unwrapKey"]) + ); + const cek = randomBytes(cekBytes); + const encryptedKey = yield* aesKwWrap(kek, cek); + return { cek, encryptedKey, headerExtras: { epk: publicEpk, ...agreementExtras } }; + } + case "PBES2-HS256+A128KW": + case "PBES2-HS384+A192KW": + case "PBES2-HS512+A256KW": { + const hash = alg.startsWith("PBES2-HS256") + ? "SHA-256" + : alg.startsWith("PBES2-HS384") + ? "SHA-384" + : "SHA-512"; + const p2s = randomBytes(16); + const salt = concatBytes(textEncoder.encode(alg), new Uint8Array([0]), p2s); + // Node's WebCrypto cannot deriveKey directly into an AES-KW key, so + // derive the raw key-encryption-key bits and import them. + const kekBits = new Uint8Array( + yield* Effect.promise(() => + crypto.subtle.deriveBits( + { name: "PBKDF2", salt: u8(salt), iterations: options.p2c, hash }, + key, + aesKwBits(alg) + ) + ) + ); + const kek = yield* Effect.promise(() => + crypto.subtle.importKey("raw", u8(kekBits), "AES-KW", false, ["wrapKey", "unwrapKey"]) + ); + const cek = randomBytes(cekBytes); + const encryptedKey = yield* aesKwWrap(kek, cek); + return { cek, encryptedKey, headerExtras: { p2s: base64Url(p2s), p2c: options.p2c } }; + } + } +}); + +/** @internal */ +const keyManagementDecrypt = Effect.fnUntraced(function* ( + header: (typeof ProtectedHeader)["Type"], + key: CryptoKey, + encryptedKey: Uint8Array, + cekBytes: number, + options: { readonly maxPBES2Count: number } +) { + const alg = header.alg; + const apu = header.apu === undefined ? new Uint8Array(0) : yield* decodeB64(header.apu); + const apv = header.apv === undefined ? new Uint8Array(0) : yield* decodeB64(header.apv); + switch (alg) { + case "dir": { + const cek = new Uint8Array( + yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("raw", key), + catch: die("KeyManagementFailed"), + }) + ); + if (cek.length !== cekBytes) return yield* new JweError({ reason: "KeyManagementFailed" }); + return cek; + } + case "RSA-OAEP": + case "RSA-OAEP-256": + return new Uint8Array( + yield* Effect.tryPromise({ + try: () => crypto.subtle.decrypt({ name: "RSA-OAEP" }, key, u8(encryptedKey)), + catch: die("DecryptionFailed"), + }) + ); + case "A128KW": + case "A192KW": + case "A256KW": + return yield* aesKwUnwrap(key, encryptedKey); + case "A128GCMKW": + case "A192GCMKW": + case "A256GCMKW": { + if (header.iv === undefined || header.tag === undefined) + return yield* new JweError({ reason: "Malformed" }); + const iv = yield* decodeB64(header.iv); + const tag = yield* decodeB64(header.tag); + const cek = yield* Effect.tryPromise({ + try: () => + crypto.subtle.decrypt( + { name: "AES-GCM", iv: u8(iv), tagLength: 128 }, + key, + u8(concatBytes(encryptedKey, tag)) + ), + catch: die("DecryptionFailed"), + }); + return new Uint8Array(cek); + } + case "ECDH-ES": + case "ECDH-ES+A128KW": + case "ECDH-ES+A192KW": + case "ECDH-ES+A256KW": { + if (header.epk === undefined) return yield* new JweError({ reason: "Malformed" }); + // The recipient's own curve is used for import. WebCrypto's EC "jwk" + // import rejects an epk whose "crv" does not match (and validates the + // point lies on the curve), which is what defeats invalid-curve attacks; + // a mismatch surfaces here as a typed KeyManagementFailed, not a defect. + const { bitLength, namedCurve } = ecKeyInfo(key); + const ephemeralPublic = yield* Effect.tryPromise({ + try: () => + crypto.subtle.importKey("jwk", header.epk as JsonWebKey, { name: "ECDH", namedCurve }, false, []), + catch: die("KeyManagementFailed"), + }); + const sharedSecret = new Uint8Array( + yield* Effect.tryPromise({ + try: () => crypto.subtle.deriveBits({ name: "ECDH", public: ephemeralPublic }, key, bitLength), + catch: die("KeyManagementFailed"), + }) + ); + if (alg === "ECDH-ES") { + return yield* concatKdf(sharedSecret, cekBytes * 8, header.enc, apu, apv); + } + const kekRaw = yield* concatKdf(sharedSecret, aesKwBits(alg), alg, apu, apv); + const kek = yield* Effect.tryPromise({ + try: () => crypto.subtle.importKey("raw", u8(kekRaw), "AES-KW", false, ["wrapKey", "unwrapKey"]), + catch: die("KeyManagementFailed"), + }); + return yield* aesKwUnwrap(kek, encryptedKey); + } + case "PBES2-HS256+A128KW": + case "PBES2-HS384+A192KW": + case "PBES2-HS512+A256KW": { + if (header.p2s === undefined || header.p2c === undefined) + return yield* new JweError({ reason: "Malformed" }); + // The iteration count is attacker-controlled; bound it to prevent a + // CPU-exhaustion DoS (RFC 8725). The expensive derivation only runs + // after this check passes. + if (!Number.isInteger(header.p2c) || header.p2c < 1000 || header.p2c > options.maxPBES2Count) { + return yield* new JweError({ reason: "Malformed" }); + } + const p2c = header.p2c; + const hash = alg.startsWith("PBES2-HS256") + ? "SHA-256" + : alg.startsWith("PBES2-HS384") + ? "SHA-384" + : "SHA-512"; + const salt = concatBytes(textEncoder.encode(alg), new Uint8Array([0]), yield* decodeB64(header.p2s)); + const kekBits = new Uint8Array( + yield* Effect.tryPromise({ + try: () => + crypto.subtle.deriveBits( + { name: "PBKDF2", salt: u8(salt), iterations: p2c, hash }, + key, + aesKwBits(alg) + ), + catch: die("KeyManagementFailed"), + }) + ); + const kek = yield* Effect.tryPromise({ + try: () => crypto.subtle.importKey("raw", u8(kekBits), "AES-KW", false, ["wrapKey", "unwrapKey"]), + catch: die("KeyManagementFailed"), + }); + return yield* aesKwUnwrap(kek, encryptedKey); + } + } +}); + +/** + * Encrypts a plaintext into a JWE Compact Serialization string. + * + * The `key` must be a WebCrypto `CryptoKey` appropriate for `algorithm`: an + * RSA public key for RSA-OAEP, an AES key for the key-wrap families, an EC + * key imported for `ECDH` for the ECDH-ES families, a PBKDF2 key for PBES2, + * or the shared content key for `dir`. + * + * @since 1.0.0 + * @category Encryption + * @see https://www.rfc-editor.org/rfc/rfc7516#section-5.1 + */ +export const encrypt = Effect.fnUntraced(function* (options: { + readonly plaintext: string | Uint8Array; + readonly key: CryptoKey; + readonly algorithm: (typeof JweAlgorithm)["Type"]; + readonly encryption: (typeof JweEncryption)["Type"]; + readonly protectedHeader?: Record | undefined; + /** + * PBES2 iteration count (defaults to 2048). Keep it at or below the + * recipient's `maxPBES2Count` on decrypt (default 10000). PBES2 is a + * password-based mode and its iteration count is bounded for DoS reasons, + * not a substitute for a high-entropy key. + */ + readonly p2c?: number | undefined; + /** ECDH-ES Agreement PartyUInfo (`apu`), bound into the Concat KDF. */ + readonly apu?: Uint8Array | undefined; + /** ECDH-ES Agreement PartyVInfo (`apv`), bound into the Concat KDF. */ + readonly apv?: Uint8Array | undefined; +}) { + const params = encryptionParameters(options.encryption); + const km = yield* keyManagementEncrypt(options.algorithm, options.encryption, options.key, params.cekBytes, { + p2c: options.p2c ?? 2048, + apu: options.apu ?? new Uint8Array(0), + apv: options.apv ?? new Uint8Array(0), + }); + + const header = { + ...options.protectedHeader, + ...km.headerExtras, + alg: options.algorithm, + enc: options.encryption, + }; + const protectedB64 = base64Url(textEncoder.encode(JSON.stringify(header))); + const aad = textEncoder.encode(protectedB64); + const iv = randomBytes(params.ivBytes); + const plaintextBytes = + typeof options.plaintext === "string" ? textEncoder.encode(options.plaintext) : options.plaintext; + + const { ciphertext, tag } = yield* contentEncrypt(params, km.cek, iv, plaintextBytes, aad); + + return [protectedB64, base64Url(km.encryptedKey), base64Url(iv), base64Url(ciphertext), base64Url(tag)].join("."); +}); + +/** + * Decrypts a JWE Compact Serialization string, returning the decoded + * protected header and the plaintext bytes. The `key` must be the + * counterpart to the one used for encryption (RSA/EC private key, or the + * shared symmetric/PBKDF2 key). + * + * @since 1.0.0 + * @category Decryption + * @see https://www.rfc-editor.org/rfc/rfc7516#section-5.2 + */ +export const decrypt = Effect.fnUntraced(function* (options: { + readonly jwe: string; + readonly key: CryptoKey; + /** When set, only these key-management (`alg`) values are accepted. */ + readonly keyManagementAlgorithms?: ReadonlyArray<(typeof JweAlgorithm)["Type"]> | undefined; + /** When set, only these content-encryption (`enc`) values are accepted. */ + readonly contentEncryptionAlgorithms?: ReadonlyArray<(typeof JweEncryption)["Type"]> | undefined; + /** Maximum PBES2 iteration count accepted (defaults to 10000; DoS guard). */ + readonly maxPBES2Count?: number | undefined; +}) { + const parts = yield* Schema.decodeUnknownEffect(Compact)(options.jwe).pipe( + Effect.mapError((cause) => new JweError({ reason: "Malformed", cause })) + ); + + const headerBytes = yield* decodeB64(parts.protected); + const header = yield* Schema.decodeUnknownEffect(ProtectedHeader)( + yield* Effect.try({ try: () => JSON.parse(new TextDecoder().decode(headerBytes)), catch: die("Malformed") }) + ).pipe(Effect.mapError((cause) => new JweError({ reason: "Malformed", cause }))); + + // RFC 7516 §4.1.13: any `crit` extension we do not understand MUST be + // rejected. This implementation understands no critical extensions. + if ((header as Record).crit !== undefined) { + return yield* new JweError({ reason: "UnsupportedAlgorithm" }); + } + if (options.keyManagementAlgorithms !== undefined && !options.keyManagementAlgorithms.includes(header.alg)) { + return yield* new JweError({ reason: "UnsupportedAlgorithm" }); + } + if ( + options.contentEncryptionAlgorithms !== undefined && + !options.contentEncryptionAlgorithms.includes(header.enc) + ) { + return yield* new JweError({ reason: "UnsupportedAlgorithm" }); + } + + const params = encryptionParameters(header.enc); + const encryptedKey = yield* decodeB64(parts.encryptedKey); + const cek = yield* keyManagementDecrypt(header, options.key, encryptedKey, params.cekBytes, { + maxPBES2Count: options.maxPBES2Count ?? defaultMaxPBES2Count, + }); + // A key-management algorithm can yield a CEK of the wrong size — e.g. an + // attacker RSA-OAEP-encrypts an arbitrary-length key to the recipient's + // public key. Reject it before it reaches AES importKey in contentDecrypt, + // which would otherwise reject and surface as an unhandled defect. + if (cek.length !== params.cekBytes) { + return yield* new JweError({ reason: "DecryptionFailed" }); + } + + const aad = textEncoder.encode(parts.protected); + const plaintext = yield* contentDecrypt( + params, + cek, + yield* decodeB64(parts.iv), + yield* decodeB64(parts.ciphertext), + yield* decodeB64(parts.tag), + aad + ); + + return { protectedHeader: header, plaintext }; +}); diff --git a/src/index.ts b/src/index.ts index 5db13ac..b67d011 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,11 +11,44 @@ * algorithm. Those two parameter sets differ (e.g. ECDSA import needs * `namedCurve` while signing needs `hash`), so they are exposed separately. * + * It also defines the JWE algorithm identifiers: the "alg" key management + * algorithms (RFC 7518 Section 4) used to encrypt or derive the Content + * Encryption Key, the "enc" content encryption algorithms (RFC 7518 + * Section 5) that perform authenticated encryption on the plaintext, and the + * structural parameters (key/IV/tag sizes) each content encryption algorithm + * requires. + * * @since 1.0.0 * @see https://www.rfc-editor.org/rfc/rfc7518 - JSON Web Algorithms (JWA) */ export * as Jwa from "./Jwa.ts" +/** + * JSON Web Encryption (JWE) based on RFC 7516. + * + * This module provides the JWE Compact Serialization together with WebCrypto + * backed authenticated encryption and decryption. It supports the AES-GCM and + * AES-CBC-HMAC-SHA2 content encryption families and the `dir`, RSA-OAEP, + * AES key wrap, AES-GCM key wrap, ECDH-ES (direct and key-wrap), and PBES2 + * key management families. + * + * `RSA1_5` key management is intentionally unsupported — the Web Crypto API + * does not implement RSAES-PKCS1-v1_5 encryption and RFC 8725 discourages it. + * + * Security note: AES-GCM (content encryption and `A*GCMKW` key wrapping) uses + * a fresh random 96-bit IV per operation. Random 96-bit nonces are only safe + * up to roughly 2^32 encryptions under a single fixed key before the + * birthday-bound collision risk becomes non-negligible; this matters for + * `dir` with a reused Content Encryption Key and for a reused `A*GCMKW` + * key-encryption key. Rotate long-lived symmetric keys well before that + * bound, or prefer a key-management mode that derives a fresh CEK per message. + * + * @since 1.0.0 + * @see https://www.rfc-editor.org/rfc/rfc7516 - JSON Web Encryption (JWE) + * @see https://www.rfc-editor.org/rfc/rfc7518 - JSON Web Algorithms (JWA) + */ +export * as Jwe from "./Jwe.ts" + /** * JSON Web Key (JWK) schemas based on RFC 7517 and RFC 7518 Section 6. * diff --git a/test/Jwe.test.ts b/test/Jwe.test.ts new file mode 100644 index 0000000..fbeca80 --- /dev/null +++ b/test/Jwe.test.ts @@ -0,0 +1,225 @@ +import { Effect } from "effect"; + +import { expect, it } from "@effect/vitest"; +import { type Jwa, Jwe } from "effect-oidc"; + +const encryptions: ReadonlyArray<(typeof Jwa.JweEncryption)["Type"]> = [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM", +]; + +const cekBytesFor = (enc: (typeof Jwa.JweEncryption)["Type"]): number => { + switch (enc) { + case "A128GCM": + return 16; + case "A192GCM": + return 24; + case "A256GCM": + return 32; + case "A128CBC-HS256": + return 32; + case "A192CBC-HS384": + return 48; + case "A256CBC-HS512": + return 64; + } +}; + +const randomBytes = (n: number) => crypto.getRandomValues(new Uint8Array(n)); +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +const importAesKw = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.importKey("raw", bytes, "AES-KW", false, ["wrapKey", "unwrapKey"])); +const importAesGcm = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"])); +const importHmac = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash: "SHA-256" }, true, ["sign"])); +const importPbkdf2 = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.importKey("raw", bytes, "PBKDF2", false, ["deriveBits"])); + +/** Builds an encrypt/decrypt key pair appropriate for a key management algorithm. */ +const keysFor = (alg: (typeof Jwa.JweAlgorithm)["Type"], enc: (typeof Jwa.JweEncryption)["Type"]) => + Effect.gen(function* () { + switch (alg) { + case "dir": { + // The shared key IS the CEK, so it must match the content algorithm's size. + const key = yield* importHmac(randomBytes(cekBytesFor(enc))); + return { encryptKey: key, decryptKey: key }; + } + case "RSA-OAEP": + case "RSA-OAEP-256": { + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: alg === "RSA-OAEP" ? "SHA-1" : "SHA-256", + }, + true, + ["encrypt", "decrypt"] + ) + ); + return { encryptKey: pair.publicKey, decryptKey: pair.privateKey }; + } + case "A128KW": + case "A192KW": + case "A256KW": { + const bytes = alg === "A128KW" ? 16 : alg === "A192KW" ? 24 : 32; + const key = yield* importAesKw(randomBytes(bytes)); + return { encryptKey: key, decryptKey: key }; + } + case "A128GCMKW": + case "A192GCMKW": + case "A256GCMKW": { + const bytes = alg === "A128GCMKW" ? 16 : alg === "A192GCMKW" ? 24 : 32; + const key = yield* importAesGcm(randomBytes(bytes)); + return { encryptKey: key, decryptKey: key }; + } + case "ECDH-ES": + case "ECDH-ES+A128KW": + case "ECDH-ES+A192KW": + case "ECDH-ES+A256KW": { + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) + ); + return { encryptKey: pair.publicKey, decryptKey: pair.privateKey }; + } + case "PBES2-HS256+A128KW": + case "PBES2-HS384+A192KW": + case "PBES2-HS512+A256KW": { + const key = yield* importPbkdf2(new TextEncoder().encode("correct horse battery staple")); + return { encryptKey: key, decryptKey: key }; + } + } + }); + +const algorithms: ReadonlyArray<(typeof Jwa.JweAlgorithm)["Type"]> = [ + "dir", + "RSA-OAEP", + "RSA-OAEP-256", + "A128KW", + "A192KW", + "A256KW", + "A128GCMKW", + "A192GCMKW", + "A256GCMKW", + "ECDH-ES", + "ECDH-ES+A128KW", + "ECDH-ES+A192KW", + "ECDH-ES+A256KW", + "PBES2-HS256+A128KW", + "PBES2-HS384+A192KW", + "PBES2-HS512+A256KW", +]; + +const plaintext = "The true sign of intelligence is not knowledge but imagination."; + +for (const alg of algorithms) { + it.live(`round-trips ${alg} with every content encryption algorithm`, () => + Effect.gen(function* () { + for (const enc of encryptions) { + const { decryptKey, encryptKey } = yield* keysFor(alg, enc); + const jwe = yield* Jwe.encrypt({ + plaintext, + key: encryptKey, + algorithm: alg, + encryption: enc, + // keep PBES2 fast in tests + p2c: 1000, + }); + const parts = jwe.split("."); + expect(parts.length, `${alg}/${enc} is not a 5-part compact JWE`).toBe(5); + + const result = yield* Jwe.decrypt({ jwe, key: decryptKey }); + expect(decode(result.plaintext), `${alg}/${enc} did not round-trip`).toBe(plaintext); + expect(result.protectedHeader.alg).toBe(alg); + expect(result.protectedHeader.enc).toBe(enc); + } + }) + ); +} + +it.live("carries extra protected header parameters", () => + Effect.gen(function* () { + const key = yield* importAesGcm(randomBytes(16)); + const jwe = yield* Jwe.encrypt({ + plaintext, + key, + algorithm: "A128GCMKW", + encryption: "A128GCM", + protectedHeader: { kid: "key-1", cty: "text/plain" }, + }); + const result = yield* Jwe.decrypt({ jwe, key }); + expect(result.protectedHeader.kid).toBe("key-1"); + expect(result.protectedHeader.cty).toBe("text/plain"); + }) +); + +it.live("rejects a tampered ciphertext", () => + Effect.gen(function* () { + const key = yield* importAesGcm(randomBytes(32)); + const jwe = yield* Jwe.encrypt({ plaintext, key, algorithm: "A256GCMKW", encryption: "A256GCM" }); + const parts = jwe.split("."); + // flip a character in the ciphertext segment + const ct = parts[3]; + parts[3] = ct.slice(0, -2) + (ct.at(-2) === "A" ? "B" : "A") + ct.slice(-1); + const error = yield* Effect.flip(Jwe.decrypt({ jwe: parts.join("."), key })); + expect(error.reason).toBe("DecryptionFailed"); + }) +); + +it.live("rejects a tampered CBC-HMAC tag", () => + Effect.gen(function* () { + const key = yield* importAesKw(randomBytes(16)); + const jwe = yield* Jwe.encrypt({ plaintext, key, algorithm: "A128KW", encryption: "A128CBC-HS256" }); + const parts = jwe.split("."); + const tag = parts[4]; + parts[4] = tag.slice(0, -2) + (tag.at(-2) === "A" ? "B" : "A") + tag.slice(-1); + const error = yield* Effect.flip(Jwe.decrypt({ jwe: parts.join("."), key })); + expect(error.reason).toBe("DecryptionFailed"); + }) +); + +it.live("fails to decrypt with the wrong key", () => + Effect.gen(function* () { + const good = yield* keysFor("RSA-OAEP", "A256GCM"); + const other = yield* keysFor("RSA-OAEP", "A256GCM"); + const jwe = yield* Jwe.encrypt({ + plaintext, + key: good.encryptKey, + algorithm: "RSA-OAEP", + encryption: "A256GCM", + }); + const error = yield* Effect.flip(Jwe.decrypt({ jwe, key: other.decryptKey })); + expect(["DecryptionFailed", "KeyManagementFailed"]).toContain(error.reason); + }) +); + +// RFC 7516 Appendix A.3: A128KW + A128CBC-HS256. This is external ground +// truth for the composite AES-CBC-HMAC content decryption and AES key +// unwrap against the exact bytes produced by the spec authors. +it.live("decrypts the RFC 7516 A.3 vector", () => + Effect.gen(function* () { + // JWK { kty: "oct", k: "GawgguFyGrWKav7AX4VKUg" } + const rawKey = Uint8Array.from( + atob("GawgguFyGrWKav7AX4VKUg".replace(/-/g, "+").replace(/_/g, "/") + "=="), + (c) => c.charCodeAt(0) + ); + const key = yield* importAesKw(rawKey); + const jwe = + "eyJhbGciOiJBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0." + + "6KB707dM9YTIgHtLvtgWQ8mKwboJW3of9locizkDTHzBC2IlrT1oOQ." + + "AxY8DCtDaGlsbGljb3RoZQ." + + "KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY." + + "U0m_YmjN04DJvceFICbCVQ"; + const result = yield* Jwe.decrypt({ jwe, key }); + expect(decode(result.plaintext)).toBe("Live long and prosper."); + expect(result.protectedHeader.alg).toBe("A128KW"); + expect(result.protectedHeader.enc).toBe("A128CBC-HS256"); + }) +); diff --git a/test/Security.test.ts b/test/Security.test.ts index c53882f..93aaf69 100644 --- a/test/Security.test.ts +++ b/test/Security.test.ts @@ -1,7 +1,7 @@ import { Effect, Schema } from "effect"; import { expect, it } from "@effect/vitest"; -import { Jwk, Jws, Jwt } from "effect-oidc"; +import { Jwe, Jwk, Jws, Jwt } from "effect-oidc"; const claims = { iss: "iss", sub: "sub", aud: "aud", exp: 9999999999, iat: 1 }; @@ -96,3 +96,165 @@ it.live("still decodes a d-only RSA private key", () => expect(decoded).toStrictEqual(dOnly); }) ); + +const importAesKw = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.importKey("raw", bytes, "AES-KW", false, ["wrapKey", "unwrapKey"])); +const importPbkdf2 = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.importKey("raw", bytes, "PBKDF2", false, ["deriveBits"])); +const rnd = (n: number) => crypto.getRandomValues(new Uint8Array(n)); +const b64 = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + +it.live("bounds the PBES2 iteration count on JWE decrypt (DoS guard)", () => + Effect.gen(function* () { + const key = yield* importPbkdf2(new TextEncoder().encode("pw")); + // craft a token with an enormous p2c by editing the header of a real one + const jwe = yield* Jwe.encrypt({ + plaintext: "secret", + key, + algorithm: "PBES2-HS256+A128KW", + encryption: "A128GCM", + p2c: 1000, + }); + const parts = jwe.split("."); + const header = JSON.parse( + new TextDecoder().decode( + Uint8Array.from(atob(parts[0].replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)) + ) + ); + header.p2c = 100_000_000; + parts[0] = b64(new TextEncoder().encode(JSON.stringify(header))); + const error = yield* Effect.flip(Jwe.decrypt({ jwe: parts.join("."), key })); + expect(error.reason).toBe("Malformed"); + }) +); + +it.live("enforces JWE key-management and content-encryption allowlists", () => + Effect.gen(function* () { + const key = yield* importAesKw(rnd(16)); + const jwe = yield* Jwe.encrypt({ plaintext: "hi", key, algorithm: "A128KW", encryption: "A128GCM" }); + + const badAlg = yield* Effect.flip(Jwe.decrypt({ jwe, key, keyManagementAlgorithms: ["RSA-OAEP"] })); + expect(badAlg.reason).toBe("UnsupportedAlgorithm"); + + const badEnc = yield* Effect.flip(Jwe.decrypt({ jwe, key, contentEncryptionAlgorithms: ["A256GCM"] })); + expect(badEnc.reason).toBe("UnsupportedAlgorithm"); + + // allowlist that matches still works + const ok = yield* Jwe.decrypt({ + jwe, + key, + keyManagementAlgorithms: ["A128KW"], + contentEncryptionAlgorithms: ["A128GCM"], + }); + expect(new TextDecoder().decode(ok.plaintext)).toBe("hi"); + }) +); + +it.live("rejects a JWE with an unrecognized crit header (RFC 7516 4.1.13)", () => + Effect.gen(function* () { + const key = yield* importAesKw(rnd(16)); + const jwe = yield* Jwe.encrypt({ + plaintext: "hi", + key, + algorithm: "A128KW", + encryption: "A128GCM", + protectedHeader: { crit: ["exp"], exp: 1 }, + }); + const error = yield* Effect.flip(Jwe.decrypt({ jwe, key })); + expect(error.reason).toBe("UnsupportedAlgorithm"); + }) +); + +it.live("returns a typed Malformed error (not a defect) on malformed JWE base64url", () => + Effect.gen(function* () { + const key = yield* importAesKw(rnd(16)); + const jwe = yield* Jwe.encrypt({ plaintext: "hi", key, algorithm: "A128KW", encryption: "A128GCM" }); + const parts = jwe.split("."); + parts[3] = "@@@not-base64@@@"; + const error = yield* Effect.flip(Jwe.decrypt({ jwe: parts.join("."), key })); + expect(error.reason).toBe("Malformed"); + }) +); + +it.live("round-trips ECDH-ES with apu/apv bound into the KDF", () => + Effect.gen(function* () { + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]) + ); + const jwe = yield* Jwe.encrypt({ + plaintext: "agree", + key: pair.publicKey, + algorithm: "ECDH-ES", + encryption: "A128GCM", + apu: new TextEncoder().encode("Alice"), + apv: new TextEncoder().encode("Bob"), + }); + const result = yield* Jwe.decrypt({ jwe, key: pair.privateKey }); + expect(new TextDecoder().decode(result.plaintext)).toBe("agree"); + // apu/apv are carried in the protected header and bound into the KDF + expect(result.protectedHeader.apu).toBeDefined(); + expect(result.protectedHeader.apv).toBeDefined(); + }) +); + +it.live("rejects a dir key whose length does not match the enc CEK size", () => + Effect.gen(function* () { + // A128GCM needs a 16-byte CEK; give dir a 32-byte key + const key = yield* Effect.promise(() => + crypto.subtle.importKey("raw", rnd(32), { name: "HMAC", hash: "SHA-256" }, true, ["sign"]) + ); + const error = yield* Effect.flip( + Jwe.encrypt({ plaintext: "hi", key, algorithm: "dir", encryption: "A128GCM" }) + ); + expect(error.reason).toBe("KeyManagementFailed"); + }) +); + +it.live("rejects a wrong-length CEK (typed error, not a defect)", () => + Effect.gen(function* () { + // An attacker with the recipient's RSA public key can RSA-OAEP-encrypt + // a CEK of the wrong length. It must fail closed as DecryptionFailed, + // never reach AES importKey and crash as an unhandled defect. + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey( + { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-1" }, + true, + ["encrypt", "decrypt"] + ) + ); + // A128GCM needs a 16-byte CEK; wrap a 20-byte one instead + const badCek = rnd(20); + const wrapped = new Uint8Array( + yield* Effect.promise(() => crypto.subtle.encrypt({ name: "RSA-OAEP" }, pair.publicKey, badCek)) + ); + const header = b64(new TextEncoder().encode(JSON.stringify({ alg: "RSA-OAEP", enc: "A128GCM" }))); + const jwe = [header, b64(wrapped), b64(rnd(12)), b64(rnd(8)), b64(rnd(16))].join("."); + const error = yield* Effect.flip(Jwe.decrypt({ jwe, key: pair.privateKey })); + expect(error.reason).toBe("DecryptionFailed"); + }) +); + +it.live("returns a typed error (not a defect) when a dir key cannot be exported", () => + Effect.gen(function* () { + // An attacker picks alg:"dir" but the recipient key is an RSA private + // key, which crypto.subtle.exportKey("raw", ...) rejects. That must fail + // closed as a typed KeyManagementFailed, never surface as a defect. + // Effect.flip only completes for a typed failure, so its success here + // proves the branch does not die. + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey( + { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["encrypt", "decrypt"] + ) + ); + const header = b64(new TextEncoder().encode(JSON.stringify({ alg: "dir", enc: "A128GCM" }))); + const jwe = [header, "", b64(rnd(12)), b64(rnd(8)), b64(rnd(16))].join("."); + const error = yield* Effect.flip(Jwe.decrypt({ jwe, key: pair.privateKey })); + expect(error.reason).toBe("KeyManagementFailed"); + }) +);