UNPKG

@csrf-armor/core

Version:

Framework-agnostic CSRF protection core functionality

1 lines 13.5 kB
{"version":3,"file":"crypto.mjs","names":[],"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * @fileoverview Cryptographic utilities for CSRF token generation and validation.\n *\n * This module provides secure cryptographic functions for creating and verifying\n * CSRF tokens using Web Crypto API. All functions use timing-safe operations\n * and strong cryptographic primitives to prevent timing attacks and ensure\n * token security.\n */\n\nimport { TokenExpiredError, TokenInvalidError } from './errors.js';\nimport type { TokenPayload } from './types.js';\n\nclass CryptoKeyCache {\n private static instance: CryptoKeyCache;\n private readonly keyCache = new Map<\n string,\n { key: CryptoKey; lastUsed: number }\n >();\n private readonly MAX_CACHE_SIZE = 10;\n private readonly encoder = new TextEncoder();\n\n static getInstance(): CryptoKeyCache {\n if (!CryptoKeyCache.instance) {\n CryptoKeyCache.instance = new CryptoKeyCache();\n }\n return CryptoKeyCache.instance;\n }\n\n async getCachedKey(secret: string): Promise<CryptoKey> {\n const cached = this.keyCache.get(secret);\n\n if (cached) {\n cached.lastUsed = Date.now();\n return cached.key;\n }\n\n if (this.keyCache.size >= this.MAX_CACHE_SIZE) {\n let oldestKey = '';\n let oldestTime = Date.now();\n\n for (const [key, { lastUsed }] of this.keyCache.entries()) {\n if (lastUsed < oldestTime) {\n oldestTime = lastUsed;\n oldestKey = key;\n }\n }\n\n if (oldestKey) {\n this.keyCache.delete(oldestKey);\n }\n }\n\n const keyBuffer = this.encoder.encode(secret);\n const key = await crypto.subtle.importKey(\n 'raw',\n keyBuffer,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n\n this.keyCache.set(secret, { key, lastUsed: Date.now() });\n return key;\n }\n}\n\n/**\n * Generates a cryptographically secure random nonce.\n *\n * Creates a random hexadecimal string using the Web Crypto API's secure\n * random number generator. Used for preventing replay attacks and ensuring\n * token uniqueness across multiple generations.\n *\n * @public\n * @param length - Length of the nonce in bytes (default: 16 bytes = 32 hex chars)\n * @returns Hexadecimal string representing the random nonce\n *\n * @example\n * ```typescript\n * import { generateNonce } from '@csrf-armor/core';\n *\n * // Generate default 32-character nonce\n * const nonce = generateNonce();\n * console.log(nonce); // \"a1b2c3d4e5f6789...\"\n *\n * // Generate custom length nonce\n * const shortNonce = generateNonce(8);\n * console.log(shortNonce); // \"a1b2c3d4e5f67890\"\n * ```\n */\nexport function generateNonce(length = 16): string {\n const bytes = new Uint8Array(length);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(\n ''\n );\n}\n\n/**\n * Generates a cryptographically secure secret key.\n *\n * Creates a random secret suitable for HMAC operations. This is used internally\n * when no secret is provided in the configuration.\n *\n * **Security Warning**: Secrets generated by this function will be different\n * on each application restart, invalidating existing tokens. For production\n * use, always provide a consistent secret key.\n *\n * @internal\n * @returns Base64-encoded random secret key\n */\nexport function generateSecureSecret(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32));\n // Convert to base64 for a compact, high-entropy string without commas\n return btoa(String.fromCharCode.apply(null, [...bytes]));\n}\n\n/**\n * Generates a cryptographically signed CSRF token with expiration.\n *\n * Creates a tamper-proof token that includes an expiration timestamp and\n * a random nonce, secured with an HMAC-SHA256 signature. The token format\n * is: `{expiration}.{nonce}.{signature}`\n *\n * @public\n * @param secret - Secret key for HMAC signing (must be consistent across requests)\n * @param expirySeconds - Token validity duration in seconds from now\n * @returns Promise resolving to the signed token string\n *\n * @example\n * ```typescript\n * import { generateSignedToken } from '@csrf-armor/core';\n *\n * // Generate token valid for 1 hour\n * const token = await generateSignedToken('my-secret-key', 3600);\n * console.log(token); // \"1640995200.a1b2c3d4e5f67890.signature...\"\n *\n * // Generate short-lived token for sensitive operations\n * const shortToken = await generateSignedToken('my-secret-key', 300); // 5 minutes\n * ```\n *\n * @throws {Error} If Web Crypto API is not available or signing fails\n */\nexport async function generateSignedToken(\n secret: string,\n expirySeconds: number\n): Promise<string> {\n const timestamp = Math.floor(Date.now() / 1000);\n const exp = timestamp + expirySeconds;\n const nonce = generateNonce();\n\n const payload = `${exp}.${nonce}`;\n const signature = await signPayload(payload, secret);\n\n return `${payload}.${signature}`;\n}\n\n/**\n * Parses and validates a signed CSRF token.\n *\n * Extracts the expiration timestamp and nonce from a signed token,\n * verifies the signature, and checks that the token hasn't expired.\n * Uses timing-safe comparison to prevent timing-based attacks.\n *\n * @public\n * @param token - The signed token string to parse\n * @param secret - Secret key used for signature verification\n * @returns Promise resolving to the validated token payload\n *\n * @example\n * ```typescript\n * import { parseSignedToken } from '@csrf-armor/core';\n *\n * try {\n * const payload = await parseSignedToken(receivedToken, 'my-secret-key');\n * console.log('Token expires at:', new Date(payload.exp * 1000));\n * console.log('Token nonce:', payload.nonce);\n * } catch (error) {\n * if (error instanceof TokenExpiredError) {\n * console.log('Token has expired');\n * } else if (error instanceof TokenInvalidError) {\n * console.log('Token is invalid:', error.message);\n * }\n * }\n * ```\n *\n * @throws {TokenInvalidError} If token format is invalid or signature verification fails\n * @throws {TokenExpiredError} If token has expired based on current time\n */\nexport async function parseSignedToken(\n token: string,\n secret: string\n): Promise<TokenPayload> {\n const parts = token.split('.');\n if (parts.length !== 3) {\n throw new TokenInvalidError('Token must have 3 parts');\n }\n\n const [expStr, nonce, signature] = parts;\n\n if (!expStr || !nonce || !signature) {\n throw new TokenInvalidError('Token parts cannot be empty');\n }\n\n const exp = Number.parseInt(expStr, 10);\n\n if (Number.isNaN(exp)) {\n throw new TokenInvalidError('Invalid expiration timestamp');\n }\n\n const payload = `${expStr}.${nonce}`;\n const expectedSignature = await signPayload(payload, secret);\n\n if (!timingSafeEqual(signature, expectedSignature)) {\n throw new TokenInvalidError('Invalid signature');\n }\n\n const currentTime = Math.floor(Date.now() / 1000);\n if (currentTime > exp) {\n throw new TokenExpiredError();\n }\n\n return { exp, nonce };\n}\n\n/**\n * Signs an existing unsigned token with HMAC-SHA256.\n *\n * Takes a plain token string and appends a cryptographic signature,\n * creating a signed token in the format: `{token}.{signature}`\n *\n * @public\n * @param unsignedToken - The token string to sign\n * @param secret - Secret key for HMAC signing\n * @returns Promise resolving to the signed token\n *\n * @example\n * ```typescript\n * import { signUnsignedToken } from '@csrf-armor/core';\n *\n * const plainToken = generateNonce(32);\n * const signedToken = await signUnsignedToken(plainToken, 'my-secret-key');\n * console.log(signedToken); // \"a1b2c3d4e5f67890.signature...\"\n * ```\n */\nexport async function signUnsignedToken(\n unsignedToken: string,\n secret: string\n): Promise<string> {\n const signature = await signPayload(unsignedToken, secret);\n return `${unsignedToken}.${signature}`;\n}\n\n/**\n * Verifies a signed token and extracts the original unsigned token.\n *\n * Validates the HMAC-SHA256 signature of a signed token and returns\n * the original unsigned token if verification succeeds. Uses timing-safe\n * comparison to prevent timing-based signature attacks.\n *\n * @public\n * @param signedToken - The signed token to verify (format: `{token}.{signature}`)\n * @param secret - Secret key used for signature verification\n * @returns Promise resolving to the original unsigned token\n *\n * @example\n * ```typescript\n * import { verifySignedToken } from '@csrf-armor/core';\n *\n * try {\n * const originalToken = await verifySignedToken(\n * 'a1b2c3d4e5f67890.signature...',\n * 'my-secret-key'\n * );\n * console.log('Original token:', originalToken); // \"a1b2c3d4e5f67890\"\n * } catch (error) {\n * console.log('Token verification failed:', error.message);\n * }\n * ```\n *\n * @throws {TokenInvalidError} If token format is invalid or signature verification fails\n */\nexport async function verifySignedToken(\n signedToken: string,\n secret: string\n): Promise<string> {\n const parts = signedToken.split('.');\n if (parts.length !== 2) {\n throw new TokenInvalidError('Signed token must have 2 parts');\n }\n\n const [unsignedToken, signature] = parts;\n\n if (!unsignedToken || !signature) {\n throw new TokenInvalidError('Token parts cannot be empty');\n }\n\n const expectedSignature = await signPayload(unsignedToken, secret);\n\n if (!timingSafeEqual(signature, expectedSignature)) {\n throw new TokenInvalidError('Invalid signature');\n }\n\n return unsignedToken;\n}\n\nasync function signPayload(payload: string, secret: string): Promise<string> {\n const keyCache = CryptoKeyCache.getInstance();\n const key = await keyCache.getCachedKey(secret);\n\n const encoder = new TextEncoder();\n const messageData = encoder.encode(payload);\n\n const signature = await crypto.subtle.sign('HMAC', key, messageData);\n const signatureArray = new Uint8Array(signature);\n return Array.from(signatureArray, (byte) =>\n byte.toString(16).padStart(2, '0')\n ).join('');\n}\n\nexport function timingSafeEqual(a: string, b: string): boolean {\n // Work with the longer length to avoid early exit timing leaks\n const len = Math.max(a.length, b.length);\n\n // Mix the length difference into the result so unequal lengths still fail\n let result = a.length ^ b.length;\n\n for (let i = 0; i < len; i++) {\n const aCode = i < a.length ? a.charCodeAt(i) : 0;\n const bCode = i < b.length ? b.charCodeAt(i) : 0;\n result |= aCode ^ bCode;\n }\n\n return result === 0;\n}\n"],"mappings":"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"}