@csrf-armor/core
Version:
Framework-agnostic CSRF protection core functionality
253 lines (251 loc) • 9 kB
JavaScript
import { TokenExpiredError, TokenInvalidError } from "./errors.js";
//#region src/crypto.ts
var CryptoKeyCache = class CryptoKeyCache {
static instance;
keyCache = /* @__PURE__ */ new Map();
MAX_CACHE_SIZE = 10;
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$1, { lastUsed }] of this.keyCache.entries()) if (lastUsed < oldestTime) {
oldestTime = lastUsed;
oldestKey = key$1;
}
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 timestamp = Math.floor(Date.now() / 1e3);
const exp = timestamp + expirySeconds;
const nonce = generateNonce();
const payload = `${exp}.${nonce}`;
const signature = await signPayload(payload, secret);
return `${payload}.${signature}`;
}
/**
* 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");
const payload = `${expStr}.${nonce}`;
const expectedSignature = await signPayload(payload, secret);
if (!timingSafeEqual(signature, expectedSignature)) throw new TokenInvalidError("Invalid signature");
const currentTime = Math.floor(Date.now() / 1e3);
if (currentTime > 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) {
const signature = await signPayload(unsignedToken, secret);
return `${unsignedToken}.${signature}`;
}
/**
* 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");
const expectedSignature = await signPayload(unsignedToken, secret);
if (!timingSafeEqual(signature, expectedSignature)) throw new TokenInvalidError("Invalid signature");
return unsignedToken;
}
async function signPayload(payload, secret) {
const keyCache = CryptoKeyCache.getInstance();
const key = await keyCache.getCachedKey(secret);
const encoder = new TextEncoder();
const messageData = encoder.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.js.map