From 03a8ec6a6cf3797fed2d94caf17383ca9c390df9 Mon Sep 17 00:00:00 2001 From: unnoq Date: Fri, 25 Jul 2025 15:39:13 +0700 Subject: [PATCH 1/8] setCookie & getCookie --- packages/server/package.json | 10 +- packages/server/src/helpers/cookie.test.ts | 106 +++++++++++++++++++++ packages/server/src/helpers/cookie.ts | 78 +++++++++++++++ packages/server/src/helpers/index.ts | 1 + pnpm-lock.yaml | 14 +++ 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/helpers/cookie.test.ts create mode 100644 packages/server/src/helpers/cookie.ts create mode 100644 packages/server/src/helpers/index.ts diff --git a/packages/server/package.json b/packages/server/package.json index 73605331e..c2c5243f4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,6 +20,11 @@ "import": "./dist/index.mjs", "default": "./dist/index.mjs" }, + "./helpers": { + "types": "./dist/helpers/index.d.mts", + "import": "./dist/helpers/index.mjs", + "default": "./dist/helpers/index.mjs" + }, "./plugins": { "types": "./dist/plugins/index.d.mts", "import": "./dist/plugins/index.mjs", @@ -84,6 +89,7 @@ }, "exports": { ".": "./src/index.ts", + "./helpers": "./src/helpers/index.ts", "./plugins": "./src/plugins/index.ts", "./hibernation": "./src/hibernation/index.ts", "./standard": "./src/adapters/standard/index.ts", @@ -125,7 +131,9 @@ "@orpc/standard-server-aws-lambda": "workspace:*", "@orpc/standard-server-fetch": "workspace:*", "@orpc/standard-server-node": "workspace:*", - "@orpc/standard-server-peer": "workspace:*" + "@orpc/standard-server-peer": "workspace:*", + "@types/cookie": "^1.0.0", + "cookie": "^1.0.2" }, "devDependencies": { "@types/ws": "^8.18.1", diff --git a/packages/server/src/helpers/cookie.test.ts b/packages/server/src/helpers/cookie.test.ts new file mode 100644 index 000000000..3b3495bae --- /dev/null +++ b/packages/server/src/helpers/cookie.test.ts @@ -0,0 +1,106 @@ +import { getCookie, setCookie } from './cookie' + +describe('setCookie', () => { + it('should work with Headers object', () => { + const headers = new Headers() + setCookie(headers, 'test', 'value', { httpOnly: true, maxAge: 3600 }) + + expect(headers.get('Set-Cookie')).toContain('test=value') + expect(headers.get('Set-Cookie')).toContain('HttpOnly') + expect(headers.get('Set-Cookie')).toContain('Max-Age=3600') + expect(headers.get('Set-Cookie')).toContain('Path=/') + }) + + it('should set default path to /', () => { + const headers = new Headers() + setCookie(headers, 'test', 'value') + + expect(headers.get('Set-Cookie')).toContain('Path=/') + }) + + it('should override default path when specified', () => { + const headers = new Headers() + setCookie(headers, 'test', 'value', { path: '/api' }) + + expect(headers.get('Set-Cookie')).toContain('Path=/api') + }) + + it('should append multiple cookies', () => { + const headers = new Headers() + setCookie(headers, 'first', 'value1') + setCookie(headers, 'second', 'value2') + + const setCookieValues = headers.getSetCookie() + expect(setCookieValues).toHaveLength(2) + expect(setCookieValues[0]).toContain('first=value1') + expect(setCookieValues[1]).toContain('second=value2') + }) + + it('should do nothing when headers is undefined', () => { + expect(() => setCookie(undefined, 'test', 'value')).not.toThrow() + }) + + it('should support all SerializeOptions', () => { + const headers = new Headers() + setCookie(headers, 'test', 'value', { + domain: 'example.com', + expires: new Date('2025-12-31'), + httpOnly: true, + maxAge: 86400, + path: '/custom', + secure: true, + sameSite: 'strict', + }) + + const cookieValue = headers.get('Set-Cookie')! + expect(cookieValue).toContain('test=value') + expect(cookieValue).toContain('Domain=example.com') + expect(cookieValue).toContain('HttpOnly') + expect(cookieValue).toContain('Max-Age=86400') + expect(cookieValue).toContain('Path=/custom') + expect(cookieValue).toContain('Secure') + expect(cookieValue).toContain('SameSite=Strict') + }) +}) + +describe('getCookie', () => { + it('should work with Headers object', () => { + const headers = new Headers() + headers.set('Cookie', 'test=value; session=abc123') + + expect(getCookie(headers, 'test')).toBe('value') + expect(getCookie(headers, 'session')).toBe('abc123') + expect(getCookie(headers, 'nonexistent')).toBeUndefined() + }) + + it('should return undefined when no cookie header is present', () => { + const headers = new Headers() + + expect(getCookie(headers, 'test')).toBeUndefined() + }) + + it('should return undefined when headers is undefined', () => { + expect(getCookie(undefined, 'test')).toBeUndefined() + }) + + it('should handle cookie parsing with special characters', () => { + const headers = new Headers() + headers.set('Cookie', 'encoded=%20value%20; normal=simple') + + expect(getCookie(headers, 'encoded')).toBe(' value ') + expect(getCookie(headers, 'normal')).toBe('simple') + }) + + it('should support ParseOptions', () => { + const headers = new Headers() + headers.set('Cookie', 'test="quoted value"; simple=unquoted') + + // Without decode option + expect(getCookie(headers, 'test')).toBe('"quoted value"') + + // With custom decode option that removes quotes + expect(getCookie(headers, 'test', { + decode: (val: string) => val.replace(/^"|"$/g, ''), + })).toBe('quoted value') + }) +}) diff --git a/packages/server/src/helpers/cookie.ts b/packages/server/src/helpers/cookie.ts new file mode 100644 index 000000000..9f348bdf4 --- /dev/null +++ b/packages/server/src/helpers/cookie.ts @@ -0,0 +1,78 @@ +import type { ParseOptions, SerializeOptions } from 'cookie' +import { parse, serialize } from 'cookie' + +export interface SetCookieOptions extends SerializeOptions { + /** + * Specifies the value for the [`Path` `Set-Cookie` attribute](https://tools.ietf.org/html/rfc6265#section-5.2.4). + * + * @default '/' + */ + path?: string +} + +/** + * Sets a cookie in the response headers, + * + * Does nothing if `headers` is `undefined`. + * + * @example + * ```ts + * const headers = new Headers() + * + * setCookie(headers, 'sessionId', 'abc123', { httpOnly: true, maxAge: 3600 }) + * + * expect(headers.get('Set-Cookie')).toBe('sessionId=abc123; HttpOnly; Max-Age=3600') + * ``` + * + */ +export function setCookie( + headers: Headers | undefined, + name: string, + value: string, + options: SetCookieOptions = {}, +): void { + if (headers === undefined) { + return + } + + const cookieString = serialize(name, value, { + path: '/', + ...options, + }) + + headers.append('Set-Cookie', cookieString) +} + +export interface GetCookieOptions extends ParseOptions {} + +/** + * Gets a cookie value from request headers + * + * Returns `undefined` if the cookie is not found or headers are `undefined`. + * + * @example + * ```ts + * const headers = new Headers({ 'Cookie': 'sessionId=abc123; theme=dark' }) + * + * const sessionId = getCookie(headers, 'sessionId') + * + * expect(sessionId).toEqual('abc123') + * ``` + */ +export function getCookie( + headers: Headers | undefined, + name: string, + options: GetCookieOptions = {}, +): string | undefined { + if (headers === undefined) { + return undefined + } + + const cookieHeader = headers.get('cookie') + + if (cookieHeader === null) { + return undefined + } + + return parse(cookieHeader, options)[name] +} diff --git a/packages/server/src/helpers/index.ts b/packages/server/src/helpers/index.ts new file mode 100644 index 000000000..4ce80d55a --- /dev/null +++ b/packages/server/src/helpers/index.ts @@ -0,0 +1 @@ +export * from './cookie' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23fd89852..cc62e5d6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,6 +522,12 @@ importers: '@orpc/standard-server-peer': specifier: workspace:* version: link:../standard-server-peer + '@types/cookie': + specifier: ^1.0.0 + version: 1.0.0 + cookie: + specifier: ^1.0.2 + version: 1.0.2 devDependencies: '@types/ws': specifier: ^8.18.1 @@ -5340,6 +5346,10 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/cookie@1.0.0': + resolution: {integrity: sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w==} + deprecated: This is a stub types definition. cookie provides its own type definitions, so you do not need this installed. + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -18545,6 +18555,10 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/cookie@1.0.0': + dependencies: + cookie: 1.0.2 + '@types/cookiejar@2.1.5': {} '@types/d3-array@3.2.1': {} From cc1bee8778f7c36ee6581cca81e2488d3b694870 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 26 Jul 2025 08:57:50 +0700 Subject: [PATCH 2/8] base64url + encryption + signing --- packages/server/src/helpers/base64url.test.ts | 36 +++ packages/server/src/helpers/base64url.ts | 52 ++++ .../server/src/helpers/encryption.test.ts | 72 ++++++ packages/server/src/helpers/encryption.ts | 115 +++++++++ packages/server/src/helpers/index.ts | 3 + packages/server/src/helpers/signing.test.ts | 234 ++++++++++++++++++ packages/server/src/helpers/signing.ts | 90 +++++++ 7 files changed, 602 insertions(+) create mode 100644 packages/server/src/helpers/base64url.test.ts create mode 100644 packages/server/src/helpers/base64url.ts create mode 100644 packages/server/src/helpers/encryption.test.ts create mode 100644 packages/server/src/helpers/encryption.ts create mode 100644 packages/server/src/helpers/signing.test.ts create mode 100644 packages/server/src/helpers/signing.ts diff --git a/packages/server/src/helpers/base64url.test.ts b/packages/server/src/helpers/base64url.test.ts new file mode 100644 index 000000000..e674c191a --- /dev/null +++ b/packages/server/src/helpers/base64url.test.ts @@ -0,0 +1,36 @@ +import { decodeBase64url, encodeBase64url } from './base64url' + +describe('encodeBase64url / decodeBase64url', () => { + it('should encode and decode Uint8Array correctly', () => { + const original = new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]) + const encoded = encodeBase64url(original) + const decoded = decodeBase64url(encoded) + + expect(decoded).toEqual(original) + }) + + it('should produce URL-safe output without padding', () => { + const data = new Uint8Array([255, 254, 253]) // Will produce +/= characters in regular base64 + const encoded = encodeBase64url(data) + + expect(encoded).not.toMatch(/[+/=]/) + expect(encoded).toMatch(/^[\w-]+$/) + }) + + it('should handle empty data', () => { + const empty = new Uint8Array([]) + const encoded = encodeBase64url(empty) + const decoded = decodeBase64url(encoded) + + expect(encoded).toBe('') + expect(decoded).toEqual(empty) + }) + + it('should work with TextEncoder/TextDecoder', () => { + const text = 'Hello World' + const encoded = encodeBase64url(new TextEncoder().encode(text)) + const decoded = decodeBase64url(encoded) + + expect(new TextDecoder().decode(decoded)).toEqual(text) + }) +}) diff --git a/packages/server/src/helpers/base64url.ts b/packages/server/src/helpers/base64url.ts new file mode 100644 index 000000000..518da60dd --- /dev/null +++ b/packages/server/src/helpers/base64url.ts @@ -0,0 +1,52 @@ +/** + * Encodes a Uint8Array to base64url format + * Base64url is URL-safe and doesn't use padding + * + * @example + * ```ts + * const text = "Hello World" + * const encoded = encodeBase64url(new TextEncoder().encode(text)) + * const decoded = decodeBase64url(encoded) + * expect(new TextDecoder().decode(decoded)).toEqual(text) + * ``` + */ +export function encodeBase64url(data: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...data)) + return base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +/** + * Decodes a base64url string to Uint8Array + * Returns undefined if the input is invalid + * + * @example + * ```ts + * const text = "Hello World" + * const encoded = encodeBase64url(new TextEncoder().encode(text)) + * const decoded = decodeBase64url(encoded) + * expect(new TextDecoder().decode(decoded)).toEqual(text) + * ``` + */ +export function decodeBase64url(base64url: string | undefined | null): Uint8Array | undefined { + try { + if (typeof base64url !== 'string') { + return undefined + } + + let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + + while (base64.length % 4) { + base64 += '=' + } + + return new Uint8Array( + atob(base64).split('').map(char => char.charCodeAt(0)), + ) + } + catch { + return undefined + } +} diff --git a/packages/server/src/helpers/encryption.test.ts b/packages/server/src/helpers/encryption.test.ts new file mode 100644 index 000000000..0a4ace9e2 --- /dev/null +++ b/packages/server/src/helpers/encryption.test.ts @@ -0,0 +1,72 @@ +import { decrypt, encrypt } from './encryption' + +describe('encrypt/decrypt', () => { + const secret = 'test-secret-key' + const message = 'Hello, World!' + + it('should encrypt and decrypt successfully', async () => { + const encrypted = await encrypt(message, secret) + const decrypted = await decrypt(encrypted, secret) + + expect(decrypted).toBe(message) + }) + + it('should produce base64url encoded output', async () => { + const encrypted = await encrypt(message, secret) + + // Should not contain base64 special characters + expect(encrypted).not.toMatch(/[+/=]/) + // Should only contain base64url safe characters + expect(encrypted).toMatch(/^[\w-]+$/) + }) + + it('should produce different encrypted values each time', async () => { + const encrypted1 = await encrypt(message, secret) + const encrypted2 = await encrypt(message, secret) + + expect(encrypted1).not.toBe(encrypted2) + + // But both should decrypt to the same message + const decrypted1 = await decrypt(encrypted1, secret) + const decrypted2 = await decrypt(encrypted2, secret) + + expect(decrypted1).toBe(message) + expect(decrypted2).toBe(message) + }) + + it('should return undefined for wrong secret', async () => { + const encrypted = await encrypt(message, secret) + const decrypted = await decrypt(encrypted, 'wrong-secret') + + expect(decrypted).toBeUndefined() + }) + + it('should return undefined for corrupted data', async () => { + const encrypted = await encrypt(message, secret) + const corrupted = `${encrypted.slice(0, -5)}XXXXX` + const decrypted = await decrypt(corrupted, secret) + + expect(decrypted).toBeUndefined() + }) + + it('should handle Unicode characters', async () => { + const unicodeMessage = '你好世界 🌍 Здравствуй мир 🚀' + const encrypted = await encrypt(unicodeMessage, secret) + const decrypted = await decrypt(encrypted, secret) + + expect(decrypted).toBe(unicodeMessage) + }) + + it('should handle empty string', async () => { + const empty = '' + const encrypted = await encrypt(empty, secret) + const decrypted = await decrypt(encrypted, secret) + + expect(decrypted).toBe(empty) + }) + + it('should return undefined if encrypted=null/undefined', async () => { + expect(await decrypt(undefined, 'secret')).toBeUndefined() + expect(await decrypt(null, 'secret')).toBeUndefined() + }) +}) diff --git a/packages/server/src/helpers/encryption.ts b/packages/server/src/helpers/encryption.ts new file mode 100644 index 000000000..3d2b1f1cf --- /dev/null +++ b/packages/server/src/helpers/encryption.ts @@ -0,0 +1,115 @@ +import { decodeBase64url, encodeBase64url } from './base64url' + +/** + * Encrypts a string using AES-GCM with a secret key. + * The output is base64url encoded to be URL-safe. + * + * @example + * ```ts + * const encrypted = await encrypt("Hello, World!", "test-secret-key") + * const decrypted = await decrypt(encrypted, "test-secret-key") + * expect(decrypted).toBe("Hello, World!") + * ``` + */ +export async function encrypt(value: string, secret: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(value) + + const salt = crypto.getRandomValues(new Uint8Array(16)) + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + 'PBKDF2', + false, + ['deriveKey'], + ) + + const key = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'], + ) + + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data, + ) + + const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength) + result.set(salt, 0) + result.set(iv, salt.length) + result.set(new Uint8Array(encrypted), salt.length + iv.length) + + return encodeBase64url(result) +} + +/** + * Decrypts a base64url encoded string using AES-GCM with a secret key. + * Returns the original string if decryption is successful, or undefined if it fails. + * + * @example + * ```ts + * const encrypted = await encrypt("Hello, World!", "test-secret-key") + * const decrypted = await decrypt(encrypted, "test-secret-key") + * expect(decrypted).toBe("Hello, World!") + * ``` + */ +export async function decrypt(encrypted: string | undefined | null, secret: string): Promise { + try { + const data = decodeBase64url(encrypted) + + if (data === undefined) { + return undefined + } + + const encoder = new TextEncoder() + const decoder = new TextDecoder() + + const salt = data.slice(0, 16) + const iv = data.slice(16, 28) + const encryptedData = data.slice(28) + + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + 'PBKDF2', + false, + ['deriveKey'], + ) + + const key = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'], + ) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encryptedData, + ) + + return decoder.decode(decrypted) + } + catch { + // Return undefined if decryption fails (invalid key, corrupted data, etc.) + return undefined + } +} diff --git a/packages/server/src/helpers/index.ts b/packages/server/src/helpers/index.ts index 4ce80d55a..79fbe74a2 100644 --- a/packages/server/src/helpers/index.ts +++ b/packages/server/src/helpers/index.ts @@ -1 +1,4 @@ +export * from './base64url' export * from './cookie' +export * from './encryption' +export * from './signing' diff --git a/packages/server/src/helpers/signing.test.ts b/packages/server/src/helpers/signing.test.ts new file mode 100644 index 000000000..c6e995aea --- /dev/null +++ b/packages/server/src/helpers/signing.test.ts @@ -0,0 +1,234 @@ +import { sign, unsign } from './signing' + +describe('signing', () => { + const secret = 'test-secret-key' + const value = 'user123' + + describe('sign', () => { + it('should sign a value and return signed string with signature', async () => { + const signedValue = await sign(value, secret) + + expect(signedValue).toContain('.') + expect(signedValue.startsWith(`${value}.`)).toBe(true) + + const parts = signedValue.split('.') + expect(parts).toHaveLength(2) + expect(parts[0]).toBe(value) + expect(parts[1]).toBeTruthy() // Should have a signature part + }) + + it('should produce consistent signatures for same input', async () => { + const signedValue1 = await sign(value, secret) + const signedValue2 = await sign(value, secret) + + expect(signedValue1).toBe(signedValue2) + }) + + it('should produce different signatures for different secrets', async () => { + const signedValue1 = await sign(value, secret) + const signedValue2 = await sign(value, 'different-secret') + + expect(signedValue1).not.toBe(signedValue2) + }) + + it('should produce different signatures for different values', async () => { + const signedValue1 = await sign(value, secret) + const signedValue2 = await sign('different-value', secret) + + expect(signedValue1).not.toBe(signedValue2) + }) + + it('should handle empty string values', async () => { + const signedValue = await sign('', secret) + + expect(signedValue).toContain('.') + expect(signedValue.startsWith('.')).toBe(true) + }) + + it('should handle values with dots', async () => { + const valueWithDot = 'user.123.test' + const signedValue = await sign(valueWithDot, secret) + + expect(signedValue).toContain('.') + expect(signedValue.startsWith(`${valueWithDot}.`)).toBe(true) + }) + + it('should handle special characters in value', async () => { + const specialValue = 'user@domain.com!#$%' + const signedValue = await sign(specialValue, secret) + + expect(signedValue).toContain('.') + expect(signedValue.startsWith(`${specialValue}.`)).toBe(true) + }) + + it('should handle unicode characters', async () => { + const unicodeValue = 'user-🚀-test-中文' + const signedValue = await sign(unicodeValue, secret) + + expect(signedValue).toContain('.') + expect(signedValue.startsWith(`${unicodeValue}.`)).toBe(true) + }) + }) + + describe('unsign', () => { + it('should successfully unsign a valid signed value', async () => { + const signedValue = await sign(value, secret) + const unsignedValue = await unsign(signedValue, secret) + + expect(unsignedValue).toBe(value) + }) + + it('should return undefined for undefined/null input', async () => { + expect(await unsign(undefined, secret)).toBeUndefined() + expect(await unsign(null, secret)).toBeUndefined() + }) + + it('should return undefined for invalid signature', async () => { + const signedValue = await sign(value, secret) + const unsignedValue = await unsign(signedValue, 'wrong-secret') + + expect(unsignedValue).toBeUndefined() + }) + + it('should return undefined for tampered value', async () => { + const signedValue = await sign(value, secret) + const tamperedValue = signedValue.replace(value, 'tampered') + const unsignedValue = await unsign(tamperedValue, secret) + + expect(unsignedValue).toBeUndefined() + }) + + it('should return undefined for tampered signature', async () => { + const signedValue = await sign(value, secret) + const parts = signedValue.split('.') + const tamperedSignedValue = `${parts[0]}.tamperedsignature` + const unsignedValue = await unsign(tamperedSignedValue, secret) + + expect(unsignedValue).toBeUndefined() + }) + + it('should return undefined for value without dot separator', async () => { + const unsignedValue = await unsign('no-dot-separator', secret) + + expect(unsignedValue).toBeUndefined() + }) + + it('should return undefined for empty signature part', async () => { + const invalidSignedValue = `${value}.` + const unsignedValue = await unsign(invalidSignedValue, secret) + + expect(unsignedValue).toBeUndefined() + }) + + it('should return undefined for invalid base64url signature', async () => { + const invalidSignedValue = `${value}.invalid@base64url!` + const unsignedValue = await unsign(invalidSignedValue, secret) + + expect(unsignedValue).toBeUndefined() + }) + + it('should handle values with multiple dots correctly', async () => { + const valueWithDots = 'user.123.test' + const signedValue = await sign(valueWithDots, secret) + const unsignedValue = await unsign(signedValue, secret) + + expect(unsignedValue).toBe(valueWithDots) + }) + + it('should handle empty string values', async () => { + const emptyValue = '' + const signedValue = await sign(emptyValue, secret) + const unsignedValue = await unsign(signedValue, secret) + + expect(unsignedValue).toBe(emptyValue) + }) + + it('should handle special characters', async () => { + const specialValue = 'user@domain.com!#$%' + const signedValue = await sign(specialValue, secret) + const unsignedValue = await unsign(signedValue, secret) + + expect(unsignedValue).toBe(specialValue) + }) + + it('should handle unicode characters', async () => { + const unicodeValue = 'user-🚀-test-中文' + const signedValue = await sign(unicodeValue, secret) + const unsignedValue = await unsign(signedValue, secret) + + expect(unsignedValue).toBe(unicodeValue) + }) + + it('should return undefined for malformed input that throws errors', async () => { + // Test various malformed inputs that might cause crypto operations to throw + const malformedInputs = [ + 'value.', + '.signature', + 'value.invalid-signature-length', + 'value.!@#$%^&*()', + ] + + for (const input of malformedInputs) { + const unsignedValue = await unsign(input, secret) + expect(unsignedValue).toBeUndefined() + } + }) + }) + + describe('integration tests', () => { + it('should maintain integrity through sign/unsign cycle', async () => { + const testValues = [ + 'simple', + '', + 'with.dots', + 'with spaces and special chars!@#', + '🚀 unicode test 中文', + 'very-long-value-that-might-test-edge-cases-in-the-crypto-implementation-and-base64url-encoding', + ] + + for (const testValue of testValues) { + const signedValue = await sign(testValue, secret) + const unsignedValue = await unsign(signedValue, secret) + expect(unsignedValue).toBe(testValue) + } + }) + + it('should fail unsign with different secrets', async () => { + const secrets = ['secret1', 'secret2', 'different-secret'] + + for (let i = 0; i < secrets.length; i++) { + const currentSecret = secrets[i]! + const signedValue = await sign(value, currentSecret) + + for (let j = 0; j < secrets.length; j++) { + const testSecret = secrets[j]! + const unsignedValue = await unsign(signedValue, testSecret) + + if (i === j) { + expect(unsignedValue).toBe(value) + } + else { + expect(unsignedValue).toBeUndefined() + } + } + } + }) + + it('should produce consistent results across multiple operations', async () => { + const results = [] + + for (let i = 0; i < 10; i++) { + const signedValue = await sign(value, secret) + const unsignedValue = await unsign(signedValue, secret) + results.push({ signed: signedValue, unsigned: unsignedValue }) + } + + // All signed values should be identical + const firstSigned = results[0]!.signed + expect(results.every(r => r.signed === firstSigned)).toBe(true) + + // All unsigned values should be identical and match original + expect(results.every(r => r.unsigned === value)).toBe(true) + }) + }) +}) diff --git a/packages/server/src/helpers/signing.ts b/packages/server/src/helpers/signing.ts new file mode 100644 index 000000000..1dd6eee59 --- /dev/null +++ b/packages/server/src/helpers/signing.ts @@ -0,0 +1,90 @@ +import { decodeBase64url, encodeBase64url } from './base64url' + +const ALGORITHM = { name: 'HMAC', hash: 'SHA-256' } + +/** + * Signs a string value using HMAC-SHA256 with a secret key. + * + * This function creates a cryptographic signature that can be used to verify + * the integrity and authenticity of the data. The signature is appended to + * the original value, separated by a dot, using base64url encoding (no padding). + * + * + * @example + * ```ts + * const signedValue = await sign("user123", "my-secret-key") + * expect(signedValue).toEqual("user123.oneQsU0r5dvwQFHFEjjV1uOI_IR3gZfkYHij3TRauVA") + * ``` + */ +export async function sign(value: string, secret: string): Promise { + const encoder = new TextEncoder() + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + ALGORITHM, + false, + ['sign'], + ) + + const signature = await crypto.subtle.sign( + ALGORITHM, + key, + encoder.encode(value), + ) + + return `${value}.${encodeBase64url(new Uint8Array(signature))}` +} + +/** + * Verifies and extracts the original value from a signed string. + * + * This function validates the signature of a previously signed value using the same + * secret key. If the signature is valid, it returns the original value. If the + * signature is invalid or the format is incorrect, it returns undefined. + * + * + * @example + * ```ts + * const signedValue = "user123.oneQsU0r5dvwQFHFEjjV1uOI_IR3gZfkYHij3TRauVA" + * const originalValue = await unsign(signedValue, "my-secret-key") + * expect(originalValue).toEqual("user123") + * ``` + */ +export async function unsign(signedValue: string | undefined | null, secret: string): Promise { + if (typeof signedValue !== 'string') { + return undefined + } + + const lastDotIndex = signedValue.lastIndexOf('.') + if (lastDotIndex === -1) { + return undefined + } + + const value = signedValue.slice(0, lastDotIndex) + const signatureBase64url = signedValue.slice(lastDotIndex + 1) + const signature = decodeBase64url(signatureBase64url) + + if (signature === undefined) { + return undefined + } + + const encoder = new TextEncoder() + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + ALGORITHM, + false, + ['verify'], + ) + + const isValid = await crypto.subtle.verify( + ALGORITHM, + key, + signature, + encoder.encode(value), + ) + + return isValid ? value : undefined +} From 71b9fe4c01b455f94852a6e41a20f2262b227b2c Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 26 Jul 2025 09:07:13 +0700 Subject: [PATCH 3/8] improve --- packages/server/src/helpers/base64url.test.ts | 20 ++++++++++++++++++ packages/server/src/helpers/base64url.ts | 21 +++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/server/src/helpers/base64url.test.ts b/packages/server/src/helpers/base64url.test.ts index e674c191a..d1d31b203 100644 --- a/packages/server/src/helpers/base64url.test.ts +++ b/packages/server/src/helpers/base64url.test.ts @@ -33,4 +33,24 @@ describe('encodeBase64url / decodeBase64url', () => { expect(new TextDecoder().decode(decoded)).toEqual(text) }) + + it('should handle large data without call stack overflow', () => { + // Create a large Uint8Array (100KB) + const largeData = new Uint8Array(100 * 1024) + for (let i = 0; i < largeData.length; i++) { + largeData[i] = i % 256 + } + + const encoded = encodeBase64url(largeData) + const decoded = decodeBase64url(encoded) + + expect(decoded).toEqual(largeData) + expect(encoded).not.toMatch(/[+/=]/) // Should still be URL-safe + }) + + it('should handle invalid input gracefully', () => { + expect(decodeBase64url(null)).toBeUndefined() + expect(decodeBase64url(undefined)).toBeUndefined() + expect(decodeBase64url('invalid base64!')).toBeUndefined() + }) }) diff --git a/packages/server/src/helpers/base64url.ts b/packages/server/src/helpers/base64url.ts index 518da60dd..c7ea78804 100644 --- a/packages/server/src/helpers/base64url.ts +++ b/packages/server/src/helpers/base64url.ts @@ -11,7 +11,15 @@ * ``` */ export function encodeBase64url(data: Uint8Array): string { - const base64 = btoa(String.fromCharCode(...data)) + const chunkSize = 8192 // 8KB chunks to stay well below call stack limits + let binaryString = '' + + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.subarray(i, i + chunkSize) + binaryString += String.fromCharCode(...chunk) + } + + const base64 = btoa(binaryString) return base64 .replace(/\+/g, '-') .replace(/\//g, '_') @@ -42,9 +50,14 @@ export function decodeBase64url(base64url: string | undefined | null): Uint8Arra base64 += '=' } - return new Uint8Array( - atob(base64).split('').map(char => char.charCodeAt(0)), - ) + const binaryString = atob(base64) + + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + return bytes } catch { return undefined From dba767b525027c989164ac9dcdce738f67b913d2 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 26 Jul 2025 09:55:32 +0700 Subject: [PATCH 4/8] docs --- apps/content/.vitepress/config.ts | 10 +++++ apps/content/docs/helpers/base64url.md | 22 ++++++++++ apps/content/docs/helpers/cookie.md | 47 ++++++++++++++++++++++ apps/content/docs/helpers/encryption.md | 29 +++++++++++++ apps/content/docs/helpers/signing.md | 29 +++++++++++++ packages/server/src/helpers/cookie.test.ts | 7 ++++ 6 files changed, 144 insertions(+) create mode 100644 apps/content/docs/helpers/base64url.md create mode 100644 apps/content/docs/helpers/cookie.md create mode 100644 apps/content/docs/helpers/encryption.md create mode 100644 apps/content/docs/helpers/signing.md diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index 240148b47..78c6d3c1c 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -141,6 +141,16 @@ export default withMermaid(defineConfig({ { text: 'Strict GET method', link: '/docs/plugins/strict-get-method' }, ], }, + { + text: 'Helpers', + collapsed: true, + items: [ + { text: 'Base64Url', link: '/docs/helpers/base64url' }, + { text: 'Cookie', link: '/docs/helpers/cookie' }, + { text: 'Encryption', link: '/docs/helpers/encryption' }, + { text: 'Signing', link: '/docs/helpers/signing' }, + ], + }, { text: 'Client', collapsed: true, diff --git a/apps/content/docs/helpers/base64url.md b/apps/content/docs/helpers/base64url.md new file mode 100644 index 000000000..3579d4f33 --- /dev/null +++ b/apps/content/docs/helpers/base64url.md @@ -0,0 +1,22 @@ +--- +title: Base64Url Helpers +description: Functions to encode and decode base64url strings, which are URL-safe variants of base64 encoding commonly used in web applications for tokens, data serialization, and API communication. +--- + +# Base64Url Helpers + +The Base64Url helpers provide functions to encode and decode base64url strings, which are URL-safe variants of base64 encoding commonly used in web applications for tokens, data serialization, and API communication. + +```ts twoslash +import { decodeBase64url, encodeBase64url } from '@orpc/server/helpers' + +const originalText = 'Hello World' +const textBytes = new TextEncoder().encode(originalText) +const encodedData = encodeBase64url(textBytes) +const decodedBytes = decodeBase64url(encodedData) +const decodedText = new TextDecoder().decode(decodedBytes) // 'Hello World' +``` + +::: info +The `decodeBase64url` helper accepts `undefined` or `null` as input and returns `undefined` for invalid inputs, enabling seamless integration with optional data handling patterns. +::: diff --git a/apps/content/docs/helpers/cookie.md b/apps/content/docs/helpers/cookie.md new file mode 100644 index 000000000..8f5296fa5 --- /dev/null +++ b/apps/content/docs/helpers/cookie.md @@ -0,0 +1,47 @@ +--- +title: Cookie Helpers +description: Functions for managing HTTP cookies in web applications. +--- + +# Cookie Helpers + +The Cookie helpers provide functions to set and get HTTP cookies. + +```ts twoslash +import { getCookie, setCookie } from '@orpc/server/helpers' + +const headers = new Headers() + +setCookie(headers, 'sessionId', 'abc123', { + httpOnly: true, + secure: true, + maxAge: 3600 +}) + +const sessionId = getCookie(headers, 'sessionId') // 'abc123' +``` + +::: info +Both helpers accept `undefined` as headers for seamless integration with plugins like [Request Headers](/docs/plugins/request-headers) or [Response Headers](/docs/plugins/response-headers). +::: + +## Security with Signing and Encryption + +Combine cookies with [signing](/docs/helpers/signing) or [encryption](/docs/helpers/encryption) for enhanced security: + +```ts twoslash +import { getCookie, setCookie, sign, unsign } from '@orpc/server/helpers' + +const secret = 'your-secret-key' + +const headers = new Headers() + +setCookie(headers, 'sessionId', await sign('abc123', secret), { + httpOnly: true, + secure: true, + maxAge: 3600 +}) + +const signedSessionId = await unsign(getCookie(headers, 'sessionId'), secret) +// 'abc123' +``` diff --git a/apps/content/docs/helpers/encryption.md b/apps/content/docs/helpers/encryption.md new file mode 100644 index 000000000..4a0a48c00 --- /dev/null +++ b/apps/content/docs/helpers/encryption.md @@ -0,0 +1,29 @@ +--- +title: Encryption Helpers +description: Functions to encrypt and decrypt sensitive data using AES-GCM, preventing users from reading data content but with slower performance compared to signing. +--- + +# Encryption Helpers + +The Encryption helpers provide functions to encrypt and decrypt sensitive data using AES-GCM with PBKDF2 key derivation. + +::: info +Encryption prevents users from reading data content but is slower than [signing](/docs/helpers/signing). +::: + +```ts twoslash +import { decrypt, encrypt } from '@orpc/server/helpers' + +const secret = 'your-encryption-key' +const sensitiveData = 'user-email@example.com' + +const encryptedData = await encrypt(sensitiveData, secret) +// 'Rq7wF8...' (base64url encoded, unreadable) + +const decryptedData = await decrypt(encryptedData, secret) +// 'user-email@example.com' +``` + +::: info +The `decrypt` helper accepts `undefined` or `null` as input and returns `undefined` for invalid inputs, enabling seamless integration with optional data handling patterns. +::: diff --git a/apps/content/docs/helpers/signing.md b/apps/content/docs/helpers/signing.md new file mode 100644 index 000000000..16ac51ed7 --- /dev/null +++ b/apps/content/docs/helpers/signing.md @@ -0,0 +1,29 @@ +--- +title: Signing Helpers +description: Functions to cryptographically sign and verify data using HMAC-SHA256. Faster than encryption but end users can read the data content. +--- + +# Signing Helpers + +The Signing helpers provide functions to cryptographically sign and verify data using HMAC-SHA256. + +::: info +Signing is faster than [encryption](/docs/helpers/encryption) but end users can read the data content. +::: + +```ts twoslash +import { sign, unsign } from '@orpc/server/helpers' + +const secret = 'your-secret-key' +const userData = 'user123' + +const signedValue = await sign(userData, secret) +// 'user123.oneQsU0r5dvwQFHFEjjV1uOI_IR3gZfkYHij3TRauVA' +// ↑ Original data is visible to users + +const verifiedValue = await unsign(signedValue, secret) // 'user123' +``` + +::: info +The `unsign` helper accepts `undefined` or `null` as input and returns `undefined` for invalid inputs, enabling seamless integration with optional data handling patterns. +::: diff --git a/packages/server/src/helpers/cookie.test.ts b/packages/server/src/helpers/cookie.test.ts index 3b3495bae..6efcc7c1b 100644 --- a/packages/server/src/helpers/cookie.test.ts +++ b/packages/server/src/helpers/cookie.test.ts @@ -103,4 +103,11 @@ describe('getCookie', () => { decode: (val: string) => val.replace(/^"|"$/g, ''), })).toBe('quoted value') }) + + it('should handle invalid cookie formats gracefully', () => { + const headers = new Headers() + headers.set('Cookie', 'invalid-cookie-format=%XX') + + expect(getCookie(headers, 'invalid-cookie-format')).toEqual('%XX') + }) }) From 9617eb24a32bd54603ca37911d573482753da838 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 26 Jul 2025 10:10:53 +0700 Subject: [PATCH 5/8] docs --- apps/content/docs/helpers/cookie.md | 1 - apps/content/docs/plugins/request-headers.md | 7 ++++++- apps/content/docs/plugins/response-headers.md | 10 +++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/content/docs/helpers/cookie.md b/apps/content/docs/helpers/cookie.md index 8f5296fa5..caf387e3d 100644 --- a/apps/content/docs/helpers/cookie.md +++ b/apps/content/docs/helpers/cookie.md @@ -13,7 +13,6 @@ import { getCookie, setCookie } from '@orpc/server/helpers' const headers = new Headers() setCookie(headers, 'sessionId', 'abc123', { - httpOnly: true, secure: true, maxAge: 3600 }) diff --git a/apps/content/docs/plugins/request-headers.md b/apps/content/docs/plugins/request-headers.md index 9b8e6d608..138f69be8 100644 --- a/apps/content/docs/plugins/request-headers.md +++ b/apps/content/docs/plugins/request-headers.md @@ -17,6 +17,7 @@ There's no functional difference, but this plugin provides a consistent interfac ```ts twoslash import { os } from '@orpc/server' // ---cut--- +import { getCookie } from '@orpc/server/helpers' import { RequestHeadersPluginContext } from '@orpc/server/plugins' interface ORPCContext extends RequestHeadersPluginContext {} @@ -25,7 +26,7 @@ const base = os.$context() const example = base .use(({ context, next }) => { - const authHeader = context.reqHeaders?.get('authorization') + const sessionId = getCookie(context.reqHeaders, 'session_id') return next() }) .handler(({ context }) => { @@ -39,6 +40,10 @@ const example = base This allows procedures to run safely even when `RequestHeadersPlugin` is not used, such as in direct calls. ::: +::: tip +Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie management. +::: + ## Handler Setup ```ts diff --git a/apps/content/docs/plugins/response-headers.md b/apps/content/docs/plugins/response-headers.md index 213bf4f46..e6d4c9d83 100644 --- a/apps/content/docs/plugins/response-headers.md +++ b/apps/content/docs/plugins/response-headers.md @@ -12,6 +12,7 @@ The Response Headers Plugin allows you to set response headers in oRPC. It injec ```ts twoslash import { os } from '@orpc/server' // ---cut--- +import { setCookie } from '@orpc/server/helpers' import { ResponseHeadersPluginContext } from '@orpc/server/plugins' interface ORPCContext extends ResponseHeadersPluginContext {} @@ -24,7 +25,10 @@ const example = base return next() }) .handler(({ context }) => { - context.resHeaders?.set('x-custom-header', 'value') + setCookie(context.resHeaders, 'session_id', 'abc123', { + secure: true, + maxAge: 3600 + }) }) ``` @@ -33,6 +37,10 @@ const example = base This allows procedures to run safely even when `ResponseHeadersPlugin` is not used, such as in direct calls. ::: +::: tip +Combine with [Cookie Helpers](/docs/helpers/cookie) for streamlined cookie management. +::: + ## Handler Setup ```ts From 283313b1e4eb10817fcbb1966af9a0fc65d551cb Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 26 Jul 2025 10:12:59 +0700 Subject: [PATCH 6/8] tests --- packages/server/src/helpers/cookie.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/src/helpers/cookie.test.ts b/packages/server/src/helpers/cookie.test.ts index 6efcc7c1b..5ce54a9fc 100644 --- a/packages/server/src/helpers/cookie.test.ts +++ b/packages/server/src/helpers/cookie.test.ts @@ -67,9 +67,11 @@ describe('getCookie', () => { it('should work with Headers object', () => { const headers = new Headers() headers.set('Cookie', 'test=value; session=abc123') + headers.append('Cookie', 'another=value2') expect(getCookie(headers, 'test')).toBe('value') expect(getCookie(headers, 'session')).toBe('abc123') + expect(getCookie(headers, 'another')).toBe('value2') expect(getCookie(headers, 'nonexistent')).toBeUndefined() }) From 1987236bbe7f997561bee777e40ae0450c6ef5db Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 26 Jul 2025 10:27:46 +0700 Subject: [PATCH 7/8] improve security --- packages/server/package.json | 1 - packages/server/src/helpers/encryption.ts | 14 ++++++++------ pnpm-lock.yaml | 11 ----------- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index c2c5243f4..1906565a2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -132,7 +132,6 @@ "@orpc/standard-server-fetch": "workspace:*", "@orpc/standard-server-node": "workspace:*", "@orpc/standard-server-peer": "workspace:*", - "@types/cookie": "^1.0.0", "cookie": "^1.0.2" }, "devDependencies": { diff --git a/packages/server/src/helpers/encryption.ts b/packages/server/src/helpers/encryption.ts index 3d2b1f1cf..8e81bcb18 100644 --- a/packages/server/src/helpers/encryption.ts +++ b/packages/server/src/helpers/encryption.ts @@ -1,5 +1,11 @@ import { decodeBase64url, encodeBase64url } from './base64url' +const ALGORITHM = { + name: 'PBKDF2', + iterations: 60_000, // OWASP PBKDF2-HMAC-SHA256 minimum for 2025 + hash: 'SHA-256', +} + /** * Encrypts a string using AES-GCM with a secret key. * The output is base64url encoded to be URL-safe. @@ -26,10 +32,8 @@ export async function encrypt(value: string, secret: string): Promise { const key = await crypto.subtle.deriveKey( { - name: 'PBKDF2', + ...ALGORITHM, salt, - iterations: 100000, - hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256 }, @@ -89,10 +93,8 @@ export async function decrypt(encrypted: string | undefined | null, secret: stri const key = await crypto.subtle.deriveKey( { - name: 'PBKDF2', + ...ALGORITHM, salt, - iterations: 100000, - hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256 }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc62e5d6f..f0ba9760f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,9 +522,6 @@ importers: '@orpc/standard-server-peer': specifier: workspace:* version: link:../standard-server-peer - '@types/cookie': - specifier: ^1.0.0 - version: 1.0.0 cookie: specifier: ^1.0.2 version: 1.0.2 @@ -5346,10 +5343,6 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/cookie@1.0.0': - resolution: {integrity: sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w==} - deprecated: This is a stub types definition. cookie provides its own type definitions, so you do not need this installed. - '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -18555,10 +18548,6 @@ snapshots: '@types/cookie@0.6.0': {} - '@types/cookie@1.0.0': - dependencies: - cookie: 1.0.2 - '@types/cookiejar@2.1.5': {} '@types/d3-array@3.2.1': {} From 0d98757a87484968009fdc44048766e6232bc4f6 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 26 Jul 2025 13:54:10 +0700 Subject: [PATCH 8/8] improve --- apps/content/docs/helpers/base64url.md | 6 +-- apps/content/docs/helpers/encryption.md | 6 +-- apps/content/docs/helpers/signing.md | 8 ++-- packages/server/src/helpers/encryption.ts | 48 ++++++++++++----------- 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/apps/content/docs/helpers/base64url.md b/apps/content/docs/helpers/base64url.md index 3579d4f33..af5d5d93a 100644 --- a/apps/content/docs/helpers/base64url.md +++ b/apps/content/docs/helpers/base64url.md @@ -1,11 +1,11 @@ --- title: Base64Url Helpers -description: Functions to encode and decode base64url strings, which are URL-safe variants of base64 encoding commonly used in web applications for tokens, data serialization, and API communication. +description: Functions to encode and decode base64url strings, a URL-safe variant of base64 encoding. --- # Base64Url Helpers -The Base64Url helpers provide functions to encode and decode base64url strings, which are URL-safe variants of base64 encoding commonly used in web applications for tokens, data serialization, and API communication. +Base64Url helpers provide functions to encode and decode base64url strings, a URL-safe variant of base64 encoding used in web tokens, data serialization, and APIs. ```ts twoslash import { decodeBase64url, encodeBase64url } from '@orpc/server/helpers' @@ -18,5 +18,5 @@ const decodedText = new TextDecoder().decode(decodedBytes) // 'Hello World' ``` ::: info -The `decodeBase64url` helper accepts `undefined` or `null` as input and returns `undefined` for invalid inputs, enabling seamless integration with optional data handling patterns. +The `decodeBase64url` accepts `undefined` or `null` as encoded value and returns `undefined` for invalid inputs, enabling seamless handling of optional data. ::: diff --git a/apps/content/docs/helpers/encryption.md b/apps/content/docs/helpers/encryption.md index 4a0a48c00..316d4f7ff 100644 --- a/apps/content/docs/helpers/encryption.md +++ b/apps/content/docs/helpers/encryption.md @@ -1,11 +1,11 @@ --- title: Encryption Helpers -description: Functions to encrypt and decrypt sensitive data using AES-GCM, preventing users from reading data content but with slower performance compared to signing. +description: Functions to encrypt and decrypt sensitive data using AES-GCM. --- # Encryption Helpers -The Encryption helpers provide functions to encrypt and decrypt sensitive data using AES-GCM with PBKDF2 key derivation. +Encryption helpers provide functions to encrypt and decrypt sensitive data using AES-GCM with PBKDF2 key derivation. ::: info Encryption prevents users from reading data content but is slower than [signing](/docs/helpers/signing). @@ -25,5 +25,5 @@ const decryptedData = await decrypt(encryptedData, secret) ``` ::: info -The `decrypt` helper accepts `undefined` or `null` as input and returns `undefined` for invalid inputs, enabling seamless integration with optional data handling patterns. +The `decrypt` helper accepts `undefined` or `null` as encrypted value and returns `undefined` for invalid inputs, enabling seamless handling of optional data. ::: diff --git a/apps/content/docs/helpers/signing.md b/apps/content/docs/helpers/signing.md index 16ac51ed7..f1444f8ae 100644 --- a/apps/content/docs/helpers/signing.md +++ b/apps/content/docs/helpers/signing.md @@ -1,14 +1,14 @@ --- title: Signing Helpers -description: Functions to cryptographically sign and verify data using HMAC-SHA256. Faster than encryption but end users can read the data content. +description: Functions to cryptographically sign and verify data using HMAC-SHA256. --- # Signing Helpers -The Signing helpers provide functions to cryptographically sign and verify data using HMAC-SHA256. +Signing helpers provide functions to cryptographically sign and verify data using HMAC-SHA256. ::: info -Signing is faster than [encryption](/docs/helpers/encryption) but end users can read the data content. +Signing is faster than [encryption](/docs/helpers/encryption) but users can view the original data. ::: ```ts twoslash @@ -25,5 +25,5 @@ const verifiedValue = await unsign(signedValue, secret) // 'user123' ``` ::: info -The `unsign` helper accepts `undefined` or `null` as input and returns `undefined` for invalid inputs, enabling seamless integration with optional data handling patterns. +The `unsign` helper accepts `undefined` or `null` as signed value and returns `undefined` for invalid inputs, enabling seamless handling of optional data. ::: diff --git a/packages/server/src/helpers/encryption.ts b/packages/server/src/helpers/encryption.ts index 8e81bcb18..fba667110 100644 --- a/packages/server/src/helpers/encryption.ts +++ b/packages/server/src/helpers/encryption.ts @@ -1,10 +1,20 @@ import { decodeBase64url, encodeBase64url } from './base64url' -const ALGORITHM = { +const PBKDF2_CONFIG = { name: 'PBKDF2', - iterations: 60_000, // OWASP PBKDF2-HMAC-SHA256 minimum for 2025 + iterations: 60_000, // Recommended minimum iterations per current OWASP guidelines hash: 'SHA-256', -} +} as const + +const AES_GCM_CONFIG = { + name: 'AES-GCM', + length: 256, +} as const + +const CRYPTO_CONSTANTS = { + SALT_LENGTH: 16, + IV_LENGTH: 12, +} as const /** * Encrypts a string using AES-GCM with a secret key. @@ -21,30 +31,27 @@ export async function encrypt(value: string, secret: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(value) - const salt = crypto.getRandomValues(new Uint8Array(16)) + const salt = crypto.getRandomValues(new Uint8Array(CRYPTO_CONSTANTS.SALT_LENGTH)) const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(secret), - 'PBKDF2', + PBKDF2_CONFIG.name, false, ['deriveKey'], ) const key = await crypto.subtle.deriveKey( - { - ...ALGORITHM, - salt, - }, + { ...PBKDF2_CONFIG, salt }, keyMaterial, - { name: 'AES-GCM', length: 256 }, + AES_GCM_CONFIG, false, ['encrypt'], ) - const iv = crypto.getRandomValues(new Uint8Array(12)) + const iv = crypto.getRandomValues(new Uint8Array(CRYPTO_CONSTANTS.IV_LENGTH)) const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, + { name: AES_GCM_CONFIG.name, iv }, key, data, ) @@ -79,31 +86,28 @@ export async function decrypt(encrypted: string | undefined | null, secret: stri const encoder = new TextEncoder() const decoder = new TextDecoder() - const salt = data.slice(0, 16) - const iv = data.slice(16, 28) - const encryptedData = data.slice(28) + const salt = data.slice(0, CRYPTO_CONSTANTS.SALT_LENGTH) + const iv = data.slice(CRYPTO_CONSTANTS.SALT_LENGTH, CRYPTO_CONSTANTS.SALT_LENGTH + CRYPTO_CONSTANTS.IV_LENGTH) + const encryptedData = data.slice(CRYPTO_CONSTANTS.SALT_LENGTH + CRYPTO_CONSTANTS.IV_LENGTH) const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(secret), - 'PBKDF2', + PBKDF2_CONFIG.name, false, ['deriveKey'], ) const key = await crypto.subtle.deriveKey( - { - ...ALGORITHM, - salt, - }, + { ...PBKDF2_CONFIG, salt }, keyMaterial, - { name: 'AES-GCM', length: 256 }, + AES_GCM_CONFIG, false, ['decrypt'], ) const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, + { name: AES_GCM_CONFIG.name, iv }, key, encryptedData, )