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/types/utils/session.ts b/src/types/utils/session.ts index f7e17bf9d..8874f3752 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,12 @@ 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; + /** 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 new file mode 100644 index 000000000..12fae5bb7 --- /dev/null +++ b/src/utils/internal/jwe.ts @@ -0,0 +1,598 @@ +import { subtle, getRandomValues } from "uncrypto"; +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?: KeyWrappingAlgorithmType; + /** + * `enc` (Encryption Algorithm): Header Parameter + * + * @default "A256GCM" + */ + enc?: ContentEncryptionAlgorithmType; + /** + * `p2c` (PBES2 Count): Header Parameter + * + * @default 2048 + */ + p2c?: number; + /** + * `p2s` (PBES2 Salt): Header Parameter + */ + p2s?: string; +} + +/** + * JWE (JSON Web Encryption) options + */ +export interface JWEOptions { + /** + * 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; +} + +/** + * 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" as KeyWrappingAlgorithmType, + enc: "A256GCM" as ContentEncryptionAlgorithmType, +}); + +/** + * 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 + * @returns Promise resolving to the compact JWE token + */ +export async function seal( + data: string | Uint8Array, + password: string | Uint8Array, + options: JWEOptions = {}, +): Promise { + // Configure options with defaults + const protectedHeader = options.protectedHeader || {}; + 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; + + // Generate random salt for PBES2 + const saltInput = randomBytes(saltSize); + + // Set up the protected header + const header = { + alg, + enc, + p2s: base64UrlEncode(saltInput), + p2c: iterations, + ...protectedHeader, + }; + + // Encode the protected header + const encodedHeader = base64UrlEncode( + textEncoder.encode(JSON.stringify(header)), + ); + + // Derive the key for key wrapping + const derivedKey = await deriveKeyFromPassword( + password, + saltInput, + iterations, + alg, + ); + + // Generate a random Content Encryption Key and wrap it + const { + wrappedKey, + rawCek: _, + cek, + } = await generateAndWrapCEK(derivedKey, encConfig); + + // Generate random initialization vector + const iv = randomBytes(encConfig.ivLength); + + let ciphertext: Uint8Array; + let tag: Uint8Array; + + // Encrypt the plaintext based on the encryption type + if (encConfig.type === "gcm") { + const result = await encryptGCM( + plaintext, + cek as CryptoKey, + iv, + 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(ciphertext), + base64UrlEncode(tag), + ].join("."); +} + +/** + * 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 + */ +export async function unseal( + token: string, + password: string | Uint8Array, +): Promise; +/** + * 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, + password: string | Uint8Array, + options: { textOutput: true }, +): Promise; +/** + * 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( + token: string, + password: string | Uint8Array, + options: { textOutput: false }, +): Promise; +/** + * 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, + password: string | Uint8Array, + options: { + /** + * Whether to return the decrypted data as a string (true) or as a Uint8Array (false). + * @default true + */ + textOutput?: boolean; + } = {}, +): Promise { + if (!token) { + throw new Error("Missing JWE token"); + } + + const textOutput = options.textOutput !== false; + + // Split the JWE token + const [ + encodedHeader, + encryptedKey, + encodedIv, + encodedCiphertext, + encodedTag, + ] = token.split("."); + + // Decode the header + const header = JSON.parse(textDecoder.decode(base64UrlDecode(encodedHeader))); + + // 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; + const saltInput = base64UrlDecode(header.p2s); + + // Derive the key unwrapping key + const derivedKey = await deriveKeyFromPassword( + password, + saltInput, + iterations, + alg, + ); + + // Decode the encrypted key, iv, ciphertext and tag + const wrappedKey = base64UrlDecode(encryptedKey); + const iv = base64UrlDecode(encodedIv); + const ciphertext = base64UrlDecode(encodedCiphertext); + const tag = base64UrlDecode(encodedTag); + + // Unwrap the CEK + const cek = await unwrapCEK(wrappedKey, derivedKey, encConfig); + + let decrypted: Uint8Array; + + // Decrypt based on encryption type + if (encConfig.type === "gcm") { + decrypted = await decryptGCM( + ciphertext, + tag, + cek as CryptoKey, + iv, + 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(decrypted) : decrypted; +} + +// Base64 URL encoding function +export function base64UrlEncode(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +// 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))); +} + +// Generate a random Uint8Array of specified length +function randomBytes(length: number): Uint8Array { + return getRandomValues(new Uint8Array(length)); +} + +/** + * 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, + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + + // Prepare the salt with algorithm prefix + const salt = concatUint8Arrays( + textEncoder.encode(alg), + new Uint8Array([0]), + saltInput, + ); + + return subtle.deriveKey( + { + name: "PBKDF2", + hash: algConfig.hash, + salt, + iterations, + }, + baseKey, + { 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/src/utils/session.ts b/src/utils/session.ts index 9b151f14a..62f9d26f5 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 } 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,10 +168,22 @@ export async function sealSession( (event.context.sessions?.[sessionName] as Session) || (await getSession(event, config)); - const sealed = await seal(session, config.password, { - ...sealDefaults, - ttl: config.maxAge ? config.maxAge * 1000 : 0, - ...config.seal, + // 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(JSON.stringify(payload), config.password, { + ...config.jwe, + protectedHeader: { + ...config.jwe?.protectedHeader, + cty: 'application/json', + } }); return sealed; @@ -176,21 +194,34 @@ export async function sealSession( */ export async function unsealSession( _event: H3Event, - config: SessionConfig, + config: Omit, sealed: string, ) { - const unsealed = (await unseal(sealed, config.password, { - ...sealDefaults, - ttl: config.maxAge ? config.maxAge * 1000 : 0, - ...config.seal, - })) 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); + const payload = JSON.parse(_payload); + + // 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; } /** @@ -198,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]) { diff --git a/test/unit/jwe.test.ts b/test/unit/jwe.test.ts new file mode 100644 index 000000000..fc5aae56f --- /dev/null +++ b/test/unit/jwe.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, assert } from "vitest"; +import * as JWE from "../../src/utils/internal/jwe"; + +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 data correctly", async () => { + const sealed = await JWE.seal(testObject, password); + const unsealed = await JWE.unseal(sealed, password); + 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"; + const sealedString = await JWE.seal(stringValue, password); + const unsealedString = await JWE.unseal(sealedString, password); + assert.equal(unsealedString, stringValue); + + // 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 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 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("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 }); + + // Compare byte arrays + assert.instanceOf(unsealed, Uint8Array); + assert.equal( + new TextDecoder().decode(unsealed as Uint8Array), + "Hello, world!", + ); + }); + + it("rejects invalid passwords", async () => { + const sealed = await JWE.seal(testObject, 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(); + + await expect( + JWE.unseal("part1.part2.part3.part4.part5.extrastuff", password), + ).rejects.toThrow(); + }); + + 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(); + + // 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(); + }); + + 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 = JWE.base64UrlEncode( + new TextEncoder().encode( + JSON.stringify({ + ...header, + alg: "PBES2-HS384+A192KW", + enc: "A128CBC-HS256", + }), + ), + ); + + const invalidAlgJWE = [ + invalidAlgHeader, + parts[1], + parts[2], + parts[3], + parts[4], + ].join("."); + + await expect(JWE.unseal(invalidAlgJWE, password)).rejects.toThrow( + "Unsupported encryption type: cbc", + ); + }); + + 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" } } } }, + 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.equal(unsealed, complexObj); + }); + + it("handles empty strings", async () => { + const sealed = await JWE.seal("", password); + const unsealed = await JWE.unseal(sealed, password); + assert.equal(unsealed, ""); + }); + + it("supports custom headers", async () => { + const options = { + 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].replace(/-/g, "+").replace(/_/g, "/")); + const header = JSON.parse(headerJson); + + assert.equal(header.kid, "test-key-id"); + assert.equal(header.customHeader, "custom-value"); + + // Verify we can still decrypt with the custom headers + const unsealed = await JWE.unseal(sealed, password); + assert.equal(unsealed, testObject); + }); + + it("allows changing iteration count", async () => { + const options = { + iterations: 1024, // Lower iterations for testing + }; + + const sealed = await JWE.seal(testObject, password, options); + 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); + }); + + // 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); + } + }); +});