UNPKG

@csrf-armor/core

Version:

Framework-agnostic CSRF protection core functionality

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