diff --git a/.env.example b/.env.example index 048949f..f396361 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ FRONTEND_URL=http://localhost:5173 # Docker Postgres defaults DATABASE_URL=postgresql://postgres:postgres@localhost:5432/accesslayer +APP_SECRET=your_32_character_long_secret_string_here GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/src/config.ts b/src/config.ts index 9876cd4..0baef39 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,10 @@ export const envSchema = z.object({ DATABASE_URL: z .string() .min(1, 'DATABASE_URL is required in the environment variables'), + APP_SECRET: z + .string() + .min(32, 'APP_SECRET should be at least 32 characters') + .default('accesslayer_default_development_secret_key_32_bytes_long'), GMAIL_USER: z.string(), GMAIL_APP_PASSWORD: z.string(), diff --git a/src/utils/cursor.utils.ts b/src/utils/cursor.utils.ts new file mode 100644 index 0000000..89dd7b3 --- /dev/null +++ b/src/utils/cursor.utils.ts @@ -0,0 +1,96 @@ +import crypto from 'crypto'; +import { Buffer } from 'node:buffer'; +import { envConfig } from '../config'; + +/** + * Custom error class thrown when cursor validation fails. + */ +export class CursorChecksumError extends Error { + constructor(message: string = 'Invalid or tampered cursor checksum') { + super(message); + this.name = 'CursorChecksumError'; + } +} + +/** + * Generates an HMAC SHA-256 checksum for the given string payload. + * + * @param payload - The data string to checksum + * @returns A hex string representing the checksum + */ +export function generateCursorChecksum(payload: string): string { + const secret = envConfig.APP_SECRET; + return crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +/** + * Encodes an object payload into a base64url string with an appended HMAC checksum. + * Format: `base64url(payload).checksum` + * + * @param payload - The object or string to encode into a cursor + * @returns The signed cursor string + */ +export function encodeCursor(payload: T): string { + const payloadStr = JSON.stringify(payload); + const base64Payload = Buffer.from(payloadStr).toString('base64url'); + const checksum = generateCursorChecksum(base64Payload); + + return `${base64Payload}.${checksum}`; +} + +/** + * Decodes a cursor string, verifying its integrity via the appended checksum. + * + * @param cursor - The signed cursor string (e.g. `base64url.checksum`) + * @returns The decoded payload object + * @throws {CursorChecksumError} If the checksum is missing or invalid + */ +export function decodeCursor(cursor: string): T { + if (!cursor || typeof cursor !== 'string') { + throw new CursorChecksumError('Cursor must be a provided string'); + } + + const parts = cursor.split('.'); + if (parts.length !== 2) { + throw new CursorChecksumError('Invalid cursor format. Expected base64payload.checksum'); + } + + const [base64Payload, providedChecksum] = parts; + + if (!base64Payload || !providedChecksum) { + throw new CursorChecksumError('Cursor payload or checksum cannot be empty'); + } + + const expectedChecksum = generateCursorChecksum(base64Payload); + + // Use timing-safe equal to prevent timing attacks comparing checksums + let expectedBuffer: Buffer; + let providedBuffer: Buffer; + + try { + expectedBuffer = Buffer.from(expectedChecksum, 'hex'); + providedBuffer = Buffer.from(providedChecksum, 'hex'); + } catch { + throw new CursorChecksumError('Invalid checksum format'); + } + + if ( + expectedBuffer.length !== providedBuffer.length || + !crypto.timingSafeEqual(expectedBuffer, providedBuffer) + ) { + throw new CursorChecksumError('Cursor checksum mismatch'); + } + + let payloadStr: string; + try { + payloadStr = Buffer.from(base64Payload, 'base64url').toString('utf8'); + } catch { + throw new CursorChecksumError('Failed to decode base64 payload'); + } + + try { + return JSON.parse(payloadStr) as T; + } catch { + throw new CursorChecksumError('Failed to parse cursor JSON payload'); + } +} diff --git a/src/utils/test/cursor.utils.test.ts b/src/utils/test/cursor.utils.test.ts new file mode 100644 index 0000000..a32a848 --- /dev/null +++ b/src/utils/test/cursor.utils.test.ts @@ -0,0 +1,101 @@ +jest.mock('../../config', () => ({ + envConfig: { + APP_SECRET: 'test_secret_for_hmac_123456789012', + }, +})); + +import { + encodeCursor, + decodeCursor, + generateCursorChecksum, + CursorChecksumError, +} from '../cursor.utils'; + +describe('Cursor Utils', () => { + const samplePayload = { id: 'user_123', createdAt: '2023-01-01T00:00:00.000Z' }; + + describe('generateCursorChecksum', () => { + it('should return a deterministic 64-character hex string for the same input', () => { + const checksum1 = generateCursorChecksum('test_payload'); + const checksum2 = generateCursorChecksum('test_payload'); + + expect(checksum1).toBe(checksum2); + expect(checksum1).toHaveLength(64); + expect(/^[0-9a-f]{64}$/.test(checksum1)).toBe(true); + }); + + it('should return different checksums for different inputs', () => { + const checksum1 = generateCursorChecksum('test_payload_1'); + const checksum2 = generateCursorChecksum('test_payload_2'); + + expect(checksum1).not.toBe(checksum2); + }); + }); + + describe('encodeCursor', () => { + it('should generate a cursor containing exactly one dot delimiter', () => { + const cursor = encodeCursor(samplePayload); + expect(cursor).toContain('.'); + expect(cursor.split('.')).toHaveLength(2); + }); + + it('should generate consistent cursors for the same payload object', () => { + const cursor1 = encodeCursor(samplePayload); + const cursor2 = encodeCursor(samplePayload); + expect(cursor1).toBe(cursor2); + }); + }); + + describe('decodeCursor', () => { + it('should correctly decode a valid cursor', () => { + const cursor = encodeCursor(samplePayload); + const decoded = decodeCursor(cursor); + expect(decoded).toEqual(samplePayload); + }); + + it('should throw CursorChecksumError for invalid formats', () => { + expect(() => decodeCursor('not_a_valid_cursor')).toThrow(CursorChecksumError); + expect(() => decodeCursor('foo.bar.baz')).toThrow(CursorChecksumError); + }); + + it('should throw CursorChecksumError when checksum is tampered', () => { + const cursor = encodeCursor(samplePayload); + const [payload, checksum] = cursor.split('.'); + const tamperedChecksum = checksum.substring(0, 63) + (checksum.endsWith('a') ? 'b' : 'a'); + const tamperedCursor = `${payload}.${tamperedChecksum}`; + + expect(() => decodeCursor(tamperedCursor)).toThrow(CursorChecksumError); + }); + + it('should throw CursorChecksumError when payload is tampered', () => { + const cursor = encodeCursor(samplePayload); + const [payload, checksum] = cursor.split('.'); + // Change base64 by modifying a character + const tamperedPayload = payload.substring(0, payload.length - 1) + (payload.endsWith('a') ? 'b' : 'a'); + const tamperedCursor = `${tamperedPayload}.${checksum}`; + + expect(() => decodeCursor(tamperedCursor)).toThrow(CursorChecksumError); + }); + + it('should throw CursorChecksumError for empty string inputs', () => { + expect(() => decodeCursor('')).toThrow(CursorChecksumError); + expect(() => decodeCursor('.')).toThrow(CursorChecksumError); + expect(() => decodeCursor('payload.')).toThrow(CursorChecksumError); + expect(() => decodeCursor('.checksum')).toThrow(CursorChecksumError); + }); + + it('should throw CursorChecksumError for invalid target typed primitives', () => { + + expect(() => decodeCursor(123 as any)).toThrow(CursorChecksumError); + }); + + it('should throw CursorChecksumError for malformed JSON payload', () => { + const badJsonStr = 'bad_json_string'; + const payload = Buffer.from(badJsonStr).toString('base64url'); + const checksum = generateCursorChecksum(payload); + const cursor = `${payload}.${checksum}`; + + expect(() => decodeCursor(cursor)).toThrow(CursorChecksumError); + }); + }); +});