diff --git a/.changeset/tame-pianos-sing.md b/.changeset/tame-pianos-sing.md new file mode 100644 index 00000000000..c848bca51a8 --- /dev/null +++ b/.changeset/tame-pianos-sing.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add unstable JOSE modules (Jwa, Jwk, Jws, Jwt, Jwe) diff --git a/packages/effect/src/unstable/jose/Jwa.ts b/packages/effect/src/unstable/jose/Jwa.ts new file mode 100644 index 00000000000..9cad5846bd0 --- /dev/null +++ b/packages/effect/src/unstable/jose/Jwa.ts @@ -0,0 +1,184 @@ +/** + * JSON Web Algorithms (JWA) schemas based on RFC 7518. + * + * This module defines the cryptographic algorithm identifiers used for JWS + * digital signatures and MACs (RFC 7518 Section 3), along with the WebCrypto + * parameter sets needed to import keys and to sign/verify with each + * algorithm. Those two parameter sets differ (e.g. ECDSA import needs + * `namedCurve` while signing needs `hash`), so they are exposed separately. + * + * @since 4.0.0 + */ +import * as Match from "../../Match.ts" +import * as Schema from "../../Schema.ts" + +/** + * JWS algorithm values as defined in RFC 7518 Section 3.1. These algorithms + * are used for digital signatures and MACs to secure the JWS. The "none" + * algorithm is intentionally unsupported. + * + * @category JWS + * @since 4.0.0 + */ +export const JwsAlgorithm = Schema.Literals([ + // HMAC with SHA-2 Functions + "HS256", // HMAC using SHA-256 - Required + "HS384", // HMAC using SHA-384 - Optional + "HS512", // HMAC using SHA-512 - Optional + + // Digital Signature with RSASSA-PKCS1-v1_5 + "RS256", // RSASSA-PKCS1-v1_5 using SHA-256 - Recommended + "RS384", // RSASSA-PKCS1-v1_5 using SHA-384 - Optional + "RS512", // RSASSA-PKCS1-v1_5 using SHA-512 - Optional + + // Digital Signature with ECDSA + "ES256", // ECDSA using P-256 and SHA-256 - Recommended+ + "ES384", // ECDSA using P-384 and SHA-384 - Optional + "ES512", // ECDSA using P-521 and SHA-512 - Optional + + // Digital Signature with RSASSA-PSS + "PS256", // RSASSA-PSS using SHA-256 and MGF1 with SHA-256 - Optional + "PS384", // RSASSA-PSS using SHA-384 and MGF1 with SHA-384 - Optional + "PS512" // RSASSA-PSS using SHA-512 and MGF1 with SHA-512 - Optional +]).annotate({ + title: "JWS Algorithm", + expected: "a JWS algorithm identifier string", + description: "Algorithm used for digital signatures and MACs in JWS as defined in RFC 7518 Section 3.1" +}) + +/** + * WebCrypto parameters for `crypto.subtle.importKey` for each JWS algorithm. + * + * @category WebCrypto + * @since 4.0.0 + */ +export const importParameters = Match.type<(typeof JwsAlgorithm)["Type"]>().pipe( + Match.when("HS256", () => ({ name: "HMAC", hash: "SHA-256" }) as HmacImportParams), + Match.when("HS384", () => ({ name: "HMAC", hash: "SHA-384" }) as HmacImportParams), + Match.when("HS512", () => ({ name: "HMAC", hash: "SHA-512" }) as HmacImportParams), + Match.when("RS256", () => ({ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }) as RsaHashedImportParams), + Match.when("RS384", () => ({ name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }) as RsaHashedImportParams), + Match.when("RS512", () => ({ name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }) as RsaHashedImportParams), + Match.when("ES256", () => ({ name: "ECDSA", namedCurve: "P-256" }) as EcKeyImportParams), + Match.when("ES384", () => ({ name: "ECDSA", namedCurve: "P-384" }) as EcKeyImportParams), + Match.when("ES512", () => ({ name: "ECDSA", namedCurve: "P-521" }) as EcKeyImportParams), + Match.when("PS256", () => ({ name: "RSA-PSS", hash: "SHA-256" }) as RsaHashedImportParams), + Match.when("PS384", () => ({ name: "RSA-PSS", hash: "SHA-384" }) as RsaHashedImportParams), + Match.when("PS512", () => ({ name: "RSA-PSS", hash: "SHA-512" }) as RsaHashedImportParams), + Match.exhaustive +) + +/** + * WebCrypto parameters for `crypto.subtle.sign`/`crypto.subtle.verify` for + * each JWS algorithm. + * + * @category WebCrypto + * @since 4.0.0 + */ +export const signatureParameters = Match.type<(typeof JwsAlgorithm)["Type"]>().pipe( + Match.whenOr("HS256", "HS384", "HS512", () => ({ name: "HMAC" }) as AlgorithmIdentifier), + Match.whenOr("RS256", "RS384", "RS512", () => ({ name: "RSASSA-PKCS1-v1_5" }) as AlgorithmIdentifier), + Match.when("ES256", () => ({ name: "ECDSA", hash: "SHA-256" }) as EcdsaParams), + Match.when("ES384", () => ({ name: "ECDSA", hash: "SHA-384" }) as EcdsaParams), + Match.when("ES512", () => ({ name: "ECDSA", hash: "SHA-512" }) as EcdsaParams), + Match.when("PS256", () => ({ name: "RSA-PSS", saltLength: 32 }) as RsaPssParams), + Match.when("PS384", () => ({ name: "RSA-PSS", saltLength: 48 }) as RsaPssParams), + 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. + * + * @category JWE + * @since 4.0.0 + */ +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. + * + * @category JWE + * @since 4.0.0 + */ +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. + * + * @category JWE + * @since 4.0.0 + */ +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/packages/effect/src/unstable/jose/Jwe.ts b/packages/effect/src/unstable/jose/Jwe.ts new file mode 100644 index 00000000000..aa32e64dafe --- /dev/null +++ b/packages/effect/src/unstable/jose/Jwe.ts @@ -0,0 +1,715 @@ +/** + * 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 4.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 * as Data from "../../Data.ts" +import * as Effect from "../../Effect.ts" +import * as Schema from "../../Schema.ts" +import * as SchemaGetter from "../../SchemaGetter.ts" +import { encryptionParameters, JweAlgorithm, JweEncryption } from "./Jwa.ts" +import { Jwk } from "./Jwk.ts" + +const textEncoder = new TextEncoder() + +/** + * @internal + * 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). + */ +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. + * + * @category Schema + * @since 4.0.0 + */ +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) + * + * @category Schema + * @since 4.0.0 + */ +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. + * + * @category Errors + * @since 4.0.0 + */ +export type JweErrorReason = + | "Malformed" + | "UnsupportedAlgorithm" + | "KeyManagementFailed" + | "DecryptionFailed" + +/** + * @category Errors + * @since 4.0.0 + */ +export class JweError extends Data.TaggedError("JweError")<{ + readonly reason: JweErrorReason + readonly cause?: unknown +}> {} + +/** @internal Encodes bytes as an unpadded base64url string. */ +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, "_") +} + +/** @internal Decodes an unpadded base64url string to bytes. */ +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 +} + +/** @internal 64-bit big-endian encoding of a bit length. */ +const uint64BE = (value: number): Uint8Array => { + const out = new Uint8Array(8) + new DataView(out.buffer).setBigUint64(0, BigInt(value), false) + return out +} + +/** @internal Constant-time byte comparison (length is not treated as secret). */ +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 +} + +const die = (reason: JweErrorReason) => (cause: unknown) => new JweError({ reason, cause }) + +/** @internal Decodes attacker-supplied base64url, mapping `atob` throws to a typed Malformed error. */ +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 + +// ------------------------------------------------------------------------------------- +// Content encryption +// ------------------------------------------------------------------------------------- + +type EncParams = ReturnType + +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) } +}) + +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) +}) + +// ------------------------------------------------------------------------------------- +// Key management: Concat KDF + AES-KW helpers +// ------------------------------------------------------------------------------------- + +/** @internal RFC 7518 Section 4.6.2 Concat KDF specialised to SHA-256. */ +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) +}) + +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"))) + }) + +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") }) + ) + }) + +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 } +} + +const aesKwBits = (alg: (typeof JweAlgorithm)["Type"]): 128 | 192 | 256 => + alg.includes("128") ? 128 : alg.includes("192") ? 192 : 256 + +// ------------------------------------------------------------------------------------- +// Key management: encrypt / decrypt the CEK +// ------------------------------------------------------------------------------------- + +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 } } + } + } +}) + +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 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: header.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) + } + } +}) + +// ------------------------------------------------------------------------------------- +// Public API +// ------------------------------------------------------------------------------------- + +/** + * 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`. + * + * @category Encryption + * @since 4.0.0 + */ +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). + * + * @category Decryption + * @since 4.0.0 + */ +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/packages/effect/src/unstable/jose/Jwk.ts b/packages/effect/src/unstable/jose/Jwk.ts new file mode 100644 index 00000000000..8c36edb20b7 --- /dev/null +++ b/packages/effect/src/unstable/jose/Jwk.ts @@ -0,0 +1,397 @@ +/** + * JSON Web Key (JWK) schemas based on RFC 7517 and RFC 7518 Section 6. + * + * This module provides Effect Schema definitions for representing + * cryptographic keys as JSON objects, including key-type-specific parameters + * for EC, RSA, and symmetric (oct) keys, as well as the JWK Set format. + * + * Binary-valued members (coordinates, exponents, key values) are kept in + * their base64url wire form: they encode raw bytes, not UTF-8 text, and the + * base64url form is exactly what `crypto.subtle.importKey("jwk", ...)` + * expects. + * + * @since 4.0.0 + */ +import * as Schema from "../../Schema.ts" +import { JwsAlgorithm } from "./Jwa.ts" + +/** + * JWK "kty" (Key Type) parameter values as defined in RFC 7518 Section 6.1. + * + * @category Key Type + * @since 4.0.0 + */ +export const KeyType = Schema.Literals([ + "EC", // Elliptic Curve [DSS] - Recommended+ + "RSA", // RSA [RFC3447] - Required + "oct" // Octet sequence (symmetric keys) - Required +]).annotate({ + title: "JWK Key Type", + expected: "the 'kty' parameter of a JWK, indicating the key type", + description: "Cryptographic algorithm family used with the key as defined in RFC 7518 Section 6.1" +}) + +/** + * JWK Public Key Use parameter values as defined in RFC 7517 Section 4.2. + * + * @category Key Use + * @since 4.0.0 + */ +export const KeyUse = Schema.Literals([ + "sig", // Digital Signature or MAC + "enc" // Encryption +]).annotate({ + title: "Public Key Use", + expected: "the 'use' parameter of a JWK, indicating the intended use of the key", + description: "Intended use of the public key: signature or encryption" +}) + +/** + * JWK Key Operations parameter values as defined in RFC 7517 Section 4.3. + * These values intentionally match the Web Cryptography API KeyUsage values. + * + * @category Key Operations + * @since 4.0.0 + */ +export const KeyOperation = Schema.Literals([ + "sign", // Compute digital signature or MAC + "verify", // Verify digital signature or MAC + "encrypt", // Encrypt content + "decrypt", // Decrypt content and validate decryption, if applicable + "wrapKey", // Encrypt key + "unwrapKey", // Decrypt key and validate decryption, if applicable + "deriveKey", // Derive key + "deriveBits" // Derive bits not to be used as a key +]).annotate({ + title: "Key Operation", + expected: "the 'key_ops' parameter of a JWK, indicating the operations for which the key is intended to be used", + description: "Operation for which the key is intended to be used as defined in RFC 7517 Section 4.3" +}) + +/** + * JWK Curve parameter values for Elliptic Curve keys as defined in RFC 7518 + * Section 6.2.1.1. + * + * @category Elliptic Curve + * @since 4.0.0 + */ +export const EllipticCurve = Schema.Literals([ + "P-256", // NIST P-256 Curve - Recommended+ + "P-384", // NIST P-384 Curve - Optional + "P-521" // NIST P-521 Curve - Optional +]).annotate({ + title: "Elliptic Curve", + expected: "the 'crv' parameter of an EC JWK, indicating the curve used", + description: "Cryptographic curve used with the key as defined in RFC 7518 Section 6.2.1.1" +}) + +/** + * Common JWK parameters shared across all key types as defined in RFC 7517 + * Section 4. + * + * @internal + */ +const JwkCommonFields = Schema.Struct({ + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.2 */ + use: Schema.optional(KeyUse), + + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.3 */ + key_ops: Schema.optional(Schema.Array(KeyOperation)), + + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.4 */ + alg: Schema.optional(JwsAlgorithm), + + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.5 */ + kid: Schema.optional(Schema.String), + + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.6 */ + x5u: Schema.optional(Schema.String), + + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.7 */ + x5c: Schema.optional(Schema.Array(Schema.String)), + + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.8 */ + x5t: Schema.optional(Schema.String), + + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-4.9 */ + "x5t#S256": Schema.optional(Schema.String) +}) + +/** + * An Elliptic Curve public key represented as a JWK. + * + * Members "kty", "crv", "x", and "y" are required for EC public keys. + * + * @category Elliptic Curve + * @since 4.0.0 + */ +export const EcPublicKey = Schema.Struct({ + /** Key Type — MUST be "EC" */ + kty: Schema.Literal("EC"), + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.1 */ + crv: EllipticCurve, + + /** + * "x" (X Coordinate) Base64urlUInt-encoded x coordinate. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.2 + */ + x: Schema.String, + + /** + * "y" (Y Coordinate) Base64urlUInt-encoded y coordinate. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.3 + */ + y: Schema.String, + + ...JwkCommonFields.fields +}).annotate({ + title: "EC Public Key", + expected: "a JWK with 'kty' of 'EC' representing an Elliptic Curve public key", + description: "An Elliptic Curve public key as defined in RFC 7518 Section 6.2.1" +}) + +/** + * An Elliptic Curve private key represented as a JWK. Extends the public key + * with the private key parameter "d". + * + * @category Elliptic Curve + * @since 4.0.0 + */ +export const EcPrivateKey = Schema.Struct({ + ...EcPublicKey.fields, + + /** + * "d" (ECC Private Key) Base64urlUInt-encoded private key value. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-6.2.2.1 + */ + d: Schema.String +}).annotate({ + title: "EC Private Key", + expected: "a JWK with 'kty' of 'EC' representing an Elliptic Curve private key", + description: "An Elliptic Curve private key as defined in RFC 7518 Section 6.2.2" +}) + +/** + * Represents information about additional primes (beyond the first two) in a + * multi-prime RSA key. + * + * @internal + */ +const OtherPrimeInfo = Schema.Struct({ + /** "r" (Prime Factor) */ + r: Schema.String, + + /** "d" (Factor CRT Exponent) */ + d: Schema.String, + + /** "t" (Factor CRT Coefficient) */ + t: Schema.String +}) + +/** + * An RSA public key represented as a JWK. + * + * Members "kty", "n", and "e" are required for RSA public keys. + * + * @category RSA + * @since 4.0.0 + */ +export const RsaPublicKey = Schema.Struct({ + /** Key Type — MUST be "RSA" */ + kty: Schema.Literal("RSA"), + + /** + * "n" (Modulus) Base64urlUInt-encoded modulus value. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.1 + */ + n: Schema.String, + + /** + * "e" (Exponent) Base64urlUInt-encoded exponent value. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.2 + */ + e: Schema.String, + + ...JwkCommonFields.fields +}).annotate({ + title: "RSA Public Key", + expected: "a JWK with 'kty' of 'RSA' representing an RSA public key", + description: "An RSA public key as defined in RFC 7518 Section 6.3.1" +}) + +/** + * An RSA private key represented as a JWK. Extends the public key with + * private key parameters. The "d" parameter is required; the CRT parameters + * ("p", "q", "dp", "dq", "qi") should be included together — if any one of + * them is present then all of them must be present, which the union encodes. + * + * @category RSA + * @since 4.0.0 + */ +export const RsaPrivateKey = Schema.Union([ + // The full CRT form must come first: a JWK carrying the CRT parameters also + // structurally satisfies the d-only member, and Struct decoding drops + // unlisted fields, so a d-only-first union would silently discard the CRT + // parameters of a complete private key. + Schema.Struct({ + ...RsaPublicKey.fields, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.1 */ + d: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.2 */ + p: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.3 */ + q: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.4 */ + dp: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.5 */ + dq: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.6 */ + qi: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7 */ + oth: Schema.optional(Schema.Array(OtherPrimeInfo)) + }), + Schema.Struct({ + ...RsaPublicKey.fields, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.1 */ + d: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7 */ + oth: Schema.optional(Schema.Array(OtherPrimeInfo)) + }) +]).annotate({ + title: "RSA Private Key", + expected: "a JWK with 'kty' of 'RSA' representing an RSA private key", + description: "An RSA private key as defined in RFC 7518 Section 6.3.2" +}) + +/** + * A symmetric key (octet sequence) represented as a JWK. + * + * Members "kty" and "k" are required for symmetric keys. + * + * @category Symmetric + * @since 4.0.0 + */ +export const OctKey = Schema.Struct({ + /** Key Type — MUST be "oct" */ + kty: Schema.Literal("oct"), + + /** + * "k" (Key Value) Base64url-encoded key value. + * + * @see https://www.rfc-editor.org/rfc/rfc7518#section-6.4.1 + */ + k: Schema.String, + + ...JwkCommonFields.fields +}).annotate({ + title: "Symmetric Key", + expected: "a JWK with 'kty' of 'oct' representing a symmetric (octet sequence) key", + description: "A symmetric (octet sequence) key as defined in RFC 7518 Section 6.4" +}) + +/** + * A JSON Web Key (JWK) as defined in RFC 7517. This is a discriminated union + * over the "kty" field, supporting EC, RSA, and symmetric (oct) key types. + * + * The union includes both public and private key representations — consumers + * can narrow using the individual schemas (e.g. `EcPublicKey`, + * `RsaPrivateKey`) when a specific key form is expected. Private forms come + * first so that keys carrying private members decode as private keys. + * + * @category JWK + * @since 4.0.0 + */ +export const Jwk = Schema.Union([EcPrivateKey, EcPublicKey, RsaPrivateKey, RsaPublicKey, OctKey]).annotate({ + title: "JSON Web Key", + expected: "a JSON object representing a cryptographic key, with the 'kty' parameter indicating the key type", + description: "A JSON Web Key as defined in RFC 7517, discriminated by the 'kty' parameter" +}) + +/** + * A JWK Set as defined in RFC 7517 Section 5. A JSON object that represents + * a set of JWKs. The "keys" member is required and must be an array of JWKs. + * + * @category JWK Set + * @since 4.0.0 + */ +export const JwkSet = Schema.Struct({ + /** @see https://www.rfc-editor.org/rfc/rfc7517#section-5.1 */ + keys: Schema.Array(Jwk) +}).annotate({ + title: "JWK Set", + expected: "a JSON object with a 'keys' member containing an array of JWKs", + description: "A set of JSON Web Keys as defined in RFC 7517 Section 5" +}) + +/** + * Returns whether a JWK may be used to verify a signature under the given JWS + * algorithm: the key type (and EC curve) must match the algorithm family, and + * a key explicitly marked for encryption (`use: "enc"`) is rejected. Gate key + * selection with this so a token cannot steer a key of one family into an + * incompatible algorithm — e.g. verifying an `RS256` token against an `oct` + * HMAC key (the classic asymmetric/symmetric confusion), which WebCrypto's + * import step alone does not prevent when the key set is attacker-influenced. + * + * @category Compatibility + * @since 4.0.0 + */ +export const isCompatibleWith = ( + alg: (typeof JwsAlgorithm)["Type"], + jwk: (typeof Jwk)["Type"] +): boolean => { + if (jwk.use === "enc") return false + switch (alg) { + case "ES256": + return jwk.kty === "EC" && jwk.crv === "P-256" + case "ES384": + return jwk.kty === "EC" && jwk.crv === "P-384" + case "ES512": + return jwk.kty === "EC" && jwk.crv === "P-521" + case "RS256": + case "RS384": + case "RS512": + case "PS256": + case "PS384": + case "PS512": + return jwk.kty === "RSA" + case "HS256": + case "HS384": + case "HS512": + return jwk.kty === "oct" + } +} + +/** + * Returns whether a JWK is a symmetric (secret) key. Such keys must never be + * accepted from an untrusted source (e.g. a token's `jku`/`jwk` header) as a + * signature-verification key, as that enables signature forgery. + * + * @category Compatibility + * @since 4.0.0 + */ +export const isSymmetric = (jwk: (typeof Jwk)["Type"]): boolean => jwk.kty === "oct" + +/** + * Returns whether a JWK carries private key material (`d` for EC/RSA). A + * public verification key never does; presence of private material in a key + * pulled from an untrusted source indicates misuse and should be rejected. + * + * @category Compatibility + * @since 4.0.0 + */ +export const isPrivate = (jwk: (typeof Jwk)["Type"]): boolean => (jwk.kty === "EC" || jwk.kty === "RSA") && "d" in jwk diff --git a/packages/effect/src/unstable/jose/Jws.ts b/packages/effect/src/unstable/jose/Jws.ts new file mode 100644 index 00000000000..ea91a1c076a --- /dev/null +++ b/packages/effect/src/unstable/jose/Jws.ts @@ -0,0 +1,820 @@ +/** + * JSON Web Signature (JWS) schemas based on RFC 7515. + * + * This module provides Effect Schema definitions for JWS structures, which + * represent content secured with digital signatures or Message Authentication + * Codes (MACs) using JSON-based data structures. All three serializations are + * supported (Compact, Flattened JSON, General JSON), along with signing and + * verification built on WebCrypto, extensible critical headers with + * compile-time key validation, and schema combinators ({@link Verified}, + * {@link Signed}) that treat signing/verification as schema transformations. + * + * Keys embedded in the token itself (`jwk` and `jku` header parameters) are + * IGNORED during verification unless explicitly opted into — an attacker can + * put any key they control in those headers, so trusting them by default + * would make signature verification meaningless for authentication use. + * + * @since 4.0.0 + */ +import type { NonEmptyReadonlyArray } from "../../Array.ts" +import * as Arr from "../../Array.ts" +import type * as Brand from "../../Brand.ts" +import * as Data from "../../Data.ts" +import * as Effect from "../../Effect.ts" +import { flow } from "../../Function.ts" +import * as Option from "../../Option.ts" +import * as Schema from "../../Schema.ts" +import * as SchemaGetter from "../../SchemaGetter.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import type * as Struct from "../../Struct.ts" +import * as Tuple from "../../Tuple.ts" +import * as VariantSchema from "../schema/VariantSchema.ts" +import { importParameters, JwsAlgorithm, signatureParameters } from "./Jwa.ts" +import { isCompatibleWith, isPrivate, isSymmetric, Jwk, JwkSet } from "./Jwk.ts" + +const joseVariantSchema = VariantSchema.make({ + variants: ["protected", "unprotected"], + defaultVariant: "protected" +}) + +/** + * JOSE Header for JWS as defined in RFC 7515 Section 4. The JOSE Header + * describes the cryptographic operations applied to the JWS Protected Header + * and the JWS Payload. + * + * This schema is extensible — additional public and private header parameters + * are permitted per RFC 7515 Sections 4.2 and 4.3. + * + * @category JOSE Header + * @since 4.0.0 + */ +export const JoseHeader = joseVariantSchema.Struct({ + /** + * "alg" (Algorithm) Header Parameter - REQUIRED Identifies the + * cryptographic algorithm used to secure the JWS. + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.1 + */ + alg: JwsAlgorithm.pipe( + joseVariantSchema.fieldEvolve({ + unprotected: (algSchema) => Schema.optional(algSchema) + }) + ), + + /** + * "jku" (JWK Set URL) Header Parameter - OPTIONAL A URI that refers to a + * resource for a set of JSON-encoded public keys. + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.2 + */ + jku: Schema.String.pipe(Schema.optional), + + /** + * "jwk" (JSON Web Key) Header Parameter - OPTIONAL The public key that + * corresponds to the key used to digitally sign the JWS. + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.3 + */ + jwk: Jwk.pipe(Schema.optional), + + /** + * "kid" (Key ID) Header Parameter - OPTIONAL A hint indicating which key + * was used to secure the JWS. + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.4 + */ + kid: Schema.String.pipe(Schema.optional), + + /** + * "x5u" (X.509 URL) Header Parameter - OPTIONAL + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.5 + */ + x5u: Schema.String.pipe(Schema.optional), + + /** + * "x5c" (X.509 Certificate Chain) Header Parameter - OPTIONAL + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.6 + */ + x5c: Schema.Array(Schema.String).pipe(Schema.optional), + + /** + * "x5t" (X.509 Certificate SHA-1 Thumbprint) Header Parameter - OPTIONAL + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.7 + */ + x5t: Schema.String.pipe(Schema.optional), + + /** + * "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header Parameter - + * OPTIONAL + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.8 + */ + "x5t#S256": Schema.String.pipe(Schema.optional), + + /** + * "typ" (Type) Header Parameter - OPTIONAL (RECOMMENDED to be "JWT" for + * JWTs) Used to declare the media type of this complete JWS. + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.9 + */ + typ: Schema.String.pipe(Schema.optional), + + /** + * "cty" (Content Type) Header Parameter - OPTIONAL Used to declare the + * media type of the secured content (the payload). For nested JWTs, this + * MUST be "JWT". + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.10 + */ + cty: Schema.String.pipe(Schema.optional), + + /** + * "crit" (Critical) Header Parameter - OPTIONAL Indicates that extensions + * are being used that MUST be understood and processed. + * + * @see https://www.rfc-editor.org/rfc/rfc7515#section-4.1.11 + */ + crit: Schema.Never.pipe(Schema.optionalKey, joseVariantSchema.FieldOnly(["protected"])) +}) + +/** + * The integrity-protected JOSE header variant. + * + * @category JOSE Header + * @since 4.0.0 + */ +export const JoseProtectedHeader = joseVariantSchema.extract(JoseHeader, "protected") + +/** + * The unprotected JOSE header variant, carried alongside JSON serializations. + * + * @category JOSE Header + * @since 4.0.0 + */ +export const JoseUnprotectedHeader = joseVariantSchema.extract(JoseHeader, "unprotected") + +/** + * Additional protected header parameters that callers may set when signing + * (everything except `alg`, which comes from the signing key entry, and + * `crit`, which is managed by the critical-header machinery). + * + * @category JOSE Header + * @since 4.0.0 + */ +export type ProtectedHeaderExtras = Omit<(typeof JoseProtectedHeader)["Type"], "alg" | "crit"> + +/** + * Type-level validation to prevent critical header keys from colliding with + * registered JOSE header parameters. Critical header keys must be distinct + * from any registered JOSE header parameter keys, as they would cause + * ambiguity in the JOSE header structure and violate the JWS specification. + * + * @category JOSE Header + * @since 4.0.0 + */ +export type ValidateCriticalHeaderKey = K extends + | keyof typeof JoseProtectedHeader.fields + | keyof typeof JoseUnprotectedHeader.fields + ? `${K} is a registered JOSE header parameter and cannot be used as a critical header key` + : {} + +/** + * Type-level validation applied to a whole record of critical headers. + * + * @category JOSE Header + * @since 4.0.0 + */ +export type ValidateCriticalHeaderKeys< + CriticalHeaders extends { + readonly [K in string]: Schema.Codec + } +> = { + [K in Extract]: ValidateCriticalHeaderKey +} + +/** + * Adds a critical extension header to a JoseHeader-like struct schema. Each + * call adds the field, and updates the `crit` field to be an exact tuple of + * all registered critical header key literals. + * + * @internal + */ +const joseHeaderWithCritical = >( + key: K & ValidateCriticalHeaderKey, + schema: S +) => { + type InputConstraint = + | typeof JoseProtectedHeader.fields + | { readonly crit: Schema.$Array>>> } + + type ValidateJoseHeaderAndKey = OldFields["crit"] extends Schema.$Array< + Schema.Union>> + > ? [OldCritKeys] extends [K] ? `Critical header key '${K}' already exists` + : {} + : {} + + return < + OldFields extends InputConstraint, + NewFields extends + & { + readonly [k in K]: S + } + & { + readonly crit: OldFields["crit"] extends Schema.$Array> + ? Schema.$Array]>> + : Schema.$Array>>> + } + >( + self: Schema.Struct & ValidateJoseHeaderAndKey + ): Schema.Struct>> => { + // Before the first augmentation `crit` is `optionalKey`; afterwards + // it is an array of a union of the registered key literals. + const crit: any = self.fields.crit + const critUnion: any = "value" in crit ? crit.value : undefined + const existingMembers: ReadonlyArray = critUnion !== undefined && "members" in critUnion + ? critUnion.members + : [] + + if (existingMembers.some((member) => member.literal === key)) { + const newFields = { [key]: schema } as unknown as NewFields + return self.pipe(Schema.fieldsAssign(newFields)) as never + } + + const keySchema = Schema.Literal(key) + const prevLength = existingMembers.length + + const critSchema = existingMembers.length > 0 + ? Schema.Array(critUnion.mapMembers(Tuple.appendElement(keySchema))) + : Schema.Array(Schema.Union([keySchema])) + + const newFields = { + [key]: schema, + crit: critSchema.check( + Schema.isUnique({ + message: "Duplicate critical header keys are not allowed" + }), + Schema.isMinLength(prevLength + 1, { + message: "All critical header keys should be present" + }) + ) + } as unknown as NewFields + + return self.pipe(Schema.fieldsAssign(newFields)) + } +} + +/** + * Adds a collection of critical extension headers to a JoseHeader-like struct + * schema. Each call adds the fields, and updates the `crit` field to be an + * exact tuple of all registered critical header key literals. + * + * @internal + */ +const joseHeaderWithCriticals = < + CriticalHeaders extends { + readonly [K in string]: Schema.Codec + } +>( + criticalHeaders: CriticalHeaders & ValidateCriticalHeaderKeys +) => { + type KeyLiterals = Extract + + type InputConstraint = + | typeof JoseProtectedHeader.fields + | { readonly crit: Schema.$Array>>> } + + type ValidateJoseHeaderAndKey = OldFields["crit"] extends Schema.$Array< + Schema.Union>> + > ? Extract extends never ? {} + : `Critical header key '${Extract}' already exists` + : {} + + return < + OldFields extends InputConstraint, + NewCritical extends KeyLiterals extends never ? OldFields["crit"] + : OldFields["crit"] extends Schema.$Array> + ? Schema.$Array]>> + : Schema.$Array>>>, + NewFields extends CriticalHeaders & { readonly crit: NewCritical } + >( + self: Schema.Struct & ValidateJoseHeaderAndKey + ): Schema.Struct>> => { + let schema = self + for (const [key, value] of Object.entries(criticalHeaders)) { + schema = schema.pipe(joseHeaderWithCritical(key, value) as any) + } + return schema as any + } +} + +/** + * General JWS JSON Serialization as defined in RFC 7515 Section 7.2.1. + * Supports multiple digital signatures and/or MACs for the same payload. + * + * @category JWS JSON Serialization + * @since 4.0.0 + */ +export class General extends Schema.Opaque>()( + Schema.Struct({ + unverifiedPayload: Schema.String, + signatures: Schema.NonEmptyArray( + Schema.Struct({ + protected: Schema.String, + signature: Schema.String, + header: JoseUnprotectedHeader.pipe(Schema.optional) + }) + ) + }) + .pipe( + Schema.encodeKeys({ + unverifiedPayload: "payload" + }) + ) + .annotate({ + title: "JWS General JSON Serialization (Unverified)", + expected: "a JWS General JSON Serialization object with unverified payload and signatures", + description: "A JWS in General JSON Serialization format with unverified payload and signatures." + }) +) {} + +/** + * Flattened JWS JSON Serialization as defined in RFC 7515 Section 7.2.2. + * Optimized for the single digital signature or MAC case — the "signatures" + * member is flattened into top-level "protected", "header", and "signature" + * members alongside "payload". + * + * @category JWS JSON Serialization + * @since 4.0.0 + */ +export class Flattened extends Schema.Opaque>()( + Schema.Struct({ + signature: Schema.String, + protected: Schema.String, + unverifiedPayload: Schema.String, + header: JoseUnprotectedHeader.pipe(Schema.optional) + }) + .pipe( + Schema.encodeKeys({ + unverifiedPayload: "payload" + }) + ) + .annotate({ + title: "JWS Flattened JSON Serialization (Unverified)", + expected: "a JWS Flattened JSON Serialization object with unverified payload and signature", + description: "A JWS in Flattened JSON Serialization format with unverified payload and signature." + }) +) {} + +/** + * JWS Compact Serialization as defined in RFC 7515 Section 7.1. Represents a + * compact, URL-safe string of the form: + * + * BASE64URL(UTF8(JWS Protected Header)) || '.' || + * BASE64URL(JWS Payload) || '.' || + * BASE64URL(JWS Signature) + * + * Only one signature/MAC is supported by the JWS Compact Serialization and it + * provides no syntax to represent a JWS Unprotected Header value. + * + * @category JWS Compact Serialization + * @since 4.0.0 + */ +export class Compact extends Schema.Opaque>()( + Schema.TemplateLiteralParser([Schema.String, Schema.Literal("."), Schema.String, Schema.Literal("."), Schema.String]) + .pipe( + Schema.decodeTo(Flattened, { + decode: SchemaGetter.transform(([protectedHeader, _dot1, payload, _dot2, signature]) => ({ + protected: protectedHeader, + payload, + signature + })), + encode: SchemaGetter.transformOrFail(({ header, payload, protected: protectedHeader, signature }) => + header === undefined + ? Effect.succeed([protectedHeader, ".", payload, ".", signature] as const) + : Effect.fail( + new SchemaIssue.InvalidValue(Option.none(), { + message: "Compact serialization does not support unprotected headers" + }) + ) + ) + }) + ) + .annotate({ + title: "JWS Compact Serialization (Unverified)", + expected: "a JWS Compact Serialization string with unverified payload and signature", + description: "A JWS in Compact Serialization format with unverified payload and signature." + }) +) {} + +/** + * Any unverified JWS serialization. + * + * @category JWS + * @since 4.0.0 + */ +export const Unsecured = Schema.Union([General, Flattened, Compact]) + +/** + * @category Errors + * @since 4.0.0 + */ +export class InvalidHeaders extends Data.TaggedError("InvalidHeaders")<{}> {} + +/** + * @category Errors + * @since 4.0.0 + */ +export class InvalidSignature extends Data.TaggedError("InvalidSignature")<{}> {} + +/** + * @category Errors + * @since 4.0.0 + */ +export class InvalidJws extends Data.TaggedError("InvalidJws")<{ reason: InvalidHeaders | InvalidSignature }> {} + +/** + * Builds a JWS verifier. Signatures are checked against the provided + * `publicKeys`. Keys embedded in the token (`jwk` header) are only considered + * when `trustEmbeddedJwk` is set, and `jku` URLs are only followed when a + * `resolveJku` effect is supplied — both default to off because tokens choose + * their own headers. + * + * @category JWS + * @since 4.0.0 + */ +export function verify< + A = string, + RD1 = never, + E2 = never, + R2 = never, + CriticalHeaders extends { + readonly [K in string]: Schema.Codec + } = {} +>({ + algorithms, + criticalHeaders, + maxSignatures = 4, + payload, + publicKeys, + resolveJku, + trustEmbeddedJwk +}: { + payload?: Schema.Codec | undefined + publicKeys?: ReadonlyArray | undefined + trustEmbeddedJwk?: boolean | undefined + resolveJku?: ((url: string) => Effect.Effect<(typeof JwkSet)["Type"], E2, R2>) | undefined + criticalHeaders?: (CriticalHeaders & ValidateCriticalHeaderKeys) | undefined + /** + * When set, only these `alg` values are accepted; a token selecting any + * other algorithm is rejected before key selection. Strongly recommended: + * pinning the algorithm is defence-in-depth against downgrade and + * key-type-confusion attacks. + */ + algorithms?: ReadonlyArray<(typeof JwsAlgorithm)["Type"]> | undefined + /** + * Maximum number of signatures accepted in a General JSON serialization + * (defaults to 4). Bounds the per-token verification work an attacker can + * force, including `jku` fetches. + */ + maxSignatures?: number | undefined +}) { + const keys = Arr.fromIterable(publicKeys ?? []) + const textEncoder = new TextEncoder() + + const defaultPayloadSchema = Schema.String as unknown as Schema.Codec + const defaultCritical = {} as CriticalHeaders & ValidateCriticalHeaderKeys + + const payloadSchema = Schema.StringFromBase64Url.pipe(Schema.decodeTo(payload ?? defaultPayloadSchema)) + const joseProtectedSchema = JoseProtectedHeader.pipe(joseHeaderWithCriticals(criticalHeaders ?? defaultCritical)) + const protectedHeaderSchema = Schema.StringFromBase64Url.pipe( + Schema.decodeTo(Schema.fromJsonString(joseProtectedSchema)) + ) + + const decodePayload = Schema.decodeEffect(payloadSchema) + const decodeProtectedHeader = Schema.decodeEffect(protectedHeaderSchema) + const decodeSignature = Schema.decodeEffect(Schema.Uint8ArrayFromBase64Url) + + // Import may reject on malformed key material or an alg/key-type mismatch; + // treat that as "unusable key" (null) rather than an unrecoverable defect. + const importJwk = (jwk: (typeof Jwk)["Type"], alg: (typeof JwsAlgorithm)["Type"]) => + Effect.tryPromise(() => crypto.subtle.importKey("jwk", jwk as JsonWebKey, importParameters(alg), false, ["verify"])) + .pipe(Effect.catch(() => Effect.succeed(null as CryptoKey | null))) + + const verifier = Effect.fnUntraced(function*(jws: General) { + if (jws.signatures.length > maxSignatures) { + return yield* new InvalidJws({ reason: new InvalidHeaders() }) + } + for (const signatureEntry of jws.signatures) { + const signatureBytes = yield* decodeSignature(signatureEntry.signature) + const protectedHeader = yield* decodeProtectedHeader(signatureEntry.protected) + + const header = { ...signatureEntry.header, ...protectedHeader } + const protectedKeys = Object.keys(protectedHeader ?? {}) + const unprotectedKeys = new Set(Object.keys(signatureEntry.header ?? {})) + if (protectedKeys.some((key) => unprotectedKeys.has(key))) { + return yield* new InvalidJws({ reason: new InvalidHeaders() }) + } + + if (header.alg === undefined) { + return yield* new InvalidJws({ reason: new InvalidHeaders() }) + } + + if (algorithms !== undefined && !algorithms.includes(header.alg)) { + return yield* new InvalidJws({ reason: new InvalidHeaders() }) + } + + const localKeys: Array = [] + + // Keys pulled from the token itself (jku URL / embedded jwk) are only + // trusted when the caller opts in, and even then a key is used only if + // it is an asymmetric public key compatible with the header algorithm — + // never a symmetric or private key, which would enable forgery. + const trustable = (jwk: (typeof Jwk)["Type"]) => + !isSymmetric(jwk) && !isPrivate(jwk) && isCompatibleWith(header.alg!, jwk) + + if (header.jku !== undefined && resolveJku !== undefined) { + const jwkSet = yield* resolveJku(header.jku).pipe( + Effect.mapError(() => new InvalidJws({ reason: new InvalidHeaders() })) + ) + for (const jwk of jwkSet.keys) { + if (!trustable(jwk)) continue + const imported = yield* importJwk(jwk, header.alg) + if (imported !== null) localKeys.push(imported) + } + } + + if (header.jwk !== undefined && trustEmbeddedJwk === true && trustable(header.jwk)) { + const imported = yield* importJwk(header.jwk, header.alg) + if (imported !== null) localKeys.push(imported) + } + + const verifyParameters = signatureParameters(header.alg) + for (const key of [...keys, ...localKeys]) { + // A key whose bound algorithm does not match makes crypto.subtle.verify + // reject; treat that (and any other failure) as "did not verify" so the + // next candidate is tried and verification stays fail-closed. + const verified = yield* Effect.tryPromise(() => + crypto.subtle.verify( + verifyParameters, + key, + Uint8Array.from(signatureBytes), + textEncoder.encode(`${signatureEntry.protected}.${jws.unverifiedPayload}`) + ) + ).pipe(Effect.catch(() => Effect.succeed(false))) + + if (verified) { + return { + signature: signatureBytes, + protected: protectedHeader, + header: signatureEntry.header, + payload: yield* decodePayload(jws.unverifiedPayload) + } + } + } + } + + return yield* new InvalidJws({ + reason: new InvalidSignature() + }) + }) + + return (jws: (typeof Unsecured)["Type"]) => { + if ("signatures" in jws) { + return verifier(jws) + } + + const intoGeneral = General.make({ + unverifiedPayload: jws.unverifiedPayload, + signatures: [ + { + header: jws.header, + protected: jws.protected, + signature: jws.signature + } + ] + }) + + return verifier(intoGeneral) + } +} + +/** + * Builds a JWS signer. Each private key entry carries its algorithm and any + * additional protected header parameters (e.g. `kid`, `typ`). Signing with a + * single key produces the Flattened serialization; multiple keys produce the + * General serialization. + * + * @category JWS + * @since 4.0.0 + */ +export function sign< + A = string, + RE1 = never, + PrivateKeys extends NonEmptyReadonlyArray<{ + algorithm: (typeof JwsAlgorithm)["Type"] + key: CryptoKey + header?: ProtectedHeaderExtras | undefined + }> = never, + CriticalHeaders extends { + readonly [K in string]: Schema.Codec + } = {} +>(options: { + privateKeys: PrivateKeys + payload?: Schema.Codec | undefined + criticalHeaders?: (CriticalHeaders & ValidateCriticalHeaderKeys) | undefined +}): ( + payload: A, + criticalHeaders: Schema.Struct.Type +) => Effect.Effect< + PrivateKeys extends [infer _] ? (typeof Flattened)["Encoded"] : (typeof General)["Encoded"], + Schema.SchemaError, + RE1 | Schema.Struct.EncodingServices +> { + const textEncoder = new TextEncoder() + const defaultCritical = {} as CriticalHeaders & ValidateCriticalHeaderKeys + + const payloadSchema = Schema.StringFromBase64Url.pipe(Schema.decodeTo(options.payload ?? Schema.String)) + const joseSchema = JoseProtectedHeader.pipe(joseHeaderWithCriticals(options.criticalHeaders ?? defaultCritical)) + const protectedHeaderSchema = Schema.StringFromBase64Url.pipe( + Schema.decodeTo(Schema.fromJsonString(joseSchema)) + ) + + const encodePayload = Schema.encodeEffect(payloadSchema) + const encodeProtected = Schema.encodeEffect(protectedHeaderSchema) + const encodeSignature = Schema.encodeEffect(Schema.Uint8ArrayFromBase64Url) + + const signMany = ( + encodedPayload: string, + criticalHeaders: Schema.Struct.Type, + privateKeys: NonEmptyReadonlyArray<{ + algorithm: (typeof JwsAlgorithm)["Type"] + key: CryptoKey + header?: ProtectedHeaderExtras | undefined + }> + ) => + Effect.forEach( + privateKeys, + Effect.fnUntraced(function*({ algorithm, header, key }) { + // RFC 7515 Section 4.1.11: the `crit` header lists the extension + // parameter names in use, so it is derived from the critical headers. + const critKeys = Object.keys(criticalHeaders as Record) + const protectedHeader = yield* encodeProtected({ + alg: algorithm as any, + ...header, + ...(critKeys.length > 0 ? { crit: critKeys } : {}), + ...criticalHeaders + } as any) + const signature = yield* Effect.promise(() => + crypto.subtle.sign( + signatureParameters(algorithm), + key, + textEncoder.encode(`${protectedHeader}.${encodedPayload}`) + ) + ) + return { + protected: protectedHeader, + signature: yield* encodeSignature(new Uint8Array(signature)) + } + }) + ) + + return Effect.fnUntraced(function*(payload: A, criticalHeaders: Schema.Struct.Type) { + const encodedPayload = yield* encodePayload(payload) + const signatures = yield* signMany(encodedPayload, criticalHeaders, options.privateKeys) + type Ret = PrivateKeys extends [infer _] ? (typeof Flattened)["Encoded"] : (typeof General)["Encoded"] + return options.privateKeys.length === 1 + ? ((yield* Schema.encodeEffect(Flattened)( + Flattened.make({ + unverifiedPayload: encodedPayload, + protected: Arr.headNonEmpty(signatures).protected, + signature: Arr.headNonEmpty(signatures).signature + }) + )) as Ret) + : ((yield* Schema.encodeEffect(General)( + General.make({ + unverifiedPayload: encodedPayload, + signatures + }) + )) as Ret) + }) +} + +/** + * Schema combinator that decodes an unverified JWS into its verified payload + * and headers, failing decode when no signature verifies. Encoding is + * forbidden — use {@link Signed} to produce a JWS. + * + * @category Schema Combinators + * @since 4.0.0 + */ +export function Verified< + A = string, + RD1 = never, + RE1 = never, + E2 = never, + R2 = never, + CriticalHeaders extends { + readonly [K in string]: Schema.Codec + } = {} +>(options: { + payload?: Schema.Codec | undefined + publicKeys?: ReadonlyArray | undefined + trustEmbeddedJwk?: boolean | undefined + resolveJku?: ((url: string) => Effect.Effect<(typeof JwkSet)["Type"], E2, R2>) | undefined + criticalHeaders?: (CriticalHeaders & ValidateCriticalHeaderKeys) | undefined +}) { + const verifier = verify(options) + const defaultPayloadSchema = Schema.String as unknown as Schema.Codec + const defaultCritical = {} as CriticalHeaders & ValidateCriticalHeaderKeys + + const to = Schema.Struct({ + signature: Schema.Uint8ArrayFromBase64Url, + payload: options.payload ?? defaultPayloadSchema, + header: JoseUnprotectedHeader.pipe(Schema.optional), + protected: JoseProtectedHeader.pipe(joseHeaderWithCriticals(options.criticalHeaders ?? defaultCritical)) + }).pipe(Schema.toType) + + const decode = flow( + verifier, + Effect.mapError((error) => (Schema.isSchemaError(error) ? error.issue : error)), + Effect.catchTag("InvalidJws", (_error) => + Effect.fail( + new SchemaIssue.Forbidden(Option.none(), { + message: "Invalid JWS" + }) + )) + ) + + return >( + from: From + ) => { + return (from as From).pipe( + Schema.decodeTo(to, { + decode: SchemaGetter.transformOrFail(decode) as never, + encode: SchemaGetter.forbidden(() => "Will not encode") + }) + ) + } +} + +/** + * Schema combinator that signs a payload (and critical headers) during + * decode, producing an unverified JWS serialization. Encoding is forbidden. + * + * @category Schema Combinators + * @since 4.0.0 + */ +export function Signed< + A = string, + RD1 = never, + RE1 = never, + PrivateKeys extends NonEmptyReadonlyArray<{ + algorithm: (typeof JwsAlgorithm)["Type"] + key: CryptoKey + header?: ProtectedHeaderExtras | undefined + }> = never, + CriticalHeaders extends { + readonly [K in string]: Schema.Codec + } = {} +>(options: { + privateKeys: PrivateKeys + payload?: Schema.Codec | undefined + criticalHeaders?: (CriticalHeaders & ValidateCriticalHeaderKeys) | undefined +}) { + const signer = sign(options) + const defaultPayloadSchema = Schema.String as unknown as Schema.Codec + + const from = Schema.Struct({ + payload: options.payload ?? defaultPayloadSchema, + ...(options.criticalHeaders ? { criticalHeaders: Schema.Struct(options.criticalHeaders) } : {}) + }) as Schema.Struct< + & { + readonly payload: Schema.Codec + } + & ([{}] extends [CriticalHeaders] ? {} + : { + readonly criticalHeaders: Schema.Struct< + CriticalHeaders & ValidateCriticalHeaderKeys + > + }) + > + + return >( + to: To + ) => { + return from.pipe( + Schema.decodeTo(to as To, { + encode: SchemaGetter.forbidden(() => "Will not encode"), + decode: SchemaGetter.transformOrFail((input: any) => + Effect.mapError( + signer(input.payload, input.criticalHeaders), + (error) => Schema.isSchemaError(error) ? error.issue : error + ) + ) + }) + ) + } +} diff --git a/packages/effect/src/unstable/jose/Jwt.ts b/packages/effect/src/unstable/jose/Jwt.ts new file mode 100644 index 00000000000..da053d8d7cb --- /dev/null +++ b/packages/effect/src/unstable/jose/Jwt.ts @@ -0,0 +1,256 @@ +/** + * High-level JSON Web Tokens (RFC 7519) built on the `Jws`, `Jwk`, and `Jwa` + * modules: compact-serialized, signed with any supported JWS algorithm, + * verified against a JWK Set with registered-claim validation. + * + * Reach for the `Jws` module directly when you need multiple signatures, + * unprotected headers, critical extension headers, or non-JSON payloads. + * + * @since 4.0.0 + */ +import * as Data from "../../Data.ts" +import * as DateTime from "../../DateTime.ts" +import * as Effect from "../../Effect.ts" +import * as Schema from "../../Schema.ts" +import * as Jwa from "./Jwa.ts" +import * as Jwk from "./Jwk.ts" +import * as Jws from "./Jws.ts" + +/** + * The registered claims (RFC 7519 Section 4.1) carried by a JWT. + * + * @category Schema + * @since 4.0.0 + */ +export const RegisteredClaims = Schema.Struct({ + /** @see https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1 */ + iss: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7519#section-4.1.2 */ + sub: Schema.String, + + /** @see https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3 */ + aud: Schema.Union([Schema.String, Schema.Array(Schema.String)]), + + /** @see https://www.rfc-editor.org/rfc/rfc7519#section-4.1.4 */ + exp: Schema.Number, + + /** @see https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 */ + iat: Schema.Number, + + /** @see https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5 */ + nbf: Schema.Number.pipe(Schema.optional), + + /** @see https://www.rfc-editor.org/rfc/rfc7519#section-4.1.7 */ + jti: Schema.String.pipe(Schema.optional) +}) + +/** + * Registered claims plus a rest record for token-specific claims, which can + * be decoded with a more specific schema from the claims returned by + * {@link verify}. + * + * @category Schema + * @since 4.0.0 + */ +export const StandardClaims = Schema.StructWithRest(RegisteredClaims, [ + Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Unknown)) +]) + +/** + * The reasons a JWT can fail verification. + * + * @category Errors + * @since 4.0.0 + */ +export type JwtErrorReason = + | "Malformed" + | "UnknownKey" + | "BadAlgorithm" + | "BadType" + | "BadSignature" + | "Expired" + | "NotYetValid" + | "BadIssuer" + | "BadAudience" + +/** + * @category Errors + * @since 4.0.0 + */ +export class JwtError extends Data.TaggedError("JwtError")<{ + readonly reason: JwtErrorReason +}> {} + +/** Seconds of clock skew tolerated when validating time claims. */ +const clockSkewSeconds = 30 + +const PayloadFromJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Unknown))) + +const ClaimsFromJson = Schema.fromJsonString(StandardClaims) + +const HeaderHint = Schema.StringFromBase64Url.pipe( + Schema.decodeTo( + Schema.fromJsonString( + Schema.Struct({ + alg: Jwa.JwsAlgorithm, + kid: Schema.String.pipe(Schema.optional), + typ: Schema.String.pipe(Schema.optional) + }) + ) + ) +) + +/** + * Generates a fresh ES256 signing key pair with a random `kid`. Persist the + * private JWK as a secret and publish the public JWK in a JWK Set. + * + * @category Keys + * @since 4.0.0 + */ +export const generateSigningKey = Effect.fnUntraced(function*() { + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]) + ) + const kid = crypto.randomUUID() + const privateJwk = yield* Effect.promise(() => crypto.subtle.exportKey("jwk", pair.privateKey)) + const publicJwk = yield* Effect.promise(() => crypto.subtle.exportKey("jwk", pair.publicKey)) + return { + privateJwk: yield* Schema.decodeUnknownEffect(Jwk.EcPrivateKey)({ + ...privateJwk, + kid, + alg: "ES256", + use: "sig" + }), + publicJwk: yield* Schema.decodeUnknownEffect(Jwk.EcPublicKey)({ + ...publicJwk, + kid, + alg: "ES256", + use: "sig" + }) + } +}) + +/** + * Signs a payload as a compact-serialized JWT with the given private JWK, + * using the key's `alg` (defaulting to ES256) and carrying its `kid` in the + * protected header. + * + * @category Signing + * @since 4.0.0 + */ +export const sign = Effect.fnUntraced(function*(options: { + readonly privateJwk: (typeof Jwk.EcPrivateKey)["Type"] | (typeof Jwk.RsaPrivateKey)["Type"] + readonly payload: Record +}) { + const algorithm = options.privateJwk.alg ?? "ES256" + const key = yield* Effect.promise(() => + crypto.subtle.importKey("jwk", options.privateJwk as JsonWebKey, Jwa.importParameters(algorithm), false, [ + "sign" + ]) + ) + + const signer = Jws.sign({ + privateKeys: [ + { + algorithm, + key, + header: { + typ: "JWT", + ...(options.privateJwk.kid === undefined ? {} : { kid: options.privateJwk.kid }) + } + } + ], + payload: PayloadFromJson + }) + + const flattened = yield* signer(options.payload, {}) + return `${flattened.protected}.${flattened.payload}.${flattened.signature}` +}) + +/** + * Verifies a compact-serialized JWT against a JWK Set: signature (any + * supported JWS algorithm, with `kid`-based key selection), `exp`/`nbf` + * (with 30s skew), and — when provided — `algorithms`, `types` (the `typ` + * header), `issuer`, and `audience`. + * + * `audience` is validated only when supplied; pass it whenever the token is + * addressed to a specific recipient. Pinning `algorithms` (e.g. `["ES256"]`) + * is recommended defence-in-depth. Returns the validated standard claims + * plus the rest record for decoding token-specific claims with a more + * precise schema. + * + * @category Verification + * @since 4.0.0 + */ +export const verify = Effect.fnUntraced(function*( + token: string, + options: { + readonly jwks: (typeof Jwk.JwkSet)["Type"] + readonly issuer?: string | undefined + readonly audience?: string | undefined + /** When set, only these `alg` values are accepted (e.g. `["ES256"]`). */ + readonly algorithms?: ReadonlyArray<(typeof Jwa.JwsAlgorithm)["Type"]> | undefined + /** When set, the `typ` header must be present and one of these (e.g. `["at+jwt"]`). */ + readonly types?: ReadonlyArray | undefined + } +) { + const flattened = yield* Schema.decodeUnknownEffect(Jws.Compact)(token).pipe( + Effect.mapError(() => new JwtError({ reason: "Malformed" })) + ) + + const hint = yield* Schema.decodeUnknownEffect(HeaderHint)(flattened.protected).pipe( + Effect.mapError(() => new JwtError({ reason: "Malformed" })) + ) + + if (options.algorithms !== undefined && !options.algorithms.includes(hint.alg)) { + return yield* new JwtError({ reason: "BadAlgorithm" }) + } + if (options.types !== undefined && (hint.typ === undefined || !options.types.includes(hint.typ))) { + return yield* new JwtError({ reason: "BadType" }) + } + + const candidates = options.jwks.keys + .filter((jwk) => Jwk.isCompatibleWith(hint.alg, jwk)) + .filter((jwk) => hint.kid === undefined || jwk.kid === undefined || jwk.kid === hint.kid) + if (candidates.length === 0) return yield* new JwtError({ reason: "UnknownKey" }) + + // Import each candidate independently, skipping any key whose material is + // malformed rather than failing the whole verification — one bad key in an + // otherwise-valid JWK Set must not deny service to tokens signed by the + // good keys. + const imported = yield* Effect.forEach( + candidates, + (jwk) => + Effect.tryPromise(() => + crypto.subtle.importKey("jwk", jwk as JsonWebKey, Jwa.importParameters(hint.alg), false, ["verify"]) + ).pipe(Effect.catch(() => Effect.succeed(null as CryptoKey | null))) + ) + const publicKeys = imported.filter((key): key is CryptoKey => key !== null) + if (publicKeys.length === 0) return yield* new JwtError({ reason: "UnknownKey" }) + + const result = yield* Jws.verify({ publicKeys, payload: ClaimsFromJson })(flattened).pipe( + Effect.mapError((error) => + error instanceof Jws.InvalidJws + ? new JwtError({ reason: error.reason._tag === "InvalidSignature" ? "BadSignature" : "Malformed" }) + : new JwtError({ reason: "Malformed" }) + ) + ) + + const claims = result.payload + const nowSeconds = DateTime.toEpochMillis(yield* DateTime.now) / 1000 + + if (claims.exp + clockSkewSeconds < nowSeconds) return yield* new JwtError({ reason: "Expired" }) + if (claims.nbf !== undefined && claims.nbf - clockSkewSeconds > nowSeconds) { + return yield* new JwtError({ reason: "NotYetValid" }) + } + if (options.issuer !== undefined && claims.iss !== options.issuer) { + return yield* new JwtError({ reason: "BadIssuer" }) + } + if (options.audience !== undefined) { + const audiences = typeof claims.aud === "string" ? [claims.aud] : claims.aud + if (!audiences.includes(options.audience)) return yield* new JwtError({ reason: "BadAudience" }) + } + + return claims +}) diff --git a/packages/effect/src/unstable/jose/index.ts b/packages/effect/src/unstable/jose/index.ts new file mode 100644 index 00000000000..b2aab5e286d --- /dev/null +++ b/packages/effect/src/unstable/jose/index.ts @@ -0,0 +1,30 @@ +/** + * @since 4.0.0 + */ + +// @barrel: Auto-generated exports. Do not edit manually. + +/** + * @since 4.0.0 + */ +export * as Jwa from "./Jwa.ts" + +/** + * @since 4.0.0 + */ +export * as Jwe from "./Jwe.ts" + +/** + * @since 4.0.0 + */ +export * as Jwk from "./Jwk.ts" + +/** + * @since 4.0.0 + */ +export * as Jws from "./Jws.ts" + +/** + * @since 4.0.0 + */ +export * as Jwt from "./Jwt.ts" diff --git a/packages/effect/test/unstable/jose/Jwe.test.ts b/packages/effect/test/unstable/jose/Jwe.test.ts new file mode 100644 index 00000000000..c0805c2ded0 --- /dev/null +++ b/packages/effect/test/unstable/jose/Jwe.test.ts @@ -0,0 +1,220 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import type { JweAlgorithm, JweEncryption } from "effect/unstable/jose/Jwa" +import * as Jwe from "effect/unstable/jose/Jwe" + +const encryptions: ReadonlyArray<(typeof JweEncryption)["Type"]> = [ + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512", + "A128GCM", + "A192GCM", + "A256GCM" +] + +const cekBytesFor = (enc: (typeof 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 JweAlgorithm)["Type"], enc: (typeof 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 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" +] + +describe("Jwe", () => { + const plaintext = "The true sign of intelligence is not knowledge but imagination." + + describe("round-trips every alg x enc", () => { + for (const alg of algorithms) { + it.effect(alg, () => + 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(".") + assert.strictEqual(parts.length, 5, `${alg}/${enc} is not a 5-part compact JWE`) + + const result = yield* Jwe.decrypt({ jwe, key: decryptKey }) + assert.strictEqual(decode(result.plaintext), plaintext, `${alg}/${enc} did not round-trip`) + assert.strictEqual(result.protectedHeader.alg, alg) + assert.strictEqual(result.protectedHeader.enc, enc) + } + })) + } + }) + + it.effect("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 }) + assert.strictEqual(result.protectedHeader.kid, "key-1") + assert.strictEqual(result.protectedHeader.cty, "text/plain") + })) + + it.effect("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.at(-1) + const error = yield* Effect.flip(Jwe.decrypt({ jwe: parts.join("."), key })) + assert.strictEqual(error.reason, "DecryptionFailed") + })) + + it.effect("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.at(-1) + const error = yield* Effect.flip(Jwe.decrypt({ jwe: parts.join("."), key })) + assert.strictEqual(error.reason, "DecryptionFailed") + })) + + it.effect("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 })) + assert.include(["DecryptionFailed", "KeyManagementFailed"], 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.effect("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 }) + assert.strictEqual(decode(result.plaintext), "Live long and prosper.") + assert.strictEqual(result.protectedHeader.alg, "A128KW") + assert.strictEqual(result.protectedHeader.enc, "A128CBC-HS256") + })) +}) diff --git a/packages/effect/test/unstable/jose/Jws.test.ts b/packages/effect/test/unstable/jose/Jws.test.ts new file mode 100644 index 00000000000..f508f7aa69c --- /dev/null +++ b/packages/effect/test/unstable/jose/Jws.test.ts @@ -0,0 +1,115 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Schema from "effect/Schema" +import * as Jws from "effect/unstable/jose/Jws" + +const fromBase64Url = (value: string) => + Uint8Array.from( + atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4)), + (c) => c.charCodeAt(0) + ) + +const A1_TOKEN = "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9" + + ".eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" + + ".dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + +describe("Jws", () => { + // RFC 7515 Appendix A.1: HMAC SHA-256. External ground truth that our + // decode + verify pipeline matches the bytes produced by the spec authors. + it.effect("verifies the RFC 7515 A.1 HS256 vector", () => + Effect.gen(function*() { + const key = yield* Effect.promise(() => + crypto.subtle.importKey( + "raw", + fromBase64Url("AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"] + ) + ) + const jws = yield* Schema.decodeUnknownEffect(Jws.Compact)(A1_TOKEN) + const result = yield* Jws.verify({ + publicKeys: [key], + payload: Schema.fromJsonString( + Schema.Struct({ + iss: Schema.String, + exp: Schema.Number, + "http://example.com/is_root": Schema.Boolean + }) + ) + })(jws) + assert.strictEqual(result.payload.iss, "joe") + assert.strictEqual(result.payload["http://example.com/is_root"], true) + assert.strictEqual(result.protected.alg, "HS256") + })) + + it.effect("rejects the A.1 vector under the wrong key", () => + Effect.gen(function*() { + const key = yield* Effect.promise(() => + crypto.subtle.importKey( + "raw", + fromBase64Url("AAAAAAAAAAAAAAAAAAAAAA"), + { name: "HMAC", hash: "SHA-256" }, + false, + [ + "verify" + ] + ) + ) + const jws = yield* Schema.decodeUnknownEffect(Jws.Compact)(A1_TOKEN) + const error = yield* Effect.flip(Jws.verify({ publicKeys: [key] })(jws)) + assert.strictEqual(error._tag, "InvalidJws") + })) + + it.effect("signs and verifies a single ES256 signature (Flattened)", () => + Effect.gen(function*() { + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]) + ) + const encoded = yield* Jws.sign({ privateKeys: [{ algorithm: "ES256", key: pair.privateKey }] })("hello jose", {}) + const jws = yield* Schema.decodeUnknownEffect(Jws.Flattened)(encoded) + const result = yield* Jws.verify({ publicKeys: [pair.publicKey] })(jws) + assert.strictEqual(result.payload, "hello jose") + })) + + it.effect("supports multiple signatures in the General serialization", () => + Effect.gen(function*() { + const a = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]) + ) + const b = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-384" }, true, ["sign", "verify"]) + ) + const encoded = yield* Jws.sign({ + privateKeys: [ + { algorithm: "ES256", key: a.privateKey }, + { algorithm: "ES384", key: b.privateKey } + ] + })("multi", {}) + const jws = yield* Schema.decodeUnknownEffect(Jws.General)(encoded) + assert.strictEqual(jws.signatures.length, 2) + + // each recipient verifies with only their own key + for (const key of [a.publicKey, b.publicKey]) { + const result = yield* Jws.verify({ publicKeys: [key] })(jws) + assert.strictEqual(result.payload, "multi") + } + })) + + it.effect("carries and round-trips a critical extension header", () => + Effect.gen(function*() { + const pair = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]) + ) + const criticalHeaders = { "https://example.com/exp": Schema.Number } + const encoded = yield* Jws.sign({ + privateKeys: [{ algorithm: "ES256", key: pair.privateKey }], + criticalHeaders + })("payload", { "https://example.com/exp": 1300819380 }) + const jws = yield* Schema.decodeUnknownEffect(Jws.Flattened)(encoded) + const result = yield* Jws.verify({ publicKeys: [pair.publicKey], criticalHeaders })(jws) + assert.strictEqual(result.payload, "payload") + assert.deepStrictEqual(result.protected.crit, ["https://example.com/exp"]) + assert.strictEqual((result.protected as Record)["https://example.com/exp"], 1300819380) + })) +}) diff --git a/packages/effect/test/unstable/jose/Jwt.test.ts b/packages/effect/test/unstable/jose/Jwt.test.ts new file mode 100644 index 00000000000..b07a8e59c10 --- /dev/null +++ b/packages/effect/test/unstable/jose/Jwt.test.ts @@ -0,0 +1,93 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Jws from "effect/unstable/jose/Jws" +import * as Jwt from "effect/unstable/jose/Jwt" + +describe("Jwt", () => { + const claims = { + iss: "https://issuer.example.com", + sub: "user-123", + aud: "my-api", + exp: 9999999999, + iat: 1, + scope: "read write" + } + + it.effect("signs and verifies an ES256 token", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk, payload: claims }) + const verified = yield* Jwt.verify(token, { + jwks: { keys: [publicJwk] }, + issuer: "https://issuer.example.com", + audience: "my-api" + }) + assert.strictEqual(verified.sub, "user-123") + assert.strictEqual(verified.scope, "read write") + })) + + it.effect("selects the right key from a mixed JWK Set by kid", () => + Effect.gen(function*() { + const a = yield* Jwt.generateSigningKey() + const b = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk: b.privateJwk, payload: claims }) + const verified = yield* Jwt.verify(token, { jwks: { keys: [a.publicJwk, b.publicJwk] } }) + assert.strictEqual(verified.iss, "https://issuer.example.com") + })) + + it.effect("rejects a tampered payload", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk, payload: claims }) + const [header, payload, signature] = token.split(".") + const tampered = `${header}.${payload!.slice(0, -4)}AAAA.${signature}` + const error = yield* Effect.flip(Jwt.verify(tampered, { jwks: { keys: [publicJwk] } })) + assert.strictEqual(error.reason, "BadSignature") + })) + + it.effect("rejects a wrong audience", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk, payload: claims }) + const error = yield* Effect.flip( + Jwt.verify(token, { jwks: { keys: [publicJwk] }, audience: "other-api" }) + ) + assert.strictEqual(error.reason, "BadAudience") + })) + + it.effect("rejects an expired token", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + // it.effect runs under TestClock at epoch 0, so anything past the + // clock-skew allowance before that counts as expired + const token = yield* Jwt.sign({ privateJwk, payload: { ...claims, exp: -60 } }) + const error = yield* Effect.flip(Jwt.verify(token, { jwks: { keys: [publicJwk] } })) + assert.strictEqual(error.reason, "Expired") + })) + + it.effect("does not trust keys embedded in the token", () => + Effect.gen(function*() { + const trusted = yield* Jwt.generateSigningKey() + const attacker = yield* Jwt.generateSigningKey() + const key = yield* Effect.promise(() => + crypto.subtle.importKey( + "jwk", + attacker.privateJwk as JsonWebKey, + { name: "ECDSA", namedCurve: "P-256" }, + false, + [ + "sign" + ] + ) + ) + const signer = Jws.sign({ + privateKeys: [{ algorithm: "ES256" as const, key, header: { jwk: attacker.publicJwk } }] + }) + const flattened = yield* signer(JSON.stringify(claims), {}) + const forged = `${flattened.protected}.${flattened.payload}.${flattened.signature}` + const error = yield* Effect.flip( + Jwt.verify(forged, { jwks: { keys: [trusted.publicJwk] } }) + ) + assert.strictEqual(error.reason, "BadSignature") + })) +}) diff --git a/packages/effect/test/unstable/jose/Security.test.ts b/packages/effect/test/unstable/jose/Security.test.ts new file mode 100644 index 00000000000..ef6daa57657 --- /dev/null +++ b/packages/effect/test/unstable/jose/Security.test.ts @@ -0,0 +1,281 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Schema from "effect/Schema" +import * as Jwe from "effect/unstable/jose/Jwe" +import * as Jwk from "effect/unstable/jose/Jwk" +import * as Jws from "effect/unstable/jose/Jws" +import * as Jwt from "effect/unstable/jose/Jwt" + +const claims = { iss: "iss", sub: "sub", aud: "aud", exp: 9999999999, iat: 1 } + +describe("JOSE security remediations", () => { + describe("Jwk.isCompatibleWith / guards", () => { + it("binds algorithm family to key type and rejects use:enc", () => + Effect.gen(function*() { + const { publicJwk } = yield* Jwt.generateSigningKey() + assert.isTrue(Jwk.isCompatibleWith("ES256", publicJwk)) + assert.isFalse(Jwk.isCompatibleWith("ES384", publicJwk)) + assert.isFalse(Jwk.isCompatibleWith("RS256", publicJwk)) + assert.isFalse(Jwk.isCompatibleWith("HS256", publicJwk)) + assert.isFalse(Jwk.isCompatibleWith("ES256", { ...publicJwk, use: "enc" } as typeof publicJwk)) + const oct = { kty: "oct" as const, k: "AAAA" } + assert.isTrue(Jwk.isSymmetric(oct)) + assert.isFalse(Jwk.isSymmetric(publicJwk)) + }).pipe(Effect.runPromise)) + }) + + describe("Jwt.verify allowlists", () => { + it("rejects an algorithm outside the allowlist", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk, payload: claims }) + const error = yield* Effect.flip( + Jwt.verify(token, { jwks: { keys: [publicJwk] }, algorithms: ["RS256"] }) + ) + assert.strictEqual(error.reason, "BadAlgorithm") + }).pipe(Effect.runPromise)) + + it("accepts an algorithm inside the allowlist", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk, payload: claims }) + const verified = yield* Jwt.verify(token, { jwks: { keys: [publicJwk] }, algorithms: ["ES256"] }) + assert.strictEqual(verified.sub, "sub") + }).pipe(Effect.runPromise)) + + it("verifies against a JWK Set containing a malformed key alongside the good one", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk, payload: claims }) + // a compatible (ES256) but structurally broken key must be skipped, not fatal + const brokenKey = { ...publicJwk, x: "!!!not-base64!!!", kid: undefined } + const verified = yield* Jwt.verify(token, { + jwks: { keys: [brokenKey as typeof publicJwk, publicJwk] }, + algorithms: ["ES256"] + }) + assert.strictEqual(verified.sub, "sub") + }).pipe(Effect.runPromise)) + + it("enforces the typ header when types is supplied", () => + Effect.gen(function*() { + const { privateJwk, publicJwk } = yield* Jwt.generateSigningKey() + const token = yield* Jwt.sign({ privateJwk, payload: claims }) // typ: "JWT" + const error = yield* Effect.flip( + Jwt.verify(token, { jwks: { keys: [publicJwk] }, types: ["at+jwt"] }) + ) + assert.strictEqual(error.reason, "BadType") + const ok = yield* Jwt.verify(token, { jwks: { keys: [publicJwk] }, types: ["JWT"] }) + assert.strictEqual(ok.sub, "sub") + }).pipe(Effect.runPromise)) + }) + + describe("Jws.verify hardening", () => { + it("does not crash on a key whose algorithm mismatches (fail-closed, not defect)", () => + Effect.gen(function*() { + // an RSA-PSS verify key handed to a verifier for an ES256 token: + // crypto.subtle.verify would reject; it must be skipped, yielding InvalidJws + const es = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]) + ) + const rsa = yield* Effect.promise(() => + crypto.subtle.generateKey( + { name: "RSA-PSS", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"] + ) + ) + const encoded = yield* Jws.sign({ privateKeys: [{ algorithm: "ES256", key: es.privateKey }] })("hi", {}) + const jws = yield* Schema.decodeUnknownEffect(Jws.Flattened)(encoded) + // rsa key first (would throw inside crypto.subtle.verify), then the correct es key + const result = yield* Jws.verify({ publicKeys: [rsa.publicKey, es.publicKey] })(jws) + assert.strictEqual(result.payload, "hi") + }).pipe(Effect.runPromise)) + + it("rejects a General JWS exceeding maxSignatures", () => + Effect.gen(function*() { + const a = yield* Effect.promise(() => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]) + ) + const encoded = yield* Jws.sign({ + privateKeys: [ + { algorithm: "ES256", key: a.privateKey }, + { algorithm: "ES256", key: a.privateKey } + ] + })("x", {}) + const jws = yield* Schema.decodeUnknownEffect(Jws.General)(encoded) + const error = yield* Effect.flip(Jws.verify({ publicKeys: [a.publicKey], maxSignatures: 1 })(jws)) + assert.strictEqual(error._tag, "InvalidJws") + }).pipe(Effect.runPromise)) + }) + + describe("Jwe.decrypt hardening", () => { + 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)) + + it("bounds the PBES2 iteration count (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 + const b64 = (s: string) => btoa(s).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") + parts[0] = b64(JSON.stringify(header)) + const error = yield* Effect.flip(Jwe.decrypt({ jwe: parts.join("."), key })) + assert.strictEqual(error.reason, "Malformed") + }).pipe(Effect.runPromise)) + + it("enforces 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 e1 = yield* Effect.flip( + Jwe.decrypt({ jwe, key, keyManagementAlgorithms: ["RSA-OAEP"] }) + ) + assert.strictEqual(e1.reason, "UnsupportedAlgorithm") + const e2 = yield* Effect.flip( + Jwe.decrypt({ jwe, key, contentEncryptionAlgorithms: ["A256GCM"] }) + ) + assert.strictEqual(e2.reason, "UnsupportedAlgorithm") + // allowlist that matches still works + const ok = yield* Jwe.decrypt({ + jwe, + key, + keyManagementAlgorithms: ["A128KW"], + contentEncryptionAlgorithms: ["A128GCM"] + }) + assert.strictEqual(new TextDecoder().decode(ok.plaintext), "hi") + }).pipe(Effect.runPromise)) + + it("rejects 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 })) + assert.strictEqual(error.reason, "UnsupportedAlgorithm") + }).pipe(Effect.runPromise)) + + it("returns a typed Malformed error (not a defect) on malformed 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 })) + assert.strictEqual(error.reason, "Malformed") + }).pipe(Effect.runPromise)) + + it("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 }) + assert.strictEqual(new TextDecoder().decode(result.plaintext), "agree") + // apu/apv are carried in the protected header and bound into the KDF + assert.isDefined((result.protectedHeader as Record).apu) + assert.isDefined((result.protectedHeader as Record).apv) + }).pipe(Effect.runPromise)) + + it("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" }) + ) + assert.strictEqual(error.reason, "KeyManagementFailed") + }).pipe(Effect.runPromise)) + + it("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"] + ) + ) + const b64 = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") + // 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 })) + assert.strictEqual(error.reason, "DecryptionFailed") + }).pipe(Effect.runPromise)) + + it("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 no longer dies. + 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 b64 = (bytes: Uint8Array) => + btoa(String.fromCharCode(...bytes)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") + 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 })) + assert.strictEqual(error.reason, "KeyManagementFailed") + }).pipe(Effect.runPromise)) + }) + + describe("Jwk.RsaPrivateKey", () => { + it("preserves the CRT parameters of a full private key", () => + Effect.gen(function*() { + const full = { kty: "RSA", n: "nnn", e: "AQAB", d: "ddd", p: "ppp", q: "qqq", dp: "dpv", dq: "dqv", qi: "qiv" } + const decoded = yield* Schema.decodeUnknownEffect(Jwk.RsaPrivateKey)(full) + assert.deepStrictEqual(decoded, full) + }).pipe(Effect.runPromise)) + + it("still decodes a d-only private key", () => + Effect.gen(function*() { + const dOnly = { kty: "RSA", n: "nnn", e: "AQAB", d: "ddd" } + const decoded = yield* Schema.decodeUnknownEffect(Jwk.RsaPrivateKey)(dOnly) + assert.deepStrictEqual(decoded, dOnly) + }).pipe(Effect.runPromise)) + }) +})