From da23d8893984f765c7e838e2605d0d8f08d7e762 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sat, 22 Mar 2025 16:35:48 +0100 Subject: [PATCH 01/12] feat: initial JWE implementation --- src/types/utils/session.ts | 5 +- src/utils/internal/jwe.ts | 221 +++++++++++++++++++++++++++++++++++++ src/utils/session.ts | 10 +- test/unit/jwe.test.ts | 187 +++++++++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 7 deletions(-) create mode 100644 src/utils/internal/jwe.ts create mode 100644 test/unit/jwe.test.ts diff --git a/src/types/utils/session.ts b/src/types/utils/session.ts index f7e17bf9d..e76e13cf6 100644 --- a/src/types/utils/session.ts +++ b/src/types/utils/session.ts @@ -1,5 +1,5 @@ import type { CookieSerializeOptions } from "cookie-es"; -import type { SealOptions } from "../../utils/internal/iron-crypto"; +import type { JWEOptions } from "../../utils/internal/jwe"; import type { kGetSession } from "../../utils/internal/session"; type SessionDataT = Record; @@ -24,7 +24,8 @@ export interface SessionConfig { cookie?: false | CookieSerializeOptions; /** Default is x-h3-session / x-{name}-session */ sessionHeader?: false | string; - seal?: SealOptions; + /** JWE options for encryption/decryption */ + jwe?: Partial; crypto?: Crypto; /** Default is Crypto.randomUUID */ generateId?: () => string; diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts new file mode 100644 index 000000000..9b08dcdd0 --- /dev/null +++ b/src/utils/internal/jwe.ts @@ -0,0 +1,221 @@ +import crypto from "uncrypto"; +import { + base64Decode, + base64Encode, + textDecoder, + textEncoder, +} from "./encoding"; + +/** + * JWE (JSON Web Encryption) implementation for H3 sessions + */ + +export interface JWEOptions { + /** Expiration time in milliseconds where 0 means forever. Defaults to 0. */ + ttl: number; + /** Number of seconds of permitted clock skew for incoming expirations. Defaults to 60 seconds. */ + timestampSkewSec: number; + /** Local clock time offset in milliseconds. Defaults to 0. */ + localtimeOffsetMsec: number; +} + +export const DEFAULT_JWE_OPTIONS: JWEOptions = { + ttl: 0, + timestampSkewSec: 60, + localtimeOffsetMsec: 0, +}; + +export interface JWEHeader { + alg: string; + enc: string; +} + +export interface JWESegments { + protected: string; + iv: string; + ciphertext: string; + tag: string; +} + +/** + * Encrypt and serialize data into a JWE token + */ +export async function seal( + payload: unknown, + password: string, + options: Partial = {}, +): Promise { + if (!password || typeof password !== "string") { + throw new Error("Invalid password"); + } + + const opts = { ...DEFAULT_JWE_OPTIONS, ...options }; + const now = Date.now() + opts.localtimeOffsetMsec; + + // Add expiration if ttl is provided + const payloadWithMeta = { + payload, + iat: now, + ...(opts.ttl ? { exp: now + opts.ttl } : {}), + }; + + // Generate a random IV and salt + const iv = crypto.getRandomValues(new Uint8Array(16)); + const salt = crypto.getRandomValues(new Uint8Array(16)); + + // Key derivation + const keyMaterial = await crypto.subtle.importKey( + "raw", + textEncoder.encode(password), + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"], + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: 100_000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"], + ); + + // Create protected header + const protectedHeader: JWEHeader = { + alg: "PBES2-HS256+A128KW", + enc: "A256GCM", + }; + + const protectedHeaderB64 = base64Encode(JSON.stringify(protectedHeader)); + + // Encrypt payload + const plaintext = textEncoder.encode(JSON.stringify(payloadWithMeta)); + const encryptedData = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + key, + plaintext, + ); + + // Split ciphertext and authentication tag + const encryptedDataArray = new Uint8Array(encryptedData); + const ciphertextLength = encryptedDataArray.length - 16; // Last 16 bytes are the auth tag + const ciphertext = encryptedDataArray.slice(0, ciphertextLength); + const tag = encryptedDataArray.slice(ciphertextLength); + + // Format as compact JWE: header.salt.iv.ciphertext.tag + return `${protectedHeaderB64}.${base64Encode(salt)}.${base64Encode(iv)}.${base64Encode(ciphertext)}.${base64Encode(tag)}`; +} + +/** + * Decrypt and verify a JWE token + */ +export async function unseal( + token: string, + password: string, + options: Partial = {}, +): Promise { + if (!password || typeof password !== "string") { + throw new Error("Invalid password"); + } + + const opts = { ...DEFAULT_JWE_OPTIONS, ...options }; + const now = Date.now() + opts.localtimeOffsetMsec; + + // Split the JWE token + const parts = token.split("."); + if (parts.length !== 5) { + throw new Error("Invalid JWE token format"); + } + + const [protectedHeaderB64, saltB64, ivB64, ciphertextB64, tagB64] = parts; + + // Decode and validate protected header + let protectedHeader: JWEHeader; + try { + protectedHeader = JSON.parse( + textDecoder.decode(base64Decode(protectedHeaderB64)), + ) as JWEHeader; + } catch { + throw new Error("Invalid JWE header"); + } + + if ( + protectedHeader.alg !== "PBES2-HS256+A128KW" || + protectedHeader.enc !== "A256GCM" + ) { + throw new Error("Unsupported JWE algorithms"); + } + + // Reconstruct the key + const salt = base64Decode(saltB64); + const iv = base64Decode(ivB64); + const ciphertext = base64Decode(ciphertextB64); + const tag = base64Decode(tagB64); + + // Combine ciphertext and tag for decryption + const encryptedData = new Uint8Array(ciphertext.length + tag.length); + encryptedData.set(ciphertext); + encryptedData.set(tag, ciphertext.length); + + // Derive key + const keyMaterial = await crypto.subtle.importKey( + "raw", + textEncoder.encode(password), + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"], + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: 100_000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"], + ); + + // Decrypt + let decrypted; + try { + decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + key, + encryptedData, + ); + } catch { + throw new Error("Failed to decrypt JWE token"); + } + + // Parse and validate + let result; + try { + result = JSON.parse(textDecoder.decode(decrypted)); + } catch { + throw new Error("Invalid JWE payload format"); + } + + // Check expiration + if (result.exp && result.exp <= now - opts.timestampSkewSec * 1000) { + throw new Error("Token expired"); + } + + return result.payload; +} diff --git a/src/utils/session.ts b/src/utils/session.ts index 9b151f14a..50af31c08 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -1,5 +1,5 @@ import type { H3Event, Session, SessionConfig, SessionData } from "../types"; -import { seal, unseal, defaults as sealDefaults } from "./internal/iron-crypto"; +import { seal, unseal, DEFAULT_JWE_OPTIONS } from "./internal/jwe"; import { getCookie, setCookie } from "./cookie"; import { DEFAULT_SESSION_NAME, @@ -163,9 +163,9 @@ export async function sealSession( (await getSession(event, config)); const sealed = await seal(session, config.password, { - ...sealDefaults, + ...DEFAULT_JWE_OPTIONS, ttl: config.maxAge ? config.maxAge * 1000 : 0, - ...config.seal, + ...config.jwe, }); return sealed; @@ -180,9 +180,9 @@ export async function unsealSession( sealed: string, ) { const unsealed = (await unseal(sealed, config.password, { - ...sealDefaults, + ...DEFAULT_JWE_OPTIONS, ttl: config.maxAge ? config.maxAge * 1000 : 0, - ...config.seal, + ...config.jwe, })) as Partial; if (config.maxAge) { const age = Date.now() - (unsealed.createdAt || Number.NEGATIVE_INFINITY); diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts new file mode 100644 index 000000000..8e7136f1e --- /dev/null +++ b/test/unit/jwe.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, assert } from "vitest"; +import * as JWE from "../../src/utils/internal/jwe"; + +const testObject = { a: 1, b: 2, c: [3, 4, 5], d: { e: "f" } }; +const password = "some_not_random_password_that_is_also_long_enough"; + +describe("JWE", () => { + it("seals and unseals an object correctly", async () => { + const sealed = await JWE.seal(testObject, password); + const unsealed = await JWE.unseal(sealed, password); + assert.deepEqual(unsealed, testObject); + }); + + it("handles expiration correctly", async () => { + // Set a 100ms expiration + const options = { ttl: 100 }; + const sealed = await JWE.seal(testObject, password, options); + + // Should work immediately + const unsealed = await JWE.unseal(sealed, password, options); + assert.deepEqual(unsealed, testObject); + + // Should fail after expiration + await new Promise((resolve) => setTimeout(resolve, 110)); + await expect(JWE.unseal(sealed, password, options)).rejects.toThrow( + "Token expired", + ); + }); + + it("handles time offset correctly", async () => { + // Set a 100ms expiration with time offset into the future + const options = { ttl: 100, localtimeOffsetMsec: 200 }; + const sealed = await JWE.seal(testObject, password, options); + + // Should work with the same offset + const unsealed = await JWE.unseal(sealed, password, options); + assert.deepEqual(unsealed, testObject); + + // Should fail with a different offset that puts us past expiration + await expect( + JWE.unseal(sealed, password, { ttl: 100, localtimeOffsetMsec: 300 }), + ).rejects.toThrow("Token expired"); + }); + + it("handles timestamp skew correctly", async () => { + // Set a very short expiration + const options = { ttl: 1 }; + const sealed = await JWE.seal(testObject, password, options); + + // Wait for expiration + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should still work with increased skew tolerance + const unsealed = await JWE.unseal(sealed, password, { + ...options, + timestampSkewSec: 10, + }); + assert.deepEqual(unsealed, testObject); + }); + + it("rejects invalid passwords", async () => { + const sealed = await JWE.seal(testObject, password); + + await expect(JWE.unseal(sealed, "wrong_password")).rejects.toThrow( + "Failed to decrypt JWE token", + ); + + await expect(JWE.seal(testObject, "")).rejects.toThrow("Invalid password"); + + await expect(JWE.unseal(sealed, "")).rejects.toThrow("Invalid password"); + }); + + it("rejects invalid JWE formats", async () => { + await expect(JWE.unseal("invalid.jwe.token", password)).rejects.toThrow( + "Invalid JWE token format", + ); + + await expect( + JWE.unseal("part1.part2.part3.part4.part5.extrastuff", password), + ).rejects.toThrow("Invalid JWE token format"); + }); + + it("rejects tampered tokens", async () => { + const sealed = await JWE.seal(testObject, password); + const parts = sealed.split("."); + + // Tamper with the ciphertext + const tamperedSeal = [ + parts[0], + parts[1], + parts[2], + parts[3] + "x", // tamper with ciphertext + parts[4], + ].join("."); + + await expect(JWE.unseal(tamperedSeal, password)).rejects.toThrow( + "Failed to decrypt JWE token", + ); + + // Tamper with the tag + const tamperedTag = [ + parts[0], + parts[1], + parts[2], + parts[3], + parts[4] + "x", // tamper with tag + ].join("."); + + await expect(JWE.unseal(tamperedTag, password)).rejects.toThrow( + "Failed to decrypt JWE token", + ); + }); + + it("rejects invalid JWE header", async () => { + const sealed = await JWE.seal(testObject, password); + const parts = sealed.split("."); + + // Replace header with invalid base64 + const invalidHeader = [ + "!@#$%^", // invalid base64 + parts[1], + parts[2], + parts[3], + parts[4], + ].join("."); + + await expect(JWE.unseal(invalidHeader, password)).rejects.toThrow( + "Invalid JWE header", + ); + }); + + it("rejects unsupported algorithms", async () => { + const sealed = await JWE.seal(testObject, password); + const parts = sealed.split("."); + + // Create a header with unsupported algorithm + const invalidAlgHeader = base64Encode( + JSON.stringify({ + alg: "unsupported", + enc: "A256GCM", + }), + ); + + const invalidAlgJWE = [ + invalidAlgHeader, + parts[1], + parts[2], + parts[3], + parts[4], + ].join("."); + + await expect(JWE.unseal(invalidAlgJWE, password)).rejects.toThrow( + "Unsupported JWE algorithms", + ); + }); + + it("handles complex nested objects", async () => { + const complexObj = { + array: [1, 2, 3, 4, 5], + nested: { + a: { b: { c: { d: { e: "deep" } } } }, + arr: [ + [1, 2], + [3, 4], + ], + }, + date: new Date().toISOString(), + nullValue: null, + booleans: [true, false], + }; + + const sealed = await JWE.seal(complexObj, password); + const unsealed = await JWE.unseal(sealed, password); + assert.deepEqual(unsealed, complexObj); + }); + + it("handles empty objects", async () => { + const sealed = await JWE.seal({}, password); + const unsealed = await JWE.unseal(sealed, password); + assert.deepEqual(unsealed, {}); + }); +}); + +// Helper function for encoding +function base64Encode(input: string): string { + return btoa(input).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} From d4b246d092cc5a2e32b702355214edff3448a868 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sat, 22 Mar 2025 17:30:36 +0100 Subject: [PATCH 02/12] fix: jwe session exp and tests --- src/utils/internal/jwe.ts | 4 ++ test/unit/jwe.test.ts | 105 +++++++++++++++++++------------------- 2 files changed, 56 insertions(+), 53 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index 9b08dcdd0..e49b6bf38 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -213,6 +213,10 @@ export async function unseal( } // Check expiration + if (result.exp && typeof result.exp !== "number") { + throw new Error("Invalid expiration"); + } + if (result.exp && result.exp <= now - opts.timestampSkewSec * 1000) { throw new Error("Token expired"); } diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts index 8e7136f1e..979bc19cb 100644 --- a/test/unit/jwe.test.ts +++ b/test/unit/jwe.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, assert } from "vitest"; +import { describe, it, expect, assert, vi } from "vitest"; import * as JWE from "../../src/utils/internal/jwe"; +import { base64Encode } from "../../src/utils/internal/encoding"; const testObject = { a: 1, b: 2, c: [3, 4, 5], d: { e: "f" } }; const password = "some_not_random_password_that_is_also_long_enough"; @@ -11,53 +12,6 @@ describe("JWE", () => { assert.deepEqual(unsealed, testObject); }); - it("handles expiration correctly", async () => { - // Set a 100ms expiration - const options = { ttl: 100 }; - const sealed = await JWE.seal(testObject, password, options); - - // Should work immediately - const unsealed = await JWE.unseal(sealed, password, options); - assert.deepEqual(unsealed, testObject); - - // Should fail after expiration - await new Promise((resolve) => setTimeout(resolve, 110)); - await expect(JWE.unseal(sealed, password, options)).rejects.toThrow( - "Token expired", - ); - }); - - it("handles time offset correctly", async () => { - // Set a 100ms expiration with time offset into the future - const options = { ttl: 100, localtimeOffsetMsec: 200 }; - const sealed = await JWE.seal(testObject, password, options); - - // Should work with the same offset - const unsealed = await JWE.unseal(sealed, password, options); - assert.deepEqual(unsealed, testObject); - - // Should fail with a different offset that puts us past expiration - await expect( - JWE.unseal(sealed, password, { ttl: 100, localtimeOffsetMsec: 300 }), - ).rejects.toThrow("Token expired"); - }); - - it("handles timestamp skew correctly", async () => { - // Set a very short expiration - const options = { ttl: 1 }; - const sealed = await JWE.seal(testObject, password, options); - - // Wait for expiration - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Should still work with increased skew tolerance - const unsealed = await JWE.unseal(sealed, password, { - ...options, - timestampSkewSec: 10, - }); - assert.deepEqual(unsealed, testObject); - }); - it("rejects invalid passwords", async () => { const sealed = await JWE.seal(testObject, password); @@ -179,9 +133,54 @@ describe("JWE", () => { const unsealed = await JWE.unseal(sealed, password); assert.deepEqual(unsealed, {}); }); -}); -// Helper function for encoding -function base64Encode(input: string): string { - return btoa(input).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); -} + it("handles tokens with expiration", async () => { + const options = { ttl: 1000 }; // 1 second expiration + const sealed = await JWE.seal(testObject, password, options); + const unsealed = await JWE.unseal(sealed, password); + assert.deepEqual(unsealed, testObject); + }); + + it("handles tokens with expiration and time offset", async () => { + // Create token with negative time offset + const options = { ttl: 1000, localtimeOffsetMsec: -100 }; + const sealed = await JWE.seal(testObject, password, options); + + // Unseal with same offset + const unsealed = await JWE.unseal(sealed, password, { + localtimeOffsetMsec: -100, + }); + assert.deepEqual(unsealed, testObject); + }); + + it("rejects expired tokens", async () => { + vi.useFakeTimers(); + const date = new Date(2025, 1, 1, 12); + vi.setSystemTime(date); + + // Create token with very short expiration + const options = { ttl: 1 }; // 1ms expiration + const sealed = await JWE.seal(testObject, password, options); + + // Advance time by 10ms + default's `timestampSkewSec` (60s) + vi.advanceTimersByTime(61 * 1000); + + await expect(JWE.unseal(sealed, password)).rejects.toThrow("Token expired"); + vi.useRealTimers(); + }); + + it("allows token within skew period", async () => { + // Create a token that's just expired + const options = { ttl: 1 }; // 1ms expiration + const sealed = await JWE.seal(testObject, password, options); + + // Wait for token to expire + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should still work with generous skew + const unsealed = await JWE.unseal(sealed, password, { + timestampSkewSec: 60, + }); + assert.deepEqual(unsealed, testObject); + }); +}); From 8e3a4e3496f56f1cb181d7a94c2d792f78f940b9 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sat, 22 Mar 2025 21:34:27 +0100 Subject: [PATCH 03/12] refactor(`jwe`): rewrite into utility functions --- src/types/utils/jwt.ts | 131 +++++++++++++++ src/utils/internal/jwe.ts | 334 +++++++++++++++++++++++++------------- src/utils/session.ts | 6 +- test/unit/jwe.test.ts | 2 +- 4 files changed, 358 insertions(+), 115 deletions(-) create mode 100644 src/types/utils/jwt.ts diff --git a/src/types/utils/jwt.ts b/src/types/utils/jwt.ts new file mode 100644 index 000000000..d6dd72f87 --- /dev/null +++ b/src/types/utils/jwt.ts @@ -0,0 +1,131 @@ +/** +Based on https://github.com/panva/jose/tree/v6.0.10 +Copyright (c) 2018 Filip Skokan. +https://github.com/panva/jose/blob/v6.0.10/LICENSE.md + */ + +/** Generic JSON Web Key Parameters. */ +export interface JWKParameters { + /** JWK "kty" (Key Type) Parameter */ + kty?: string; + /** + * JWK "alg" (Algorithm) Parameter + * + * @see {@link https://github.com/panva/jose/issues/210 Algorithm Key Requirements} + */ + alg?: string; + /** JWK "key_ops" (Key Operations) Parameter */ + key_ops?: string[]; + /** JWK "ext" (Extractable) Parameter */ + ext?: boolean; + /** JWK "use" (Public Key Use) Parameter */ + use?: string; + /** JWK "x5c" (X.509 Certificate Chain) Parameter */ + x5c?: string[]; + /** JWK "x5t" (X.509 Certificate SHA-1 Thumbprint) Parameter */ + x5t?: string; + /** JWK "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Parameter */ + "x5t#S256"?: string; + /** JWK "x5u" (X.509 URL) Parameter */ + x5u?: string; + /** JWK "kid" (Key ID) Parameter */ + kid?: string; +} + +/** + * JSON Web Key ({@link https://www.rfc-editor.org/rfc/rfc7517 JWK}). "RSA", "EC", "OKP", and "oct" + * key types are supported. + * + * @see {@link JWK_OKP_Public} + * @see {@link JWK_OKP_Private} + * @see {@link JWK_EC_Public} + * @see {@link JWK_EC_Private} + * @see {@link JWK_RSA_Public} + * @see {@link JWK_RSA_Private} + * @see {@link JWK_oct} + */ +export interface JWK extends JWKParameters { + /** + * - EC JWK "crv" (Curve) Parameter + * - OKP JWK "crv" (The Subtype of Key Pair) Parameter + */ + crv?: string; + /** + * - Private RSA JWK "d" (Private Exponent) Parameter + * - Private EC JWK "d" (ECC Private Key) Parameter + * - Private OKP JWK "d" (The Private Key) Parameter + */ + d?: string; + /** Private RSA JWK "dp" (First Factor CRT Exponent) Parameter */ + dp?: string; + /** Private RSA JWK "dq" (Second Factor CRT Exponent) Parameter */ + dq?: string; + /** RSA JWK "e" (Exponent) Parameter */ + e?: string; + /** Oct JWK "k" (Key Value) Parameter */ + k?: string; + /** RSA JWK "n" (Modulus) Parameter */ + n?: string; + /** Private RSA JWK "p" (First Prime Factor) Parameter */ + p?: string; + /** Private RSA JWK "q" (Second Prime Factor) Parameter */ + q?: string; + /** Private RSA JWK "qi" (First CRT Coefficient) Parameter */ + qi?: string; + /** + * - EC JWK "x" (X Coordinate) Parameter + * - OKP JWK "x" (The public key) Parameter + */ + x?: string; + /** EC JWK "y" (Y Coordinate) Parameter */ + y?: string; +} + +/** Header Parameters common to JWE and JWS */ +export interface JoseHeaderParameters { + /** "kid" (Key ID) Header Parameter */ + kid?: string; + + /** "x5t" (X.509 Certificate SHA-1 Thumbprint) Header Parameter */ + x5t?: string; + + /** "x5c" (X.509 Certificate Chain) Header Parameter */ + x5c?: string[]; + + /** "x5u" (X.509 URL) Header Parameter */ + x5u?: string; + + /** "jku" (JWK Set URL) Header Parameter */ + jku?: string; + + /** "jwk" (JSON Web Key) Header Parameter */ + jwk?: Pick; + + /** "typ" (Type) Header Parameter */ + typ?: string; + + /** "cty" (Content Type) Header Parameter */ + cty?: string; +} + +/** Recognized JWS Header Parameters, any other Header Members may also be present. */ +export interface JWSHeaderParameters extends JoseHeaderParameters { + /** + * JWS "alg" (Algorithm) Header Parameter + * + * @see {@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements} + */ + alg?: string; + + /** + * This JWS Extension Header Parameter modifies the JWS Payload representation and the JWS Signing + * Input computation as per {@link https://www.rfc-editor.org/rfc/rfc7797 RFC7797}. + */ + b64?: boolean; + + /** JWS "crit" (Critical) Header Parameter */ + crit?: string[]; + + /** Any other JWS Header member. */ + [propName: string]: unknown; +} diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index e49b6bf38..3504be015 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -5,11 +5,11 @@ import { textDecoder, textEncoder, } from "./encoding"; +import type { JWSHeaderParameters } from "../../types/utils/jwt"; /** * JWE (JSON Web Encryption) implementation for H3 sessions */ - export interface JWEOptions { /** Expiration time in milliseconds where 0 means forever. Defaults to 0. */ ttl: number; @@ -17,25 +17,18 @@ export interface JWEOptions { timestampSkewSec: number; /** Local clock time offset in milliseconds. Defaults to 0. */ localtimeOffsetMsec: number; + /** Iteration count for PBKDF2. Defaults to 8192. */ + pbkdf2Iterations?: number; } -export const DEFAULT_JWE_OPTIONS: JWEOptions = { - ttl: 0, - timestampSkewSec: 60, - localtimeOffsetMsec: 0, -}; - -export interface JWEHeader { - alg: string; - enc: string; -} - -export interface JWESegments { - protected: string; - iv: string; - ciphertext: string; - tag: string; -} +/** The default settings. */ +export const defaults: Readonly> = + /* @__PURE__ */ Object.freeze({ + ttl: 0, + timestampSkewSec: 60, + localtimeOffsetMsec: 0, + pbkdf2Iterations: 8192, + }); /** * Encrypt and serialize data into a JWE token @@ -49,61 +42,34 @@ export async function seal( throw new Error("Invalid password"); } - const opts = { ...DEFAULT_JWE_OPTIONS, ...options }; - const now = Date.now() + opts.localtimeOffsetMsec; + const opts = { ...defaults, ...options }; + const iterations = opts.pbkdf2Iterations || defaults.pbkdf2Iterations; - // Add expiration if ttl is provided - const payloadWithMeta = { - payload, - iat: now, - ...(opts.ttl ? { exp: now + opts.ttl } : {}), - }; + // Prepare payload with metadata + const payloadWithMeta = createPayloadWithMeta(payload, opts); - // Generate a random IV and salt + // Generate random values const iv = crypto.getRandomValues(new Uint8Array(16)); const salt = crypto.getRandomValues(new Uint8Array(16)); - // Key derivation - const keyMaterial = await crypto.subtle.importKey( - "raw", - textEncoder.encode(password), - { name: "PBKDF2" }, - false, - ["deriveBits", "deriveKey"], + // Generate encryption keys + const cek = await generateCEK(); + const passwordDerivedKey = await deriveKeyFromPassword( + password, + salt, + iterations, ); - const key = await crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt, - iterations: 100_000, - hash: "SHA-256", - }, - keyMaterial, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt"], - ); - - // Create protected header - const protectedHeader: JWEHeader = { - alg: "PBES2-HS256+A128KW", - enc: "A256GCM", - }; - + // Create and encode header + const protectedHeader = createProtectedHeader(salt, iterations); const protectedHeaderB64 = base64Encode(JSON.stringify(protectedHeader)); // Encrypt payload const plaintext = textEncoder.encode(JSON.stringify(payloadWithMeta)); - const encryptedData = await crypto.subtle.encrypt( - { - name: "AES-GCM", - iv, - tagLength: 128, - }, - key, - plaintext, - ); + const encryptedData = await encryptData(cek, plaintext, iv); + + // Wrap the CEK with password-derived key + const wrappedCek = await wrapCEK(cek, passwordDerivedKey); // Split ciphertext and authentication tag const encryptedDataArray = new Uint8Array(encryptedData); @@ -111,8 +77,8 @@ export async function seal( const ciphertext = encryptedDataArray.slice(0, ciphertextLength); const tag = encryptedDataArray.slice(ciphertextLength); - // Format as compact JWE: header.salt.iv.ciphertext.tag - return `${protectedHeaderB64}.${base64Encode(salt)}.${base64Encode(iv)}.${base64Encode(ciphertext)}.${base64Encode(tag)}`; + // Format as compact JWE + return `${protectedHeaderB64}.${base64Encode(wrappedCek)}.${base64Encode(iv)}.${base64Encode(ciphertext)}.${base64Encode(tag)}`; } /** @@ -127,7 +93,7 @@ export async function unseal( throw new Error("Invalid password"); } - const opts = { ...DEFAULT_JWE_OPTIONS, ...options }; + const opts = { ...defaults, ...options }; const now = Date.now() + opts.localtimeOffsetMsec; // Split the JWE token @@ -136,27 +102,25 @@ export async function unseal( throw new Error("Invalid JWE token format"); } - const [protectedHeaderB64, saltB64, ivB64, ciphertextB64, tagB64] = parts; + const [protectedHeaderB64, encryptedKeyB64, ivB64, ciphertextB64, tagB64] = + parts; - // Decode and validate protected header - let protectedHeader: JWEHeader; - try { - protectedHeader = JSON.parse( - textDecoder.decode(base64Decode(protectedHeaderB64)), - ) as JWEHeader; - } catch { - throw new Error("Invalid JWE header"); - } + // Parse and validate header + const protectedHeader = parseJWEHeader(protectedHeaderB64); - if ( - protectedHeader.alg !== "PBES2-HS256+A128KW" || - protectedHeader.enc !== "A256GCM" - ) { - throw new Error("Unsupported JWE algorithms"); - } + // Get parameters from header + const salt = + typeof protectedHeader.p2s === "string" + ? base64Decode(protectedHeader.p2s) + : new Uint8Array(16); + + const iterations = + typeof protectedHeader.p2c === "number" + ? protectedHeader.p2c + : defaults.pbkdf2Iterations; - // Reconstruct the key - const salt = base64Decode(saltB64); + // Decode components + const encryptedKey = base64Decode(encryptedKeyB64); const iv = base64Decode(ivB64); const ciphertext = base64Decode(ciphertextB64); const tag = base64Decode(tagB64); @@ -166,7 +130,65 @@ export async function unseal( encryptedData.set(ciphertext); encryptedData.set(tag, ciphertext.length); - // Derive key + // Derive key from password + const passwordDerivedKey = await deriveKeyFromPassword( + password, + salt, + iterations, + ); + + // Unwrap the CEK + let cek; + try { + cek = await unwrapCEK(encryptedKey, passwordDerivedKey); + } catch { + throw new Error("Failed to decrypt JWE token: Invalid key"); + } + + // Decrypt the payload + let decrypted; + try { + decrypted = await decryptData(cek, encryptedData, iv); + } catch { + throw new Error("Failed to decrypt JWE token: Invalid data"); + } + + // Parse and validate + let result; + try { + result = JSON.parse(textDecoder.decode(decrypted)); + } catch { + throw new Error("Invalid JWE payload format"); + } + + // Check expiration + validateExpiration(result.exp, now, opts.timestampSkewSec); + + return result.payload; +} + +// Utility functions + +/** + * Creates a payload object with metadata including timestamp and expiration + */ +function createPayloadWithMeta(payload: unknown, opts: JWEOptions) { + const now = Date.now() + opts.localtimeOffsetMsec; + return { + payload, + iat: now, + ...(opts.ttl ? { exp: now + opts.ttl } : {}), + }; +} + +/** + * Derives a key from password using PBKDF2 + */ +async function deriveKeyFromPassword( + password: string, + salt: Uint8Array, + iterations: number, +) { const keyMaterial = await crypto.subtle.importKey( "raw", textEncoder.encode(password), @@ -175,51 +197,141 @@ export async function unseal( ["deriveBits", "deriveKey"], ); - const key = await crypto.subtle.deriveKey( + return crypto.subtle.deriveKey( { name: "PBKDF2", salt, - iterations: 100_000, + iterations, hash: "SHA-256", }, keyMaterial, - { name: "AES-GCM", length: 256 }, + { name: "AES-KW", length: 128 }, false, + ["wrapKey", "unwrapKey"], + ); +} + +/** + * Generates content encryption key + */ +async function generateCEK() { + return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ + "encrypt", + "decrypt", + ]); +} + +/** + * Creates a protected header for JWE + */ +function createProtectedHeader( + salt: Uint8Array, + iterations: number, +): JWSHeaderParameters { + return Object.freeze({ + alg: "PBES2-HS256+A128KW", + enc: "A256GCM", + p2s: base64Encode(salt), + p2c: iterations, + typ: "JWT", + cty: "application/json", + }); +} + +/** + * Encrypts data using AES-GCM + */ +async function encryptData(cek: CryptoKey, data: Uint8Array, iv: Uint8Array) { + return crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + cek, + data, + ); +} + +/** + * Decrypts data using AES-GCM + */ +async function decryptData( + cek: CryptoKey, + encryptedData: Uint8Array, + iv: Uint8Array, +) { + return crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + tagLength: 128, + }, + cek, + encryptedData, + ); +} + +/** + * Wraps (encrypts) the Content Encryption Key + */ +async function wrapCEK(cek: CryptoKey, wrappingKey: CryptoKey) { + const rawCek = await crypto.subtle.exportKey("raw", cek); + + const importedCek = await crypto.subtle.importKey( + "raw", + rawCek, + { name: "AES-GCM", length: 256 }, + true, + ["encrypt"], + ); + + return crypto.subtle.wrapKey("raw", importedCek, wrappingKey, { + name: "AES-KW", + }); +} + +/** + * Unwraps (decrypts) the Content Encryption Key + */ +async function unwrapCEK(wrappedCek: Uint8Array, wrappingKey: CryptoKey) { + return crypto.subtle.unwrapKey( + "raw", + wrappedCek, + wrappingKey, + { name: "AES-KW" }, + { name: "AES-GCM", length: 256 }, + true, ["decrypt"], ); +} - // Decrypt - let decrypted; +/** + * Parses and verifies a JWE token's header + */ +function parseJWEHeader(headerB64: string): JWSHeaderParameters { try { - decrypted = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv, - tagLength: 128, - }, - key, - encryptedData, - ); - } catch { - throw new Error("Failed to decrypt JWE token"); - } + const header = JSON.parse(textDecoder.decode(base64Decode(headerB64))); - // Parse and validate - let result; - try { - result = JSON.parse(textDecoder.decode(decrypted)); + if (header.alg !== "PBES2-HS256+A128KW" || header.enc !== "A256GCM") { + throw new Error("Unsupported JWE algorithms"); + } + + return header; } catch { - throw new Error("Invalid JWE payload format"); + throw new Error("Invalid JWE header"); } +} - // Check expiration - if (result.exp && typeof result.exp !== "number") { +/** + * Validates the expiration of a token + */ +function validateExpiration(exp: unknown, now: number, skew: number) { + if (exp && typeof exp !== "number") { throw new Error("Invalid expiration"); } - if (result.exp && result.exp <= now - opts.timestampSkewSec * 1000) { + if (typeof exp === "number" && exp <= now - skew * 1000) { throw new Error("Token expired"); } - - return result.payload; } diff --git a/src/utils/session.ts b/src/utils/session.ts index 50af31c08..96c9c76c5 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -1,5 +1,5 @@ import type { H3Event, Session, SessionConfig, SessionData } from "../types"; -import { seal, unseal, DEFAULT_JWE_OPTIONS } from "./internal/jwe"; +import { seal, unseal, defaults } from "./internal/jwe"; import { getCookie, setCookie } from "./cookie"; import { DEFAULT_SESSION_NAME, @@ -163,7 +163,7 @@ export async function sealSession( (await getSession(event, config)); const sealed = await seal(session, config.password, { - ...DEFAULT_JWE_OPTIONS, + ...defaults, ttl: config.maxAge ? config.maxAge * 1000 : 0, ...config.jwe, }); @@ -180,7 +180,7 @@ export async function unsealSession( sealed: string, ) { const unsealed = (await unseal(sealed, config.password, { - ...DEFAULT_JWE_OPTIONS, + ...defaults, ttl: config.maxAge ? config.maxAge * 1000 : 0, ...config.jwe, })) as Partial; diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts index 979bc19cb..bc6dd63db 100644 --- a/test/unit/jwe.test.ts +++ b/test/unit/jwe.test.ts @@ -104,7 +104,7 @@ describe("JWE", () => { ].join("."); await expect(JWE.unseal(invalidAlgJWE, password)).rejects.toThrow( - "Unsupported JWE algorithms", + "Invalid JWE header", ); }); From 66b0d891303bdc968f2b83acf80a763f29fcfbb7 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sat, 22 Mar 2025 23:24:59 +0100 Subject: [PATCH 04/12] fix(`jwe`): p2s --- src/utils/internal/jwe.ts | 42 ++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index 3504be015..8a336fa33 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -19,6 +19,10 @@ export interface JWEOptions { localtimeOffsetMsec: number; /** Iteration count for PBKDF2. Defaults to 8192. */ pbkdf2Iterations?: number; + /** JWE algorithm. Defaults to "PBES2-HS256+A128KW". */ + algorithm: string; + /** JWE encryption algorithm. Defaults to "A256GCM". */ + encryption: string; } /** The default settings. */ @@ -28,6 +32,8 @@ export const defaults: Readonly> = timestampSkewSec: 60, localtimeOffsetMsec: 0, pbkdf2Iterations: 8192, + algorithm: "PBES2-HS256+A128KW", + encryption: "A256GCM", }); /** @@ -186,9 +192,16 @@ function createPayloadWithMeta(payload: unknown, opts: JWEOptions) { */ async function deriveKeyFromPassword( password: string, - salt: Uint8Array, + saltInput: Uint8Array, iterations: number, ) { + const algorithmId = defaults.algorithm; + // Construct the full salt as per RFC: (UTF8(Alg) || 0x00 || Salt Input) + const fullSalt = new Uint8Array(algorithmId.length + 1 + saltInput.length); + fullSalt.set(textEncoder.encode(algorithmId), 0); + fullSalt.set([0x00], algorithmId.length); + fullSalt.set(saltInput, algorithmId.length + 1); + const keyMaterial = await crypto.subtle.importKey( "raw", textEncoder.encode(password), @@ -200,7 +213,7 @@ async function deriveKeyFromPassword( return crypto.subtle.deriveKey( { name: "PBKDF2", - salt, + salt: fullSalt, iterations, hash: "SHA-256", }, @@ -225,13 +238,13 @@ async function generateCEK() { * Creates a protected header for JWE */ function createProtectedHeader( - salt: Uint8Array, + saltInput: Uint8Array, iterations: number, ): JWSHeaderParameters { return Object.freeze({ - alg: "PBES2-HS256+A128KW", - enc: "A256GCM", - p2s: base64Encode(salt), + alg: defaults.algorithm, + enc: defaults.encryption, + p2s: base64Encode(saltInput), p2c: iterations, typ: "JWT", cty: "application/json", @@ -276,17 +289,7 @@ async function decryptData( * Wraps (encrypts) the Content Encryption Key */ async function wrapCEK(cek: CryptoKey, wrappingKey: CryptoKey) { - const rawCek = await crypto.subtle.exportKey("raw", cek); - - const importedCek = await crypto.subtle.importKey( - "raw", - rawCek, - { name: "AES-GCM", length: 256 }, - true, - ["encrypt"], - ); - - return crypto.subtle.wrapKey("raw", importedCek, wrappingKey, { + return crypto.subtle.wrapKey("raw", cek, wrappingKey, { name: "AES-KW", }); } @@ -313,7 +316,10 @@ function parseJWEHeader(headerB64: string): JWSHeaderParameters { try { const header = JSON.parse(textDecoder.decode(base64Decode(headerB64))); - if (header.alg !== "PBES2-HS256+A128KW" || header.enc !== "A256GCM") { + if ( + header.alg !== defaults.algorithm || + header.enc !== defaults.encryption + ) { throw new Error("Unsupported JWE algorithms"); } From 06417360662180217573d05415224ffdba9dbe22 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 00:51:30 +0100 Subject: [PATCH 05/12] fix(`jwe`): support encrypting any payload --- src/types/utils/session.ts | 4 ++ src/utils/internal/jwe.ts | 128 ++++++++++++++----------------------- src/utils/session.ts | 58 ++++++++++++----- test/unit/jwe.test.ts | 88 +++++++++++++------------ 4 files changed, 140 insertions(+), 138 deletions(-) diff --git a/src/types/utils/session.ts b/src/types/utils/session.ts index e76e13cf6..8874f3752 100644 --- a/src/types/utils/session.ts +++ b/src/types/utils/session.ts @@ -26,6 +26,10 @@ export interface SessionConfig { sessionHeader?: false | string; /** JWE options for encryption/decryption */ jwe?: Partial; + /** Time skew tolerance in seconds */ + timestampSkewSec?: number; + /** Local time offset in milliseconds */ + localtimeOffsetMsec?: number; crypto?: Crypto; /** Default is Crypto.randomUUID */ generateId?: () => string; diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index 8a336fa33..088c7ad36 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -10,37 +10,29 @@ import type { JWSHeaderParameters } from "../../types/utils/jwt"; /** * JWE (JSON Web Encryption) implementation for H3 sessions */ -export interface JWEOptions { - /** Expiration time in milliseconds where 0 means forever. Defaults to 0. */ - ttl: number; - /** Number of seconds of permitted clock skew for incoming expirations. Defaults to 60 seconds. */ - timestampSkewSec: number; - /** Local clock time offset in milliseconds. Defaults to 0. */ - localtimeOffsetMsec: number; +export interface JWEOptions extends JWSHeaderParameters { /** Iteration count for PBKDF2. Defaults to 8192. */ - pbkdf2Iterations?: number; + p2c?: number; /** JWE algorithm. Defaults to "PBES2-HS256+A128KW". */ - algorithm: string; + alg?: string; /** JWE encryption algorithm. Defaults to "A256GCM". */ - encryption: string; + enc?: string; } /** The default settings. */ -export const defaults: Readonly> = - /* @__PURE__ */ Object.freeze({ - ttl: 0, - timestampSkewSec: 60, - localtimeOffsetMsec: 0, - pbkdf2Iterations: 8192, - algorithm: "PBES2-HS256+A128KW", - encryption: "A256GCM", - }); +export const defaults: Readonly< + JWEOptions & Pick, "p2c" | "alg" | "enc"> +> = /* @__PURE__ */ Object.freeze({ + p2c: 8192, + alg: "PBES2-HS256+A128KW", + enc: "A256GCM", +}); /** * Encrypt and serialize data into a JWE token */ export async function seal( - payload: unknown, + payload: any, password: string, options: Partial = {}, ): Promise { @@ -49,10 +41,7 @@ export async function seal( } const opts = { ...defaults, ...options }; - const iterations = opts.pbkdf2Iterations || defaults.pbkdf2Iterations; - - // Prepare payload with metadata - const payloadWithMeta = createPayloadWithMeta(payload, opts); + const iterations = opts.p2c; // Generate random values const iv = crypto.getRandomValues(new Uint8Array(16)); @@ -64,14 +53,15 @@ export async function seal( password, salt, iterations, + opts.alg, ); // Create and encode header - const protectedHeader = createProtectedHeader(salt, iterations); + const protectedHeader = createProtectedHeader(salt, iterations, opts); const protectedHeaderB64 = base64Encode(JSON.stringify(protectedHeader)); - // Encrypt payload - const plaintext = textEncoder.encode(JSON.stringify(payloadWithMeta)); + // Serialize and encrypt payload + const plaintext = textEncoder.encode(JSON.stringify(payload)); const encryptedData = await encryptData(cek, plaintext, iv); // Wrap the CEK with password-derived key @@ -94,13 +84,12 @@ export async function unseal( token: string, password: string, options: Partial = {}, -): Promise { +): Promise { if (!password || typeof password !== "string") { throw new Error("Invalid password"); } const opts = { ...defaults, ...options }; - const now = Date.now() + opts.localtimeOffsetMsec; // Split the JWE token const parts = token.split("."); @@ -112,7 +101,7 @@ export async function unseal( parts; // Parse and validate header - const protectedHeader = parseJWEHeader(protectedHeaderB64); + const protectedHeader = parseJWEHeader(protectedHeaderB64, opts); // Get parameters from header const salt = @@ -121,9 +110,7 @@ export async function unseal( : new Uint8Array(16); const iterations = - typeof protectedHeader.p2c === "number" - ? protectedHeader.p2c - : defaults.pbkdf2Iterations; + typeof protectedHeader.p2c === "number" ? protectedHeader.p2c : opts.p2c; // Decode components const encryptedKey = base64Decode(encryptedKeyB64); @@ -131,6 +118,14 @@ export async function unseal( const ciphertext = base64Decode(ciphertextB64); const tag = base64Decode(tagB64); + // Verify the original base64 matches the re-encoded data to detect tampering + if ( + base64Encode(ciphertext) !== ciphertextB64 || + base64Encode(tag) !== tagB64 + ) { + throw new Error("Failed to decrypt JWE token: Token has been tampered"); + } + // Combine ciphertext and tag for decryption const encryptedData = new Uint8Array(ciphertext.length + tag.length); encryptedData.set(ciphertext); @@ -141,6 +136,7 @@ export async function unseal( password, salt, iterations, + protectedHeader.alg as string, ); // Unwrap the CEK @@ -159,34 +155,16 @@ export async function unseal( throw new Error("Failed to decrypt JWE token: Invalid data"); } - // Parse and validate - let result; + // Parse the decrypted data try { - result = JSON.parse(textDecoder.decode(decrypted)); + return JSON.parse(textDecoder.decode(decrypted)); } catch { throw new Error("Invalid JWE payload format"); } - - // Check expiration - validateExpiration(result.exp, now, opts.timestampSkewSec); - - return result.payload; } // Utility functions -/** - * Creates a payload object with metadata including timestamp and expiration - */ -function createPayloadWithMeta(payload: unknown, opts: JWEOptions) { - const now = Date.now() + opts.localtimeOffsetMsec; - return { - payload, - iat: now, - ...(opts.ttl ? { exp: now + opts.ttl } : {}), - }; -} - /** * Derives a key from password using PBKDF2 */ @@ -194,13 +172,13 @@ async function deriveKeyFromPassword( password: string, saltInput: Uint8Array, iterations: number, + alg: string, ) { - const algorithmId = defaults.algorithm; // Construct the full salt as per RFC: (UTF8(Alg) || 0x00 || Salt Input) - const fullSalt = new Uint8Array(algorithmId.length + 1 + saltInput.length); - fullSalt.set(textEncoder.encode(algorithmId), 0); - fullSalt.set([0x00], algorithmId.length); - fullSalt.set(saltInput, algorithmId.length + 1); + const fullSalt = new Uint8Array(alg.length + 1 + saltInput.length); + fullSalt.set(textEncoder.encode(alg), 0); + fullSalt.set([0x00], alg.length); + fullSalt.set(saltInput, alg.length + 1); const keyMaterial = await crypto.subtle.importKey( "raw", @@ -240,15 +218,16 @@ async function generateCEK() { function createProtectedHeader( saltInput: Uint8Array, iterations: number, + options: JWEOptions, ): JWSHeaderParameters { - return Object.freeze({ - alg: defaults.algorithm, - enc: defaults.encryption, + return { + ...options, + alg: options.alg, + enc: options.enc, p2s: base64Encode(saltInput), p2c: iterations, typ: "JWT", - cty: "application/json", - }); + }; } /** @@ -312,14 +291,14 @@ async function unwrapCEK(wrappedCek: Uint8Array, wrappingKey: CryptoKey) { /** * Parses and verifies a JWE token's header */ -function parseJWEHeader(headerB64: string): JWSHeaderParameters { +function parseJWEHeader( + headerB64: string, + options: JWEOptions, +): JWSHeaderParameters { try { const header = JSON.parse(textDecoder.decode(base64Decode(headerB64))); - if ( - header.alg !== defaults.algorithm || - header.enc !== defaults.encryption - ) { + if (header.alg !== options.alg || header.enc !== options.enc) { throw new Error("Unsupported JWE algorithms"); } @@ -328,16 +307,3 @@ function parseJWEHeader(headerB64: string): JWSHeaderParameters { throw new Error("Invalid JWE header"); } } - -/** - * Validates the expiration of a token - */ -function validateExpiration(exp: unknown, now: number, skew: number) { - if (exp && typeof exp !== "number") { - throw new Error("Invalid expiration"); - } - - if (typeof exp === "number" && exp <= now - skew * 1000) { - throw new Error("Token expired"); - } -} diff --git a/src/utils/session.ts b/src/utils/session.ts index 96c9c76c5..fdd6b7878 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -1,5 +1,5 @@ import type { H3Event, Session, SessionConfig, SessionData } from "../types"; -import { seal, unseal, defaults } from "./internal/jwe"; +import { seal, unseal } from "./internal/jwe"; import { getCookie, setCookie } from "./cookie"; import { DEFAULT_SESSION_NAME, @@ -8,6 +8,12 @@ import { } from "./internal/session"; import { EmptyObject } from "./internal/obj"; +// Session defaults for time-related options +const SESSION_DEFAULTS = { + timestampSkewSec: 60, + localtimeOffsetMsec: 0, +}; + /** * Create a session manager for the current request. * @@ -162,11 +168,17 @@ export async function sealSession( (event.context.sessions?.[sessionName] as Session) || (await getSession(event, config)); - const sealed = await seal(session, config.password, { - ...defaults, - ttl: config.maxAge ? config.maxAge * 1000 : 0, - ...config.jwe, - }); + // Add timestamp metadata + const now = + Date.now() + + (config.localtimeOffsetMsec || SESSION_DEFAULTS.localtimeOffsetMsec); + const payload = { + session, + iat: now, + ...(config.maxAge ? { exp: now + config.maxAge * 1000 } : {}), + }; + + const sealed = await seal(payload, config.password, config.jwe); return sealed; } @@ -179,18 +191,30 @@ export async function unsealSession( config: SessionConfig, sealed: string, ) { - const unsealed = (await unseal(sealed, config.password, { - ...defaults, - ttl: config.maxAge ? config.maxAge * 1000 : 0, - ...config.jwe, - })) as Partial; - if (config.maxAge) { - const age = Date.now() - (unsealed.createdAt || Number.NEGATIVE_INFINITY); - if (age > config.maxAge * 1000) { - throw new Error("Session expired!"); - } + const now = + Date.now() + + (config.localtimeOffsetMsec || SESSION_DEFAULTS.localtimeOffsetMsec); + const timestampSkewSec = + config.timestampSkewSec || SESSION_DEFAULTS.timestampSkewSec; + + // Decrypt the payload + const payload = await unseal(sealed, config.password, config.jwe); + + // Type check for expected format + if (!payload || typeof payload !== "object" || !payload.session) { + throw new Error("Invalid session format"); + } + + // Verify expiration + if ( + payload.exp && + typeof payload.exp === "number" && + payload.exp <= now - timestampSkewSec * 1000 + ) { + throw new Error("Session expired"); } - return unsealed; + + return payload.session; } /** diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts index bc6dd63db..714250cb5 100644 --- a/test/unit/jwe.test.ts +++ b/test/unit/jwe.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, assert, vi } from "vitest"; +import { describe, it, expect, assert } from "vitest"; import * as JWE from "../../src/utils/internal/jwe"; import { base64Encode } from "../../src/utils/internal/encoding"; @@ -12,6 +12,32 @@ describe("JWE", () => { assert.deepEqual(unsealed, testObject); }); + it("seals and unseals primitive values correctly", async () => { + // Test with string + const stringValue = "Just a simple string"; + const sealedString = await JWE.seal(stringValue, password); + const unsealedString = await JWE.unseal(sealedString, password); + assert.equal(unsealedString, stringValue); + + // Test with number + const numberValue = 12_345; + const sealedNumber = await JWE.seal(numberValue, password); + const unsealedNumber = await JWE.unseal(sealedNumber, password); + assert.equal(unsealedNumber, numberValue); + + // Test with boolean + const boolValue = true; + const sealedBool = await JWE.seal(boolValue, password); + const unsealedBool = await JWE.unseal(sealedBool, password); + assert.equal(unsealedBool, boolValue); + + // Test with null + const nullValue = null; + const sealedNull = await JWE.seal(nullValue, password); + const unsealedNull = await JWE.unseal(sealedNull, password); + assert.equal(unsealedNull, nullValue); + }); + it("rejects invalid passwords", async () => { const sealed = await JWE.seal(testObject, password); @@ -134,53 +160,35 @@ describe("JWE", () => { assert.deepEqual(unsealed, {}); }); - it("handles tokens with expiration", async () => { - const options = { ttl: 1000 }; // 1 second expiration - const sealed = await JWE.seal(testObject, password, options); - const unsealed = await JWE.unseal(sealed, password); - assert.deepEqual(unsealed, testObject); - }); + it("supports custom headers", async () => { + const options = { + kid: "test-key-id", + customHeader: "custom-value", + }; - it("handles tokens with expiration and time offset", async () => { - // Create token with negative time offset - const options = { ttl: 1000, localtimeOffsetMsec: -100 }; const sealed = await JWE.seal(testObject, password, options); + const parts = sealed.split("."); - // Unseal with same offset - const unsealed = await JWE.unseal(sealed, password, { - localtimeOffsetMsec: -100, - }); - assert.deepEqual(unsealed, testObject); - }); - - it("rejects expired tokens", async () => { - vi.useFakeTimers(); - const date = new Date(2025, 1, 1, 12); - vi.setSystemTime(date); - - // Create token with very short expiration - const options = { ttl: 1 }; // 1ms expiration - const sealed = await JWE.seal(testObject, password, options); + // Decode the header to verify it contains our custom values + const headerJson = atob(parts[0]); + const header = JSON.parse(headerJson); - // Advance time by 10ms + default's `timestampSkewSec` (60s) - vi.advanceTimersByTime(61 * 1000); + assert.equal(header.kid, "test-key-id"); + assert.equal(header.customHeader, "custom-value"); - await expect(JWE.unseal(sealed, password)).rejects.toThrow("Token expired"); - vi.useRealTimers(); + // Verify we can still decrypt with the custom headers + const unsealed = await JWE.unseal(sealed, password); + assert.deepEqual(unsealed, testObject); }); - it("allows token within skew period", async () => { - // Create a token that's just expired - const options = { ttl: 1 }; // 1ms expiration - const sealed = await JWE.seal(testObject, password, options); - - // Wait for token to expire - await new Promise((resolve) => setTimeout(resolve, 10)); + it("allows changing encryption algorithm", async () => { + const options = { + enc: "A256GCM", // Same as default, but explicitly set + p2c: 1024, // Lower iterations for testing + }; - // Should still work with generous skew - const unsealed = await JWE.unseal(sealed, password, { - timestampSkewSec: 60, - }); + const sealed = await JWE.seal(testObject, password, options); + const unsealed = await JWE.unseal(sealed, password, options); assert.deepEqual(unsealed, testObject); }); }); From d0d0de25709b01fab2f7518ea9e4544b8b140b7f Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 14:03:19 +0100 Subject: [PATCH 06/12] refactor(jwe): simplify logic and improve cross-compatibility --- src/utils/internal/jwe.ts | 481 +++++++++++++++++++------------------- src/utils/session.ts | 5 +- test/unit/jwe.test.ts | 124 +++++----- 3 files changed, 307 insertions(+), 303 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index 088c7ad36..7681d6894 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -1,309 +1,316 @@ -import crypto from "uncrypto"; -import { - base64Decode, - base64Encode, - textDecoder, - textEncoder, -} from "./encoding"; +import { subtle, getRandomValues } from "uncrypto"; import type { JWSHeaderParameters } from "../../types/utils/jwt"; +import { textEncoder, textDecoder } from "./encoding"; + /** * JWE (JSON Web Encryption) implementation for H3 sessions */ export interface JWEOptions extends JWSHeaderParameters { /** Iteration count for PBKDF2. Defaults to 8192. */ p2c?: number; - /** JWE algorithm. Defaults to "PBES2-HS256+A128KW". */ - alg?: string; + /** Base64-encoded salt for PBKDF2. */ + p2s?: string; /** JWE encryption algorithm. Defaults to "A256GCM". */ enc?: string; } /** The default settings. */ export const defaults: Readonly< - JWEOptions & Pick, "p2c" | "alg" | "enc"> + JWEOptions & + Pick, "p2c" | "alg" | "enc"> & { saltSize: number } > = /* @__PURE__ */ Object.freeze({ - p2c: 8192, + saltSize: 16, + p2c: 2048, alg: "PBES2-HS256+A128KW", enc: "A256GCM", }); +// Base64 URL encoding/decoding functions +function base64UrlEncode(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function base64UrlDecode(str: string): Uint8Array { + str = str.replace(/-/g, "+").replace(/_/g, "/"); + while (str.length % 4) str += "="; + return new Uint8Array([...atob(str)].map((c) => c.charCodeAt(0))); +} + +// Generate a random Uint8Array of specified length +function randomBytes(length: number): Uint8Array { + const bytes = getRandomValues(new Uint8Array(length)); + return bytes; +} + /** - * Encrypt and serialize data into a JWE token + * Seal (encrypt) data using JWE with AES-GCM and PBES2-HS256+A128KW + * @param data The data to encrypt + * @param password The password to use for encryption + * @param options Optional parameters + * @returns Promise resolving to the compact JWE token */ export async function seal( - payload: any, + data: string | Uint8Array, password: string, - options: Partial = {}, + options: { + iterations?: number; + saltSize?: number; + protectedHeader?: Record; + } = {}, ): Promise { - if (!password || typeof password !== "string") { - throw new Error("Invalid password"); - } + // Configure options with defaults + const iterations = options.iterations || defaults.p2c; + const saltSize = options.saltSize || defaults.saltSize; + const protectedHeader = options.protectedHeader || {}; + + // Convert input data to Uint8Array if it's a string + const plaintext = typeof data === "string" ? textEncoder.encode(data) : data; + + // Generate random salt for PBES2 + const saltInput = randomBytes(saltSize); + + // Set up the protected header + const header = { + alg: "PBES2-HS256+A128KW", + enc: "A256GCM", + p2s: base64UrlEncode(saltInput), + p2c: iterations, + ...protectedHeader, + }; + + // Encode the protected header + const encodedHeader = base64UrlEncode( + textEncoder.encode(JSON.stringify(header)), + ); - const opts = { ...defaults, ...options }; - const iterations = opts.p2c; + // Derive key from password using PBKDF2 + const baseKey = await subtle.importKey( + "raw", + textEncoder.encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); - // Generate random values - const iv = crypto.getRandomValues(new Uint8Array(16)); - const salt = crypto.getRandomValues(new Uint8Array(16)); + // Concatenate 'PBES2-HS256+A128KW' + 00 + encoded saltInput + const salt = new Uint8Array([ + ...new TextEncoder().encode("PBES2-HS256+A128KW"), + 0, + ...saltInput, + ]); - // Generate encryption keys - const cek = await generateCEK(); - const passwordDerivedKey = await deriveKeyFromPassword( - password, - salt, - iterations, - opts.alg, + // Derive the key for key wrapping + const derivedKey = await subtle.deriveKey( + { + name: "PBKDF2", + hash: "SHA-256", + salt, + iterations, + }, + baseKey, + { name: "AES-KW", length: 128 }, + false, + ["wrapKey"], ); - // Create and encode header - const protectedHeader = createProtectedHeader(salt, iterations, opts); - const protectedHeaderB64 = base64Encode(JSON.stringify(protectedHeader)); + // Generate a random Content Encryption Key + const cek = await subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ + "encrypt", + "wrapKey", + "unwrapKey", + ]); - // Serialize and encrypt payload - const plaintext = textEncoder.encode(JSON.stringify(payload)); - const encryptedData = await encryptData(cek, plaintext, iv); + // Wrap the CEK using the derived key + const wrappedKey = await subtle.wrapKey("raw", cek, derivedKey, { + name: "AES-KW", + }); - // Wrap the CEK with password-derived key - const wrappedCek = await wrapCEK(cek, passwordDerivedKey); + // Generate random initialization vector for AES-GCM + const iv = randomBytes(12); - // Split ciphertext and authentication tag - const encryptedDataArray = new Uint8Array(encryptedData); - const ciphertextLength = encryptedDataArray.length - 16; // Last 16 bytes are the auth tag - const ciphertext = encryptedDataArray.slice(0, ciphertextLength); - const tag = encryptedDataArray.slice(ciphertextLength); + // Encrypt the plaintext + const ciphertext = await subtle.encrypt( + { + name: "AES-GCM", + iv, + additionalData: textEncoder.encode(encodedHeader), + }, + cek, + plaintext, + ); - // Format as compact JWE - return `${protectedHeaderB64}.${base64Encode(wrappedCek)}.${base64Encode(iv)}.${base64Encode(ciphertext)}.${base64Encode(tag)}`; + // Split the result into ciphertext and authentication tag + const encrypted = new Uint8Array(ciphertext); + const tag = encrypted.slice(-16); + const ciphertextOutput = encrypted.slice(0, -16); + + // Construct the JWE compact serialization + return [ + encodedHeader, + base64UrlEncode(new Uint8Array(wrappedKey)), + base64UrlEncode(iv), + base64UrlEncode(ciphertextOutput), + base64UrlEncode(tag), + ].join("."); } /** - * Decrypt and verify a JWE token + * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. + * + * This function implements PBES2-HS256+A128KW for key encryption and A256GCM for content encryption, + * following the JWE (RFC 7516) specification. It decrypts the token's protected content using the + * provided password. + * + * @param token - The JWE token string in compact serialization format (header.encryptedKey.iv.ciphertext.tag) + * @param password - The password used to derive the encryption key + * @returns The decrypted content as a string + * @throws {Error} If the token uses unsupported algorithms or cannot be decrypted + * @example + * ```ts + * const decrypted = await unseal(jweToken, 'your-secure-password'); + * console.log(decrypted); // Decrypted string content + * ``` + */ +export async function unseal(token: string, password: string): Promise; +/** + * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. + * + * @param token - The JWE token string in compact serialization format + * @param password - The password used to derive the encryption key + * @param options - Decryption options + * @returns The decrypted content as a string or Uint8Array based on options */ export async function unseal( token: string, password: string, - options: Partial = {}, -): Promise { - if (!password || typeof password !== "string") { - throw new Error("Invalid password"); - } - - const opts = { ...defaults, ...options }; + options: { textOutput: true }, +): Promise; +/** + * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. + * + * @param token - The JWE token string in compact serialization format + * @param password - The password used to derive the encryption key + * @param options - Decryption options + * @returns The decrypted content as a Uint8Array + */ +export async function unseal( + token: string, + password: string, + options: { textOutput: false }, +): Promise; +/** + * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. + * + * @param token - The JWE token string in compact serialization format + * @param password - The password used to derive the encryption key + * @param options - Decryption options + * @returns The decrypted content as a string or Uint8Array based on options + */ +export async function unseal( + token: string, + password: string, + options: { + /** + * Whether to return the decrypted data as a string (true) or as a Uint8Array (false). + * @default true + */ + textOutput?: boolean; + } = {}, +): Promise { + const textOutput = options.textOutput !== false; // Split the JWE token - const parts = token.split("."); - if (parts.length !== 5) { - throw new Error("Invalid JWE token format"); + const [ + encodedHeader, + encryptedKey, + encodedIv, + encodedCiphertext, + encodedTag, + ] = token.split("."); + + // Decode the header + const header = JSON.parse(textDecoder.decode(base64UrlDecode(encodedHeader))); + + // Verify the algorithm and encryption method + if (header.alg !== "PBES2-HS256+A128KW" || header.enc !== "A256GCM") { + throw new Error( + `Unsupported algorithm or encryption: ${header.alg}, ${header.enc}`, + ); } - const [protectedHeaderB64, encryptedKeyB64, ivB64, ciphertextB64, tagB64] = - parts; - - // Parse and validate header - const protectedHeader = parseJWEHeader(protectedHeaderB64, opts); - - // Get parameters from header - const salt = - typeof protectedHeader.p2s === "string" - ? base64Decode(protectedHeader.p2s) - : new Uint8Array(16); - - const iterations = - typeof protectedHeader.p2c === "number" ? protectedHeader.p2c : opts.p2c; - - // Decode components - const encryptedKey = base64Decode(encryptedKeyB64); - const iv = base64Decode(ivB64); - const ciphertext = base64Decode(ciphertextB64); - const tag = base64Decode(tagB64); - - // Verify the original base64 matches the re-encoded data to detect tampering - if ( - base64Encode(ciphertext) !== ciphertextB64 || - base64Encode(tag) !== tagB64 - ) { - throw new Error("Failed to decrypt JWE token: Token has been tampered"); - } + // Extract PBES2 parameters + const iterations = header.p2c; + const saltInput = base64UrlDecode(header.p2s); - // Combine ciphertext and tag for decryption - const encryptedData = new Uint8Array(ciphertext.length + tag.length); - encryptedData.set(ciphertext); - encryptedData.set(tag, ciphertext.length); - - // Derive key from password - const passwordDerivedKey = await deriveKeyFromPassword( - password, - salt, - iterations, - protectedHeader.alg as string, - ); - - // Unwrap the CEK - let cek; - try { - cek = await unwrapCEK(encryptedKey, passwordDerivedKey); - } catch { - throw new Error("Failed to decrypt JWE token: Invalid key"); - } - - // Decrypt the payload - let decrypted; - try { - decrypted = await decryptData(cek, encryptedData, iv); - } catch { - throw new Error("Failed to decrypt JWE token: Invalid data"); - } - - // Parse the decrypted data - try { - return JSON.parse(textDecoder.decode(decrypted)); - } catch { - throw new Error("Invalid JWE payload format"); - } -} - -// Utility functions - -/** - * Derives a key from password using PBKDF2 - */ -async function deriveKeyFromPassword( - password: string, - saltInput: Uint8Array, - iterations: number, - alg: string, -) { - // Construct the full salt as per RFC: (UTF8(Alg) || 0x00 || Salt Input) - const fullSalt = new Uint8Array(alg.length + 1 + saltInput.length); - fullSalt.set(textEncoder.encode(alg), 0); - fullSalt.set([0x00], alg.length); - fullSalt.set(saltInput, alg.length + 1); - - const keyMaterial = await crypto.subtle.importKey( + // Import the password as a key + const baseKey = await subtle.importKey( "raw", textEncoder.encode(password), { name: "PBKDF2" }, false, - ["deriveBits", "deriveKey"], + ["deriveKey"], ); - return crypto.subtle.deriveKey( + // Prepare the salt for key derivation + const salt = new Uint8Array([ + ...new TextEncoder().encode("PBES2-HS256+A128KW"), + 0, + ...saltInput, + ]); + + // Derive the key unwrapping key + const derivedKey = await subtle.deriveKey( { name: "PBKDF2", - salt: fullSalt, - iterations, hash: "SHA-256", + salt, + iterations, }, - keyMaterial, + baseKey, { name: "AES-KW", length: 128 }, false, - ["wrapKey", "unwrapKey"], + ["unwrapKey"], ); -} -/** - * Generates content encryption key - */ -async function generateCEK() { - return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ - "encrypt", - "decrypt", - ]); -} + // Decode the encrypted key, iv, ciphertext and tag + const wrappedKey = base64UrlDecode(encryptedKey); + const iv = base64UrlDecode(encodedIv); + const ciphertext = base64UrlDecode(encodedCiphertext); + const tag = base64UrlDecode(encodedTag); -/** - * Creates a protected header for JWE - */ -function createProtectedHeader( - saltInput: Uint8Array, - iterations: number, - options: JWEOptions, -): JWSHeaderParameters { - return { - ...options, - alg: options.alg, - enc: options.enc, - p2s: base64Encode(saltInput), - p2c: iterations, - typ: "JWT", - }; -} + // Combine ciphertext and authentication tag + const encryptedData = new Uint8Array(ciphertext.length + tag.length); + encryptedData.set(ciphertext); + encryptedData.set(tag, ciphertext.length); -/** - * Encrypts data using AES-GCM - */ -async function encryptData(cek: CryptoKey, data: Uint8Array, iv: Uint8Array) { - return crypto.subtle.encrypt( - { - name: "AES-GCM", - iv, - tagLength: 128, - }, - cek, - data, + // Unwrap the CEK + const cek = await subtle.unwrapKey( + "raw", + wrappedKey, + derivedKey, + { name: "AES-KW" }, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt", "encrypt", "wrapKey", "unwrapKey"], ); -} -/** - * Decrypts data using AES-GCM - */ -async function decryptData( - cek: CryptoKey, - encryptedData: Uint8Array, - iv: Uint8Array, -) { - return crypto.subtle.decrypt( + // Decrypt the data + const decrypted = await subtle.decrypt( { name: "AES-GCM", iv, - tagLength: 128, + additionalData: textEncoder.encode(encodedHeader), }, cek, encryptedData, ); -} -/** - * Wraps (encrypts) the Content Encryption Key - */ -async function wrapCEK(cek: CryptoKey, wrappingKey: CryptoKey) { - return crypto.subtle.wrapKey("raw", cek, wrappingKey, { - name: "AES-KW", - }); -} - -/** - * Unwraps (decrypts) the Content Encryption Key - */ -async function unwrapCEK(wrappedCek: Uint8Array, wrappingKey: CryptoKey) { - return crypto.subtle.unwrapKey( - "raw", - wrappedCek, - wrappingKey, - { name: "AES-KW" }, - { name: "AES-GCM", length: 256 }, - true, - ["decrypt"], - ); -} - -/** - * Parses and verifies a JWE token's header - */ -function parseJWEHeader( - headerB64: string, - options: JWEOptions, -): JWSHeaderParameters { - try { - const header = JSON.parse(textDecoder.decode(base64Decode(headerB64))); - - if (header.alg !== options.alg || header.enc !== options.enc) { - throw new Error("Unsupported JWE algorithms"); - } - - return header; - } catch { - throw new Error("Invalid JWE header"); - } + // Return the decrypted data + return textOutput + ? textDecoder.decode(new Uint8Array(decrypted)) + : new Uint8Array(decrypted); } diff --git a/src/utils/session.ts b/src/utils/session.ts index fdd6b7878..6cd81132b 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -178,7 +178,7 @@ export async function sealSession( ...(config.maxAge ? { exp: now + config.maxAge * 1000 } : {}), }; - const sealed = await seal(payload, config.password, config.jwe); + const sealed = await seal(JSON.stringify(payload), config.password); return sealed; } @@ -198,7 +198,8 @@ export async function unsealSession( config.timestampSkewSec || SESSION_DEFAULTS.timestampSkewSec; // Decrypt the payload - const payload = await unseal(sealed, config.password, config.jwe); + const _payload = await unseal(sealed, config.password); + const payload = JSON.parse(_payload); // Type check for expected format if (!payload || typeof payload !== "object" || !payload.session) { diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts index 714250cb5..2fce45037 100644 --- a/test/unit/jwe.test.ts +++ b/test/unit/jwe.test.ts @@ -2,14 +2,14 @@ import { describe, it, expect, assert } from "vitest"; import * as JWE from "../../src/utils/internal/jwe"; import { base64Encode } from "../../src/utils/internal/encoding"; -const testObject = { a: 1, b: 2, c: [3, 4, 5], d: { e: "f" } }; +const testObject = JSON.stringify({ a: 1, b: 2, c: [3, 4, 5], d: { e: "f" } }); const password = "some_not_random_password_that_is_also_long_enough"; describe("JWE", () => { - it("seals and unseals an object correctly", async () => { + it("seals and unseals data correctly", async () => { const sealed = await JWE.seal(testObject, password); const unsealed = await JWE.unseal(sealed, password); - assert.deepEqual(unsealed, testObject); + assert.equal(unsealed, testObject); }); it("seals and unseals primitive values correctly", async () => { @@ -19,45 +19,50 @@ describe("JWE", () => { const unsealedString = await JWE.unseal(sealedString, password); assert.equal(unsealedString, stringValue); - // Test with number - const numberValue = 12_345; + // Test with numbers and other types (need to be stringified first) + const numberValue = "12345"; // As string const sealedNumber = await JWE.seal(numberValue, password); const unsealedNumber = await JWE.unseal(sealedNumber, password); assert.equal(unsealedNumber, numberValue); - // Test with boolean - const boolValue = true; + // Test with boolean as string + const boolValue = "true"; // As string const sealedBool = await JWE.seal(boolValue, password); const unsealedBool = await JWE.unseal(sealedBool, password); assert.equal(unsealedBool, boolValue); - // Test with null - const nullValue = null; + // Test with null as string + const nullValue = "null"; // As string const sealedNull = await JWE.seal(nullValue, password); const unsealedNull = await JWE.unseal(sealedNull, password); assert.equal(unsealedNull, nullValue); }); - it("rejects invalid passwords", async () => { - const sealed = await JWE.seal(testObject, password); + it("works with Uint8Array data", async () => { + const uint8Data = new TextEncoder().encode("Hello, world!"); + const sealed = await JWE.seal(uint8Data, password); + const unsealed = await JWE.unseal(sealed, password, { textOutput: false }); - await expect(JWE.unseal(sealed, "wrong_password")).rejects.toThrow( - "Failed to decrypt JWE token", + // Compare byte arrays + assert.instanceOf(unsealed, Uint8Array); + assert.equal( + new TextDecoder().decode(unsealed as Uint8Array), + "Hello, world!", ); + }); - await expect(JWE.seal(testObject, "")).rejects.toThrow("Invalid password"); + it("rejects invalid passwords", async () => { + const sealed = await JWE.seal(testObject, password); - await expect(JWE.unseal(sealed, "")).rejects.toThrow("Invalid password"); + await expect(JWE.unseal(sealed, "wrong_password")).rejects.toThrow(); }); it("rejects invalid JWE formats", async () => { - await expect(JWE.unseal("invalid.jwe.token", password)).rejects.toThrow( - "Invalid JWE token format", - ); + await expect(JWE.unseal("invalid.jwe.token", password)).rejects.toThrow(); await expect( JWE.unseal("part1.part2.part3.part4.part5.extrastuff", password), - ).rejects.toThrow("Invalid JWE token format"); + ).rejects.toThrow(); }); it("rejects tampered tokens", async () => { @@ -73,9 +78,7 @@ describe("JWE", () => { parts[4], ].join("."); - await expect(JWE.unseal(tamperedSeal, password)).rejects.toThrow( - "Failed to decrypt JWE token", - ); + await expect(JWE.unseal(tamperedSeal, password)).rejects.toThrow(); // Tamper with the tag const tamperedTag = [ @@ -86,27 +89,7 @@ describe("JWE", () => { parts[4] + "x", // tamper with tag ].join("."); - await expect(JWE.unseal(tamperedTag, password)).rejects.toThrow( - "Failed to decrypt JWE token", - ); - }); - - it("rejects invalid JWE header", async () => { - const sealed = await JWE.seal(testObject, password); - const parts = sealed.split("."); - - // Replace header with invalid base64 - const invalidHeader = [ - "!@#$%^", // invalid base64 - parts[1], - parts[2], - parts[3], - parts[4], - ].join("."); - - await expect(JWE.unseal(invalidHeader, password)).rejects.toThrow( - "Invalid JWE header", - ); + await expect(JWE.unseal(tamperedTag, password)).rejects.toThrow(); }); it("rejects unsupported algorithms", async () => { @@ -115,10 +98,12 @@ describe("JWE", () => { // Create a header with unsupported algorithm const invalidAlgHeader = base64Encode( - JSON.stringify({ - alg: "unsupported", - enc: "A256GCM", - }), + new TextEncoder().encode( + JSON.stringify({ + alg: "unsupported", + enc: "A256GCM", + }), + ), ); const invalidAlgJWE = [ @@ -130,12 +115,12 @@ describe("JWE", () => { ].join("."); await expect(JWE.unseal(invalidAlgJWE, password)).rejects.toThrow( - "Invalid JWE header", + "Unsupported algorithm or encryption", ); }); - it("handles complex nested objects", async () => { - const complexObj = { + it("handles complex nested objects as strings", async () => { + const complexObj = JSON.stringify({ array: [1, 2, 3, 4, 5], nested: { a: { b: { c: { d: { e: "deep" } } } }, @@ -147,30 +132,32 @@ describe("JWE", () => { date: new Date().toISOString(), nullValue: null, booleans: [true, false], - }; + }); const sealed = await JWE.seal(complexObj, password); const unsealed = await JWE.unseal(sealed, password); - assert.deepEqual(unsealed, complexObj); + assert.equal(unsealed, complexObj); }); - it("handles empty objects", async () => { - const sealed = await JWE.seal({}, password); + it("handles empty strings", async () => { + const sealed = await JWE.seal("", password); const unsealed = await JWE.unseal(sealed, password); - assert.deepEqual(unsealed, {}); + assert.equal(unsealed, ""); }); it("supports custom headers", async () => { const options = { - kid: "test-key-id", - customHeader: "custom-value", + protectedHeader: { + kid: "test-key-id", + customHeader: "custom-value", + }, }; const sealed = await JWE.seal(testObject, password, options); const parts = sealed.split("."); // Decode the header to verify it contains our custom values - const headerJson = atob(parts[0]); + const headerJson = atob(parts[0].replace(/-/g, "+").replace(/_/g, "/")); const header = JSON.parse(headerJson); assert.equal(header.kid, "test-key-id"); @@ -178,17 +165,26 @@ describe("JWE", () => { // Verify we can still decrypt with the custom headers const unsealed = await JWE.unseal(sealed, password); - assert.deepEqual(unsealed, testObject); + assert.equal(unsealed, testObject); }); - it("allows changing encryption algorithm", async () => { + it("allows changing iteration count", async () => { const options = { - enc: "A256GCM", // Same as default, but explicitly set - p2c: 1024, // Lower iterations for testing + iterations: 1024, // Lower iterations for testing }; const sealed = await JWE.seal(testObject, password, options); - const unsealed = await JWE.unseal(sealed, password, options); - assert.deepEqual(unsealed, testObject); + const unsealed = await JWE.unseal(sealed, password); + assert.equal(unsealed, testObject); + }); + + it("allows changing salt size", async () => { + const options = { + saltSize: 32, // Larger salt size + }; + + const sealed = await JWE.seal(testObject, password, options); + const unsealed = await JWE.unseal(sealed, password); + assert.equal(unsealed, testObject); }); }); From 9d3915a84fac0f86959d925902a6b15dbfdaa8d0 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 16:07:48 +0100 Subject: [PATCH 07/12] fix(jwe): reintroduce derivedKey utility function --- src/utils/internal/jwe.ts | 137 ++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 74 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index 7681d6894..df2f5cfc9 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -26,26 +26,6 @@ export const defaults: Readonly< enc: "A256GCM", }); -// Base64 URL encoding/decoding functions -function base64UrlEncode(data: Uint8Array): string { - return btoa(String.fromCharCode(...data)) - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); -} - -function base64UrlDecode(str: string): Uint8Array { - str = str.replace(/-/g, "+").replace(/_/g, "/"); - while (str.length % 4) str += "="; - return new Uint8Array([...atob(str)].map((c) => c.charCodeAt(0))); -} - -// Generate a random Uint8Array of specified length -function randomBytes(length: number): Uint8Array { - const bytes = getRandomValues(new Uint8Array(length)); - return bytes; -} - /** * Seal (encrypt) data using JWE with AES-GCM and PBES2-HS256+A128KW * @param data The data to encrypt @@ -87,34 +67,11 @@ export async function seal( textEncoder.encode(JSON.stringify(header)), ); - // Derive key from password using PBKDF2 - const baseKey = await subtle.importKey( - "raw", - textEncoder.encode(password), - { name: "PBKDF2" }, - false, - ["deriveKey"], - ); - - // Concatenate 'PBES2-HS256+A128KW' + 00 + encoded saltInput - const salt = new Uint8Array([ - ...new TextEncoder().encode("PBES2-HS256+A128KW"), - 0, - ...saltInput, - ]); - // Derive the key for key wrapping - const derivedKey = await subtle.deriveKey( - { - name: "PBKDF2", - hash: "SHA-256", - salt, - iterations, - }, - baseKey, - { name: "AES-KW", length: 128 }, - false, - ["wrapKey"], + const derivedKey = await deriveKeyFromPassword( + password, + saltInput, + iterations, ); // Generate a random Content Encryption Key @@ -246,34 +203,11 @@ export async function unseal( const iterations = header.p2c; const saltInput = base64UrlDecode(header.p2s); - // Import the password as a key - const baseKey = await subtle.importKey( - "raw", - textEncoder.encode(password), - { name: "PBKDF2" }, - false, - ["deriveKey"], - ); - - // Prepare the salt for key derivation - const salt = new Uint8Array([ - ...new TextEncoder().encode("PBES2-HS256+A128KW"), - 0, - ...saltInput, - ]); - // Derive the key unwrapping key - const derivedKey = await subtle.deriveKey( - { - name: "PBKDF2", - hash: "SHA-256", - salt, - iterations, - }, - baseKey, - { name: "AES-KW", length: 128 }, - false, - ["unwrapKey"], + const derivedKey = await deriveKeyFromPassword( + password, + saltInput, + iterations, ); // Decode the encrypted key, iv, ciphertext and tag @@ -314,3 +248,58 @@ export async function unseal( ? textDecoder.decode(new Uint8Array(decrypted)) : new Uint8Array(decrypted); } + +// Base64 URL encoding/decoding functions +function base64UrlEncode(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function base64UrlDecode(str: string): Uint8Array { + str = str.replace(/-/g, "+").replace(/_/g, "/"); + while (str.length % 4) str += "="; + return new Uint8Array([...atob(str)].map((c) => c.charCodeAt(0))); +} + +// Generate a random Uint8Array of specified length +function randomBytes(length: number): Uint8Array { + const bytes = getRandomValues(new Uint8Array(length)); + return bytes; +} + +// Derive the key for key wrapping/unwrapping +async function deriveKeyFromPassword( + password: string, + saltInput: Uint8Array, + iterations: number, +): Promise { + const baseKey = await subtle.importKey( + "raw", + textEncoder.encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + + // Prepare the salt with algorithm prefix + const salt = new Uint8Array([ + ...textEncoder.encode("PBES2-HS256+A128KW"), + 0, + ...saltInput, + ]); + + return subtle.deriveKey( + { + name: "PBKDF2", + hash: "SHA-256", + salt, + iterations, + }, + baseKey, + { name: "AES-KW", length: 128 }, + false, + ["wrapKey", "unwrapKey"], + ); +} From 3a57bd92487be9660ede5d8b4539f540799c60b7 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 16:21:49 +0100 Subject: [PATCH 08/12] fix(jwe): standardize options --- src/utils/internal/jwe.ts | 69 +++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index df2f5cfc9..e772ee59f 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -3,25 +3,62 @@ import type { JWSHeaderParameters } from "../../types/utils/jwt"; import { textEncoder, textDecoder } from "./encoding"; +export interface JWEHeaderParameters extends JWSHeaderParameters { + /** + * `alg` (Algorithm): Header Parameter + * + * @default "PBES2-HS256+A128KW" + */ + alg?: string; + /** + * `enc` (Encryption Algorithm): Header Parameter + * + * @default "A256GCM" + */ + enc?: string; + /** + * `zip` (Compression Algorithm): Header Parameter + */ + zip?: string; + /** + * `p2c` (PBES2 Count): Header Parameter + * + * @default 2048 + */ + p2c?: number; + /** + * `p2s` (PBES2 Salt): Header Parameter + */ + p2s?: string; +} + /** - * JWE (JSON Web Encryption) implementation for H3 sessions + * JWE (JSON Web Encryption) options */ export interface JWEOptions extends JWSHeaderParameters { - /** Iteration count for PBKDF2. Defaults to 8192. */ - p2c?: number; - /** Base64-encoded salt for PBKDF2. */ - p2s?: string; - /** JWE encryption algorithm. Defaults to "A256GCM". */ - enc?: string; + /** + * Number of iterations for PBES2 key derivation + * Also accessible as `protectedHeader.p2c` + * + * @default 2048 + */ + iterations?: number; + /** + * Size of the salt for PBES2 key derivation + * + * @default 16 + */ + saltSize?: number; + /** + * Additional protected header parameters + */ + protectedHeader?: JWEHeaderParameters; } /** The default settings. */ -export const defaults: Readonly< - JWEOptions & - Pick, "p2c" | "alg" | "enc"> & { saltSize: number } -> = /* @__PURE__ */ Object.freeze({ +export const defaults = /* @__PURE__ */ Object.freeze({ saltSize: 16, - p2c: 2048, + iterations: 2048, alg: "PBES2-HS256+A128KW", enc: "A256GCM", }); @@ -36,14 +73,10 @@ export const defaults: Readonly< export async function seal( data: string | Uint8Array, password: string, - options: { - iterations?: number; - saltSize?: number; - protectedHeader?: Record; - } = {}, + options: JWEOptions = {}, ): Promise { // Configure options with defaults - const iterations = options.iterations || defaults.p2c; + const iterations = options.iterations || defaults.iterations; const saltSize = options.saltSize || defaults.saltSize; const protectedHeader = options.protectedHeader || {}; From 197adc8fc315e6fe51a9e3e839ac90aff6a48297 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 16:47:11 +0100 Subject: [PATCH 09/12] fix(jwe): support `Uint8Array` passwords --- src/utils/internal/jwe.ts | 25 ++++++++++++++++++------- test/unit/jwe.test.ts | 17 +++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index e772ee59f..11fdf9dd3 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -72,7 +72,7 @@ export const defaults = /* @__PURE__ */ Object.freeze({ */ export async function seal( data: string | Uint8Array, - password: string, + password: string | Uint8Array, options: JWEOptions = {}, ): Promise { // Configure options with defaults @@ -165,7 +165,10 @@ export async function seal( * console.log(decrypted); // Decrypted string content * ``` */ -export async function unseal(token: string, password: string): Promise; +export async function unseal( + token: string, + password: string | Uint8Array, +): Promise; /** * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. * @@ -176,7 +179,7 @@ export async function unseal(token: string, password: string): Promise; */ export async function unseal( token: string, - password: string, + password: string | Uint8Array, options: { textOutput: true }, ): Promise; /** @@ -189,7 +192,7 @@ export async function unseal( */ export async function unseal( token: string, - password: string, + password: string | Uint8Array, options: { textOutput: false }, ): Promise; /** @@ -202,7 +205,7 @@ export async function unseal( */ export async function unseal( token: string, - password: string, + password: string | Uint8Array, options: { /** * Whether to return the decrypted data as a string (true) or as a Uint8Array (false). @@ -211,6 +214,10 @@ export async function unseal( textOutput?: boolean; } = {}, ): Promise { + if (!token) { + throw new Error("Missing JWE token"); + } + const textOutput = options.textOutput !== false; // Split the JWE token @@ -304,13 +311,17 @@ function randomBytes(length: number): Uint8Array { // Derive the key for key wrapping/unwrapping async function deriveKeyFromPassword( - password: string, + password: string | Uint8Array, saltInput: Uint8Array, iterations: number, ): Promise { + if (!password) { + throw new Error("Missing password"); + } + const baseKey = await subtle.importKey( "raw", - textEncoder.encode(password), + typeof password === "string" ? textEncoder.encode(password) : password, { name: "PBKDF2" }, false, ["deriveKey"], diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts index 2fce45037..a6bd5c7d0 100644 --- a/test/unit/jwe.test.ts +++ b/test/unit/jwe.test.ts @@ -12,6 +12,23 @@ describe("JWE", () => { assert.equal(unsealed, testObject); }); + it("should accept Uint8Array as password", async () => { + const sealed = await JWE.seal( + testObject, + new TextEncoder().encode(password), + ); + const unsealed = await JWE.unseal( + sealed, + new TextEncoder().encode(password), + ); + assert.equal(unsealed, testObject); + }); + + it("should reject if missing password", async () => { + await expect(JWE.seal(testObject, "")).rejects.toThrow(); + await expect(JWE.unseal("", "")).rejects.toThrow(); + }); + it("seals and unseals primitive values correctly", async () => { // Test with string const stringValue = "Just a simple string"; From 748046c8dbb65292513bd197ef9c4dc26d5e72cb Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 18:34:04 +0100 Subject: [PATCH 10/12] up --- src/utils/internal/jwe.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index 11fdf9dd3..31cdc24e7 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -16,10 +16,6 @@ export interface JWEHeaderParameters extends JWSHeaderParameters { * @default "A256GCM" */ enc?: string; - /** - * `zip` (Compression Algorithm): Header Parameter - */ - zip?: string; /** * `p2c` (PBES2 Count): Header Parameter * @@ -35,7 +31,7 @@ export interface JWEHeaderParameters extends JWSHeaderParameters { /** * JWE (JSON Web Encryption) options */ -export interface JWEOptions extends JWSHeaderParameters { +export interface JWEOptions { /** * Number of iterations for PBES2 key derivation * Also accessible as `protectedHeader.p2c` @@ -76,9 +72,9 @@ export async function seal( options: JWEOptions = {}, ): Promise { // Configure options with defaults - const iterations = options.iterations || defaults.iterations; - const saltSize = options.saltSize || defaults.saltSize; const protectedHeader = options.protectedHeader || {}; + const iterations = protectedHeader.p2c || options.iterations || defaults.iterations; + const saltSize = options.saltSize || defaults.saltSize; // Convert input data to Uint8Array if it's a string const plaintext = typeof data === "string" ? textEncoder.encode(data) : data; From 1967ccfee30c3aecd7f554a9faf048e00cfb1ad4 Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 20:00:26 +0100 Subject: [PATCH 11/12] feat(jwe): support more `alg` and `enc` --- src/utils/internal/jwe.ts | 475 +++++++++++++++++++++++++++++--------- test/unit/jwe.test.ts | 214 ++++++++++++++++- 2 files changed, 573 insertions(+), 116 deletions(-) diff --git a/src/utils/internal/jwe.ts b/src/utils/internal/jwe.ts index 31cdc24e7..12fae5bb7 100644 --- a/src/utils/internal/jwe.ts +++ b/src/utils/internal/jwe.ts @@ -9,13 +9,13 @@ export interface JWEHeaderParameters extends JWSHeaderParameters { * * @default "PBES2-HS256+A128KW" */ - alg?: string; + alg?: KeyWrappingAlgorithmType; /** * `enc` (Encryption Algorithm): Header Parameter * * @default "A256GCM" */ - enc?: string; + enc?: ContentEncryptionAlgorithmType; /** * `p2c` (PBES2 Count): Header Parameter * @@ -51,16 +51,91 @@ export interface JWEOptions { protectedHeader?: JWEHeaderParameters; } +/** + * Supported key wrapping algorithms + */ +export const KEY_WRAPPING_ALGORITHMS = /* @__PURE__ */ Object.freeze({ + "PBES2-HS256+A128KW": { + hash: "SHA-256", + keyLength: 128, + }, + "PBES2-HS384+A192KW": { + hash: "SHA-384", + keyLength: 192, + }, + "PBES2-HS512+A256KW": { + hash: "SHA-512", + keyLength: 256, + }, +}); + +/** + * Supported content encryption algorithms + */ +export const CONTENT_ENCRYPTION_ALGORITHMS = /* @__PURE__ */ Object.freeze({ + // GCM algorithms + A128GCM: { + type: "gcm", + keyLength: 128, + tagLength: 16, + ivLength: 12, + }, + A192GCM: { + type: "gcm", + keyLength: 192, + tagLength: 16, + ivLength: 12, + }, + A256GCM: { + type: "gcm", + keyLength: 256, + tagLength: 16, + ivLength: 12, + }, + // TODO: implement future CBC algorithms + "A128CBC-HS256": { + type: "cbc", + keyLength: 256, // Combined key length (encryption + HMAC) + encKeyLength: 128, + macKeyLength: 128, + tagLength: 16, + ivLength: 16, + macAlgorithm: "SHA-256", + }, + "A192CBC-HS384": { + type: "cbc", + keyLength: 384, // Combined key length (encryption + HMAC) + encKeyLength: 192, + macKeyLength: 192, + tagLength: 24, + ivLength: 16, + macAlgorithm: "SHA-384", + }, + "A256CBC-HS512": { + type: "cbc", + keyLength: 512, // Combined key length (encryption + HMAC) + encKeyLength: 256, + macKeyLength: 256, + tagLength: 32, + ivLength: 16, + macAlgorithm: "SHA-512", + }, +}); + +type KeyWrappingAlgorithmType = keyof typeof KEY_WRAPPING_ALGORITHMS; +type ContentEncryptionAlgorithmType = + keyof typeof CONTENT_ENCRYPTION_ALGORITHMS; + /** The default settings. */ export const defaults = /* @__PURE__ */ Object.freeze({ saltSize: 16, iterations: 2048, - alg: "PBES2-HS256+A128KW", - enc: "A256GCM", + alg: "PBES2-HS256+A128KW" as KeyWrappingAlgorithmType, + enc: "A256GCM" as ContentEncryptionAlgorithmType, }); /** - * Seal (encrypt) data using JWE with AES-GCM and PBES2-HS256+A128KW + * Seal (encrypt) data using JWE with configurable algorithms * @param data The data to encrypt * @param password The password to use for encryption * @param options Optional parameters @@ -73,9 +148,19 @@ export async function seal( ): Promise { // Configure options with defaults const protectedHeader = options.protectedHeader || {}; - const iterations = protectedHeader.p2c || options.iterations || defaults.iterations; + const iterations = + protectedHeader.p2c || options.iterations || defaults.iterations; const saltSize = options.saltSize || defaults.saltSize; + // Set algorithms with defaults + const alg = (protectedHeader.alg || defaults.alg) as KeyWrappingAlgorithmType; + const enc = (protectedHeader.enc || + defaults.enc) as ContentEncryptionAlgorithmType; + + // Validate both algorithms + validateKeyWrappingAlgorithm(alg); + const encConfig = validateContentEncryptionAlgorithm(enc); + // Convert input data to Uint8Array if it's a string const plaintext = typeof data === "string" ? textEncoder.encode(data) : data; @@ -84,8 +169,8 @@ export async function seal( // Set up the protected header const header = { - alg: "PBES2-HS256+A128KW", - enc: "A256GCM", + alg, + enc, p2s: base64UrlEncode(saltInput), p2c: iterations, ...protectedHeader, @@ -101,77 +186,64 @@ export async function seal( password, saltInput, iterations, + alg, ); - // Generate a random Content Encryption Key - const cek = await subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ - "encrypt", - "wrapKey", - "unwrapKey", - ]); + // Generate a random Content Encryption Key and wrap it + const { + wrappedKey, + rawCek: _, + cek, + } = await generateAndWrapCEK(derivedKey, encConfig); - // Wrap the CEK using the derived key - const wrappedKey = await subtle.wrapKey("raw", cek, derivedKey, { - name: "AES-KW", - }); + // Generate random initialization vector + const iv = randomBytes(encConfig.ivLength); - // Generate random initialization vector for AES-GCM - const iv = randomBytes(12); + let ciphertext: Uint8Array; + let tag: Uint8Array; - // Encrypt the plaintext - const ciphertext = await subtle.encrypt( - { - name: "AES-GCM", + // Encrypt the plaintext based on the encryption type + if (encConfig.type === "gcm") { + const result = await encryptGCM( + plaintext, + cek as CryptoKey, iv, - additionalData: textEncoder.encode(encodedHeader), - }, - cek, - plaintext, - ); - - // Split the result into ciphertext and authentication tag - const encrypted = new Uint8Array(ciphertext); - const tag = encrypted.slice(-16); - const ciphertextOutput = encrypted.slice(0, -16); + textEncoder.encode(encodedHeader), + encConfig, + ); + ciphertext = result.ciphertext; + tag = result.tag; + } else { + // TODO: CBC encryption + throw new Error(`Unsupported encryption type: ${(encConfig as any).type}`); + } // Construct the JWE compact serialization return [ encodedHeader, base64UrlEncode(new Uint8Array(wrappedKey)), base64UrlEncode(iv), - base64UrlEncode(ciphertextOutput), + base64UrlEncode(ciphertext), base64UrlEncode(tag), ].join("."); } /** - * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. - * - * This function implements PBES2-HS256+A128KW for key encryption and A256GCM for content encryption, - * following the JWE (RFC 7516) specification. It decrypts the token's protected content using the - * provided password. - * - * @param token - The JWE token string in compact serialization format (header.encryptedKey.iv.ciphertext.tag) - * @param password - The password used to derive the encryption key + * Decrypts a JWE (JSON Web Encryption) token + * @param token The JWE token string in compact serialization format + * @param password The password used to derive the encryption key * @returns The decrypted content as a string - * @throws {Error} If the token uses unsupported algorithms or cannot be decrypted - * @example - * ```ts - * const decrypted = await unseal(jweToken, 'your-secure-password'); - * console.log(decrypted); // Decrypted string content - * ``` */ export async function unseal( token: string, password: string | Uint8Array, ): Promise; /** - * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. - * - * @param token - The JWE token string in compact serialization format - * @param password - The password used to derive the encryption key - * @param options - Decryption options - * @returns The decrypted content as a string or Uint8Array based on options + * Decrypts a JWE (JSON Web Encryption) token + * @param token The JWE token string in compact serialization format + * @param password The password used to derive the encryption key + * @param options Decryption options + * @returns The decrypted content as a string */ export async function unseal( token: string, @@ -179,11 +251,10 @@ export async function unseal( options: { textOutput: true }, ): Promise; /** - * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. - * - * @param token - The JWE token string in compact serialization format - * @param password - The password used to derive the encryption key - * @param options - Decryption options + * Decrypts a JWE (JSON Web Encryption) token + * @param token The JWE token string in compact serialization format + * @param password The password used to derive the encryption key + * @param options Decryption options * @returns The decrypted content as a Uint8Array */ export async function unseal( @@ -192,12 +263,11 @@ export async function unseal( options: { textOutput: false }, ): Promise; /** - * Decrypts a JWE (JSON Web Encryption) token using password-based encryption. - * - * @param token - The JWE token string in compact serialization format - * @param password - The password used to derive the encryption key - * @param options - Decryption options - * @returns The decrypted content as a string or Uint8Array based on options + * Decrypts a JWE (JSON Web Encryption) token + * @param token The JWE token string in compact serialization format + * @param password The password used to derive the encryption key + * @param options Decryption options + * @returns The decrypted content */ export async function unseal( token: string, @@ -228,12 +298,13 @@ export async function unseal( // Decode the header const header = JSON.parse(textDecoder.decode(base64UrlDecode(encodedHeader))); - // Verify the algorithm and encryption method - if (header.alg !== "PBES2-HS256+A128KW" || header.enc !== "A256GCM") { - throw new Error( - `Unsupported algorithm or encryption: ${header.alg}, ${header.enc}`, - ); - } + // Get the algorithms + const alg = header.alg as KeyWrappingAlgorithmType; + const enc = header.enc as ContentEncryptionAlgorithmType; + + // Validate both algorithms + validateKeyWrappingAlgorithm(alg); + const encConfig = validateContentEncryptionAlgorithm(enc); // Extract PBES2 parameters const iterations = header.p2c; @@ -244,6 +315,7 @@ export async function unseal( password, saltInput, iterations, + alg, ); // Decode the encrypted key, iv, ciphertext and tag @@ -252,48 +324,42 @@ export async function unseal( const ciphertext = base64UrlDecode(encodedCiphertext); const tag = base64UrlDecode(encodedTag); - // Combine ciphertext and authentication tag - const encryptedData = new Uint8Array(ciphertext.length + tag.length); - encryptedData.set(ciphertext); - encryptedData.set(tag, ciphertext.length); - // Unwrap the CEK - const cek = await subtle.unwrapKey( - "raw", - wrappedKey, - derivedKey, - { name: "AES-KW" }, - { name: "AES-GCM", length: 256 }, - false, - ["decrypt", "encrypt", "wrapKey", "unwrapKey"], - ); + const cek = await unwrapCEK(wrappedKey, derivedKey, encConfig); - // Decrypt the data - const decrypted = await subtle.decrypt( - { - name: "AES-GCM", + let decrypted: Uint8Array; + + // Decrypt based on encryption type + if (encConfig.type === "gcm") { + decrypted = await decryptGCM( + ciphertext, + tag, + cek as CryptoKey, iv, - additionalData: textEncoder.encode(encodedHeader), - }, - cek, - encryptedData, - ); + textEncoder.encode(encodedHeader), + ); + } else { + // TODO: CBC decryption + throw new Error(`Unsupported encryption type: ${(encConfig as any).type}`); + } // Return the decrypted data - return textOutput - ? textDecoder.decode(new Uint8Array(decrypted)) - : new Uint8Array(decrypted); + return textOutput ? textDecoder.decode(decrypted) : decrypted; } -// Base64 URL encoding/decoding functions -function base64UrlEncode(data: Uint8Array): string { +// Base64 URL encoding function +export function base64UrlEncode(data: Uint8Array): string { return btoa(String.fromCharCode(...data)) .replace(/=/g, "") .replace(/\+/g, "-") .replace(/\//g, "_"); } -function base64UrlDecode(str: string): Uint8Array { +// Base64 URL decoding function +export function base64UrlDecode(str: string): Uint8Array { + if (!str) { + return new Uint8Array(0); + } str = str.replace(/-/g, "+").replace(/_/g, "/"); while (str.length % 4) str += "="; return new Uint8Array([...atob(str)].map((c) => c.charCodeAt(0))); @@ -301,20 +367,29 @@ function base64UrlDecode(str: string): Uint8Array { // Generate a random Uint8Array of specified length function randomBytes(length: number): Uint8Array { - const bytes = getRandomValues(new Uint8Array(length)); - return bytes; + return getRandomValues(new Uint8Array(length)); } -// Derive the key for key wrapping/unwrapping +/** + * Derives a key from a password using PBKDF2 + * @param password The password to derive the key from + * @param saltInput Salt input for key derivation + * @param iterations Number of iterations for key derivation + * @param alg Key wrapping algorithm + * @returns Promise resolving to the derived CryptoKey + */ async function deriveKeyFromPassword( password: string | Uint8Array, saltInput: Uint8Array, iterations: number, + alg: KeyWrappingAlgorithmType, ): Promise { if (!password) { throw new Error("Missing password"); } + const algConfig = validateKeyWrappingAlgorithm(alg); + const baseKey = await subtle.importKey( "raw", typeof password === "string" ? textEncoder.encode(password) : password, @@ -324,22 +399,200 @@ async function deriveKeyFromPassword( ); // Prepare the salt with algorithm prefix - const salt = new Uint8Array([ - ...textEncoder.encode("PBES2-HS256+A128KW"), - 0, - ...saltInput, - ]); + const salt = concatUint8Arrays( + textEncoder.encode(alg), + new Uint8Array([0]), + saltInput, + ); return subtle.deriveKey( { name: "PBKDF2", - hash: "SHA-256", + hash: algConfig.hash, salt, iterations, }, baseKey, - { name: "AES-KW", length: 128 }, + { name: "AES-KW", length: algConfig.keyLength }, false, ["wrapKey", "unwrapKey"], ); } + +/** + * Concatenates multiple Uint8Arrays + * @param arrays Arrays to concatenate + * @returns Concatenated array + */ +function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((length, arr) => length + arr.length, 0); + const result = new Uint8Array(totalLength); + + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + + return result; +} + +/** + * Validates and returns information about a key wrapping algorithm + * @param alg The key wrapping algorithm to validate + * @returns The algorithm configuration + * @throws Error if the algorithm is not supported + */ +function validateKeyWrappingAlgorithm(alg: string) { + const config = KEY_WRAPPING_ALGORITHMS[alg as KeyWrappingAlgorithmType]; + if (!config) { + throw new Error(`Unsupported key wrapping algorithm: ${alg}`); + } + return { alg, ...config }; +} + +/** + * Validates and returns information about a content encryption algorithm + * @param enc The content encryption algorithm to validate + * @returns The algorithm configuration + * @throws Error if the algorithm is not supported + */ +function validateContentEncryptionAlgorithm(enc: string) { + const config = + CONTENT_ENCRYPTION_ALGORITHMS[enc as ContentEncryptionAlgorithmType]; + if (!config) { + throw new Error(`Unsupported content encryption algorithm: ${enc}`); + } + return { enc, ...config }; +} + +/** + * Generates and wraps a content encryption key + * @param derivedKey Key used for wrapping + * @param encConfig Encryption configuration + * @returns Promise resolving to the wrapped key and the raw CEK + */ +async function generateAndWrapCEK( + derivedKey: CryptoKey, + encConfig: (typeof CONTENT_ENCRYPTION_ALGORITHMS)[ContentEncryptionAlgorithmType], +): Promise<{ + wrappedKey: ArrayBuffer; + rawCek: Uint8Array | null; + cek: CryptoKey; +}> { + if (encConfig.type === "gcm") { + // For GCM, use the WebCrypto API to generate a key + const cek = await subtle.generateKey( + { name: "AES-GCM", length: encConfig.keyLength }, + true, + ["encrypt", "decrypt", "wrapKey", "unwrapKey"], + ); + + // Wrap the key + const wrappedKey = await subtle.wrapKey("raw", cek, derivedKey, { + name: "AES-KW", + }); + + return { wrappedKey, rawCek: null, cek }; + } + + // TODO: CBC key generation + + throw new Error(`Unsupported encryption type: ${(encConfig as any).type}`); +} + +/** + * Unwraps a content encryption key + * @param wrappedKey The wrapped key to unwrap + * @param derivedKey Key used for unwrapping + * @param encConfig Encryption configuration + * @returns Promise resolving to the unwrapped key + */ +async function unwrapCEK( + wrappedKey: Uint8Array, + derivedKey: CryptoKey, + encConfig: (typeof CONTENT_ENCRYPTION_ALGORITHMS)[ContentEncryptionAlgorithmType], +): Promise { + if (encConfig.type === "gcm") { + // For GCM, unwrap to AES-GCM key + return subtle.unwrapKey( + "raw", + wrappedKey, + derivedKey, + { name: "AES-KW" }, + { name: "AES-GCM", length: encConfig.keyLength }, + false, + ["decrypt"], + ); + } + + // TODO: CBC unwrapping + + throw new Error(`Unsupported encryption type: ${(encConfig as any).type}`); +} + +/** + * Performs GCM encryption + * @param plaintext Data to encrypt + * @param cek Content encryption key + * @param iv Initialization vector + * @param aad Additional authenticated data + * @param encConfig Encryption configuration + * @returns Promise resolving to encrypted data with tag + */ +async function encryptGCM( + plaintext: Uint8Array, + cek: CryptoKey, + iv: Uint8Array, + aad: Uint8Array, + encConfig: (typeof CONTENT_ENCRYPTION_ALGORITHMS)[ContentEncryptionAlgorithmType], +): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> { + const ciphertext = await subtle.encrypt( + { + name: "AES-GCM", + iv, + additionalData: aad, + }, + cek, + plaintext, + ); + + const encrypted = new Uint8Array(ciphertext); + const tag = encrypted.slice(-encConfig.tagLength); + const ciphertextOutput = encrypted.slice(0, -encConfig.tagLength); + + return { ciphertext: ciphertextOutput, tag }; +} + +/** + * Performs GCM decryption + * @param ciphertext Encrypted data + * @param tag Authentication tag + * @param cek Content encryption key + * @param iv Initialization vector + * @param aad Additional authenticated data + * @returns Promise resolving to decrypted data + */ +async function decryptGCM( + ciphertext: Uint8Array, + tag: Uint8Array, + cek: CryptoKey, + iv: Uint8Array, + aad: Uint8Array, +): Promise { + // Combine ciphertext and authentication tag + const encryptedData = concatUint8Arrays(ciphertext, tag); + + // Decrypt the data + const decrypted = await subtle.decrypt( + { + name: "AES-GCM", + iv, + additionalData: aad, + }, + cek, + encryptedData, + ); + + return new Uint8Array(decrypted); +} diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts index a6bd5c7d0..fc5aae56f 100644 --- a/test/unit/jwe.test.ts +++ b/test/unit/jwe.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, assert } from "vitest"; import * as JWE from "../../src/utils/internal/jwe"; -import { base64Encode } from "../../src/utils/internal/encoding"; const testObject = JSON.stringify({ a: 1, b: 2, c: [3, 4, 5], d: { e: "f" } }); const password = "some_not_random_password_that_is_also_long_enough"; @@ -110,15 +109,25 @@ describe("JWE", () => { }); it("rejects unsupported algorithms", async () => { + await expect( + JWE.seal(testObject, password, { + protectedHeader: { enc: "A128CBC-HS256" }, + }), + ).rejects.toThrow("Unsupported encryption type: cbc"); + const sealed = await JWE.seal(testObject, password); const parts = sealed.split("."); + const header = JSON.parse( + atob(parts[0].replace(/-/g, "+").replace(/_/g, "/")), + ); // Create a header with unsupported algorithm - const invalidAlgHeader = base64Encode( + const invalidAlgHeader = JWE.base64UrlEncode( new TextEncoder().encode( JSON.stringify({ - alg: "unsupported", - enc: "A256GCM", + ...header, + alg: "PBES2-HS384+A192KW", + enc: "A128CBC-HS256", }), ), ); @@ -132,7 +141,7 @@ describe("JWE", () => { ].join("."); await expect(JWE.unseal(invalidAlgJWE, password)).rejects.toThrow( - "Unsupported algorithm or encryption", + "Unsupported encryption type: cbc", ); }); @@ -204,4 +213,199 @@ describe("JWE", () => { const unsealed = await JWE.unseal(sealed, password); assert.equal(unsealed, testObject); }); + + // Add tests focusing on the remaining uncovered lines + + // Test error paths in seal/unseal functions + it("handles explicit type errors in seal and unseal", async () => { + // Test with explicitly unsupported encryption type to hit lines 192-193 + await expect( + JWE.seal(testObject, password, { + protectedHeader: { + enc: "unsupported-enc" as any, + }, + }), + ).rejects.toThrow("Unsupported content encryption algorithm"); + + // Test with explicitly unsupported alg type to hit related branches + await expect( + JWE.seal(testObject, password, { + protectedHeader: { + alg: "unsupported-alg" as any, + }, + }), + ).rejects.toThrow("Unsupported key wrapping algorithm"); + + // Create a token with malformed parts to hit lines 317-318 + const sealed = await JWE.seal(testObject, password); + const parts = sealed.split("."); + + // Corrupt header to force JSON parsing error + const corruptedToken = [ + "invalid-base64", // Invalid base64 to trigger decode error + parts[1], + parts[2], + parts[3], + parts[4], + ].join("."); + + await expect(JWE.unseal(corruptedToken, password)).rejects.toThrow(); + }); + + // Test edge cases for randomBytes and encoding functions + it("handles edge cases in encoding and randomness", async () => { + // Test with zero-length input to various internal functions + const emptyData = new Uint8Array(0); + const sealed = await JWE.seal(emptyData, password); + const unsealed = await JWE.unseal(sealed, password, { textOutput: false }); + assert.equal(unsealed.length, 0); + + // Test with very small and specific lengths to hit edge cases + for (const length of [1, 2, 3, 4, 5]) { + const smallData = new Uint8Array(length).fill(1); + const smallSealed = await JWE.seal(smallData, password); + const smallUnsealed = await JWE.unseal(smallSealed, password, { + textOutput: false, + }); + assert.equal(smallUnsealed.length, length); + } + }); + + // Test function parameter variations to catch edge cases + it("tests all parameter variations thoroughly", async () => { + // Test that options are properly propagated to internal functions + // This covers lines 435-436, 472-473 by ensuring all paths in validation are hit + + // Test with empty options object + const sealed1 = await JWE.seal(testObject, password, {}); + const unsealed1 = await JWE.unseal(sealed1, password); + assert.equal(unsealed1, testObject); + + // Test with only protectedHeader but no alg/enc specified + const sealed2 = await JWE.seal(testObject, password, { + protectedHeader: { kid: "test-key" }, + }); + const unsealed2 = await JWE.unseal(sealed2, password); + assert.equal(unsealed2, testObject); + + // Test with exactly minimum iteration count + const sealed3 = await JWE.seal(testObject, password, { + iterations: 1, + }); + const unsealed3 = await JWE.unseal(sealed3, password); + assert.equal(unsealed3, testObject); + }); + + // Test error cases in encryption/decryption to hit lines 502-503 + it("tests error cases in crypto operations", async () => { + // Test with malformed wrapped key + const sealed = await JWE.seal(testObject, password); + const parts = sealed.split("."); + + // Replace encrypted key with something too short + const badKeyToken = [ + parts[0], + "Q", // Very short invalid key + parts[2], + parts[3], + parts[4], + ].join("."); + + await expect(JWE.unseal(badKeyToken, password)).rejects.toThrow(); + + // Test with malformed IV + const badIVToken = [ + parts[0], + parts[1], + "a", // Invalid IV + parts[3], + parts[4], + ].join("."); + + await expect(JWE.unseal(badIVToken, password)).rejects.toThrow(); + }); + + // Test with different key lengths and algorithm combinations + it("tests all supported algorithm combinations", async () => { + const algOptions = [ + "PBES2-HS256+A128KW", + "PBES2-HS384+A192KW", + "PBES2-HS512+A256KW", + ] as const; + const encOptions = ["A128GCM", "A192GCM", "A256GCM"] as const; + + // Test every combination of alg and enc + for (const alg of algOptions) { + for (const enc of encOptions) { + const sealed = await JWE.seal(testObject, password, { + protectedHeader: { alg, enc }, + }); + const unsealed = await JWE.unseal(sealed, password); + assert.equal(unsealed, testObject); + } + } + }); + + // Test different input data types and encodings + it("handles all input data types correctly", async () => { + // Test with ArrayBuffer + const buffer = new ArrayBuffer(16); + const view = new Uint8Array(buffer); + for (let i = 0; i < view.length; i++) view[i] = i; + + const sealed = await JWE.seal(view, password); + const unsealed = await JWE.unseal(sealed, password, { textOutput: false }); + assert.instanceOf(unsealed, Uint8Array); + + // Test with non-UTF8 data + const nonUtf8 = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]); + const sealedBinary = await JWE.seal(nonUtf8, password); + const unsealedBinary = await JWE.unseal(sealedBinary, password, { + textOutput: false, + }); + assert.instanceOf(unsealedBinary, Uint8Array); + assert.equal((unsealedBinary as Uint8Array).length, nonUtf8.length); + + // Test with Uint8Array of exactly the block size + const blockSized = new Uint8Array(16); // AES block size + blockSized.fill(1); + const sealedBlock = await JWE.seal(blockSized, password); + const unsealedBlock = await JWE.unseal(sealedBlock, password, { + textOutput: false, + }); + assert.equal((unsealedBlock as Uint8Array).length, blockSized.length); + }); + + // Test password handling edge cases + it("tests password handling edge cases", async () => { + // Test with password that's exactly a Uint8Array + const binaryPassword = new Uint8Array(32); + for (let i = 0; i < binaryPassword.length; i++) binaryPassword[i] = i + 1; + + const sealed = await JWE.seal(testObject, binaryPassword); + const unsealed = await JWE.unseal(sealed, binaryPassword); + assert.equal(unsealed, testObject); + + // Test with very short valid password + const shortPassword = "x"; + const sealedShort = await JWE.seal(testObject, shortPassword); + const unsealedShort = await JWE.unseal(sealedShort, shortPassword); + assert.equal(unsealedShort, testObject); + }); + + // Test special cases for base64 encoding/decoding + it("tests base64 encoding edge cases", async () => { + // Test with data that would produce different padding lengths + for (let i = 0; i < 5; i++) { + const testData = new Uint8Array(i); + testData.fill(0xff); + + const sealed = await JWE.seal(testData, password); + const unsealed = await JWE.unseal(sealed, password, { + textOutput: false, + }); + + assert.equal((unsealed as Uint8Array).length, i); + } + }); }); From 4f29e7ea611d5d594a38348d0f067c8b5ca6f71f Mon Sep 17 00:00:00 2001 From: Sandros94 Date: Sun, 23 Mar 2025 20:08:41 +0100 Subject: [PATCH 12/12] fix(session): jwe options --- src/utils/session.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/utils/session.ts b/src/utils/session.ts index 6cd81132b..62f9d26f5 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -178,7 +178,13 @@ export async function sealSession( ...(config.maxAge ? { exp: now + config.maxAge * 1000 } : {}), }; - const sealed = await seal(JSON.stringify(payload), config.password); + const sealed = await seal(JSON.stringify(payload), config.password, { + ...config.jwe, + protectedHeader: { + ...config.jwe?.protectedHeader, + cty: 'application/json', + } + }); return sealed; } @@ -188,7 +194,7 @@ export async function sealSession( */ export async function unsealSession( _event: H3Event, - config: SessionConfig, + config: Omit, sealed: string, ) { const now = @@ -223,7 +229,7 @@ export async function unsealSession( */ export function clearSession( event: H3Event, - config: Partial, + config: Partial>, ): Promise { const sessionName = config.name || DEFAULT_SESSION_NAME; if (event.context.sessions?.[sessionName]) {