UNPKG

@cap.js/server

Version:

Server-side challenge generator and verifier for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.

537 lines (458 loc) 14.2 kB
// @ts-check /// <reference lib="dom" /> /// <reference types="node" /> /** * @typedef {import('node:crypto')} Crypto * @typedef {import('node:fs/promises')} FsPromises * @typedef {import('fs').PathLike} PathLike */ /** * @typedef {[string, string]} ChallengeTuple */ /** * @typedef {Object} ChallengeData * @property {Object} challenge - Challenge configuration object * @property {number} challenge.c - Number of challenges * @property {number} challenge.s - Size of each challenge * @property {number} challenge.d - Difficulty level * @property {number} expires - Expiration timestamp */ /** * @typedef {Object} ChallengeState * @property {Record<string, ChallengeData>} challengesList - Map of challenge tokens to challenge data * @property {Record<string, number>} tokensList - Map of token hashes to expiration timestamps */ /** * @typedef {Object} ChallengeConfig * @property {number} [challengeCount=50] - Number of challenges to generate * @property {number} [challengeSize=32] - Size of each challenge in bytes * @property {number} [challengeDifficulty=4] - Difficulty level of the challenge * @property {number} [expiresMs=600000] - Time in milliseconds until the challenge expires * @property {boolean} [store=true] - Whether to store the challenge in memory */ /** * @typedef {Object} TokenConfig * @property {boolean} [keepToken] - Whether to keep the token after validation */ /** * @typedef {Object} Solution * @property {string} token - Challenge token * @property {number[]} solutions - Array of challenge solutions */ /** * @typedef {Object} ChallengeStorage * @property {function(string, ChallengeData): Promise<void>} store - Store challenge data * @property {function(string): Promise<ChallengeData|null>} read - Retrieve challenge data * @property {function(string): Promise<void>} delete - Delete challenge data * @property {function(): Promise<string[]>} listExpired - List expired challenge tokens */ /** * @typedef {Object} TokenStorage * @property {function(string, number): Promise<void>} store - Store token with expiration * @property {function(string): Promise<number|null>} get - Retrieve token expiration * @property {function(string): Promise<void>} delete - Delete token * @property {function(): Promise<string[]>} listExpired - List expired token keys */ /** * @typedef {Object} StorageHooks * @property {ChallengeStorage} [challenges] - Challenge storage hooks * @property {TokenStorage} [tokens] - Token storage hooks */ /** * @typedef {Object} CapConfig * @property {string} tokens_store_path - Path to store the tokens file * @property {ChallengeState} state - State configuration * @property {boolean} noFSState - Whether to disable the state file * @property {boolean} [disableAutoCleanup] - Whether to disable automatic cleanup of expired tokens and challenges * @property {StorageHooks} [storage] - Custom storage hooks for challenges and tokens */ /** @type {typeof import('node:crypto')} */ const crypto = require("node:crypto"); /** @type {typeof import('node:fs/promises')} */ const fs = require("node:fs/promises"); const { EventEmitter } = require("node:events"); const DEFAULT_TOKENS_STORE = ".data/tokensList.json"; /** * Generates a deterministic hex string of given length from a string seed * * @param {string} seed - Initial seed value * @param {number} length - Output hex string length * @returns {string} Deterministic hex string generated from the seed */ function prng(seed, length) { /** * @param {string} str */ function fnv1a(str) { let hash = 2166136261; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); } return hash >>> 0; } let state = fnv1a(seed); let result = ""; function next() { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return state >>> 0; } while (result.length < length) { const rnd = next(); result += rnd.toString(16).padStart(8, "0"); } return result.substring(0, length); } /** * Main Cap class * @extends EventEmitter */ class Cap extends EventEmitter { /** @type {Promise<void>|null} */ _cleanupPromise; /** @type {number} */ _lastCleanup; /** @type {CapConfig} */ config; /** * Creates a new Cap instance * @param {Partial<CapConfig>} [configObj] - Configuration object */ constructor(configObj) { super(); this._cleanupPromise = null; this._lastCleanup = 0; /** @type {CapConfig} */ this.config = { tokens_store_path: DEFAULT_TOKENS_STORE, noFSState: false, state: { challengesList: {}, tokensList: {}, }, ...configObj, }; if (!this.config.noFSState && !this.config.storage?.tokens) { this._loadTokens().catch(() => {}); } process.on("beforeExit", () => this.cleanup()); ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => { process.once(signal, () => { this.cleanup() .then(() => process.exit(0)) .catch(() => process.exit(1)); }); }); } /** * Performs cleanup if enough time has passed since last cleanup * @private * @returns {Promise<void>} */ async _lazyCleanup() { if (this.config.disableAutoCleanup) return; const now = Date.now(); const fiveMinutes = 5 * 60 * 1000; if (now - this._lastCleanup > fiveMinutes) { await this._cleanExpiredTokens(); this._lastCleanup = now; } } /** * Retrieves challenge data from storage * @private * @param {string} token - Challenge token * @returns {Promise<ChallengeData|null>} Challenge data or null if not found */ async _getChallenge(token) { if (this.config.storage?.challenges?.read) { return (await this.config.storage.challenges.read(token)) || null; } return this.config.state.challengesList[token] || null; } /** * Deletes challenge from storage * @private * @param {string} token - Challenge token * @returns {Promise<void>} */ async _deleteChallenge(token) { if (this.config.storage?.challenges?.delete) { await this.config.storage.challenges.delete(token); } else { delete this.config.state.challengesList[token]; } } /** * Generates a new challenge * @param {ChallengeConfig} [conf] - Challenge configuration * @returns {Promise<{ challenge: {c: number, s: number, d: number}, token?: string, expires: number }>} Challenge data */ async createChallenge(conf) { await this._lazyCleanup(); /** @type {{c: number, s: number, d: number}} */ const challenge = { c: conf?.challengeCount ?? 50, s: conf?.challengeSize ?? 32, d: conf?.challengeDifficulty ?? 4, }; const token = crypto.randomBytes(25).toString("hex"); const expires = Date.now() + (conf?.expiresMs ?? 600000); if (conf && conf.store === false) { return { challenge, expires }; } const challengeData = { expires, challenge }; if (this.config.storage?.challenges?.store) { await this.config.storage.challenges.store(token, challengeData); } else { this.config.state.challengesList[token] = challengeData; } return { challenge, token, expires }; } /** * Redeems a challenge solution in exchange for a token * @param {Solution} param0 - Challenge solution data * @returns {Promise<{success: boolean, message?: string, token?: string, expires?: number}>} */ async redeemChallenge({ token, solutions }) { if ( !token || !solutions || !Array.isArray(solutions) || solutions.some((s) => typeof s !== "number") ) { return { success: false, message: "Invalid body" }; } await this._lazyCleanup(); const challengeData = await this._getChallenge(token); await this._deleteChallenge(token); if (!challengeData || challengeData.expires < Date.now()) { return { success: false, message: "Challenge invalid or expired" }; } let i = 0; const challenges = Array.from({ length: challengeData.challenge.c }, () => { i = i + 1; return [ prng(`${token}${i}`, challengeData.challenge.s), prng(`${token}${i}d`, challengeData.challenge.d), ]; }); const isValid = challenges.every(([salt, target], i) => { return ( solutions[i] && crypto .createHash("sha256") .update(salt + solutions[i]) .digest("hex") .startsWith(target) ); }); if (!isValid) return { success: false, message: "Invalid solution" }; const vertoken = crypto.randomBytes(15).toString("hex"); const expires = Date.now() + 20 * 60 * 1000; const hash = crypto.createHash("sha256").update(vertoken).digest("hex"); const id = crypto.randomBytes(8).toString("hex"); const tokenKey = `${id}:${hash}`; if (this.config.storage?.tokens?.store) { await this.config.storage.tokens.store(tokenKey, expires); } else { if (this?.config?.state?.tokensList) { this.config.state.tokensList[tokenKey] = expires; } if (!this.config.noFSState) { await fs.writeFile( this.config.tokens_store_path, JSON.stringify(this.config.state.tokensList), "utf8", ); } } return { success: true, token: `${id}:${vertoken}`, expires }; } /** * Retrieves token expiration from storage * @private * @param {string} tokenKey - Token key * @returns {Promise<number|null>} Token expiration or null if not found */ async _getToken(tokenKey) { if (this.config.storage?.tokens?.get) { return await this.config.storage.tokens.get(tokenKey); } return this.config.state.tokensList[tokenKey] || null; } /** * Deletes token from storage * @private * @param {string} tokenKey - Token key * @returns {Promise<void>} */ async _deleteToken(tokenKey) { if (this.config.storage?.tokens?.delete) { await this.config.storage.tokens.delete(tokenKey); } else { delete this.config.state.tokensList[tokenKey]; if (!this.config.noFSState) { await fs.writeFile( this.config.tokens_store_path, JSON.stringify(this.config.state.tokensList), "utf8", ); } } } /** * Validates a token * @param {string} token - The token to validate * @param {TokenConfig} [conf] - Validation configuration * @returns {Promise<{success: boolean}>} */ async validateToken(token, conf) { await this._lazyCleanup(); if (!token || typeof token !== "string") { return { success: false }; } const parts = token.split(":"); if (parts.length !== 2 || !parts[0] || !parts[1]) { return { success: false }; } const [id, vertoken] = parts; const hash = crypto.createHash("sha256").update(vertoken).digest("hex"); const key = `${id}:${hash}`; await this._waitForTokensList(); const tokenExpires = await this._getToken(key); if (tokenExpires && tokenExpires > Date.now()) { if (!conf?.keepToken) { await this._deleteToken(key); } return { success: true }; } return { success: false }; } /** * Loads tokens from the storage file * @private * @returns {Promise<void>} */ async _loadTokens() { try { const dirPath = this.config.tokens_store_path .split("/") .slice(0, -1) .join("/"); if (dirPath) { await fs.mkdir(dirPath, { recursive: true }); } try { await fs.access(this.config.tokens_store_path); const data = await fs.readFile(this.config.tokens_store_path, "utf-8"); this.config.state.tokensList = JSON.parse(data) || {}; await this._lazyCleanup(); } catch { console.warn(`[cap] tokens file not found, creating a new empty one`); await fs.writeFile(this.config.tokens_store_path, "{}", "utf-8"); this.config.state.tokensList = {}; } } catch { console.warn( `[cap] Couldn't load or write tokens file, using empty state`, ); this.config.state.tokensList = {}; } } /** * Removes expired tokens and challenges from memory and storage * @private * @returns {Promise<boolean>} - True if any tokens were changed/removed */ async _cleanExpiredTokens() { const now = Date.now(); let tokensChanged = false; if (this.config.storage?.challenges?.listExpired) { const expiredChallenges = await this.config.storage.challenges.listExpired(); await Promise.all( expiredChallenges.map(async (token) => { await this._deleteChallenge(token); }), ); } else if (!this.config.storage?.challenges) { const expired = Object.entries(this.config.state.challengesList) .filter(([_, v]) => v.expires < now) .map(([k]) => k); await Promise.all( expired.map(async (k) => { await this._deleteChallenge(k); }), ); } else { console.warn( "[cap] challenge storage hooks provided but no listExpired, couldn't delete expired challenges", ); } if (this.config.storage?.tokens?.listExpired) { const expiredTokens = await this.config.storage.tokens.listExpired(); await Promise.all( expiredTokens.map(async (tokenKey) => { await this._deleteToken(tokenKey); tokensChanged = true; }), ); } else if (!this.config.storage?.tokens) { for (const k in this.config.state.tokensList) { if (this.config.state.tokensList[k] < now) { await this._deleteToken(k); tokensChanged = true; } } } else { console.warn( "[cap] token storage hooks provided but no listExpired, couldn't delete expired tokens", ); } return tokensChanged; } /** * Waits for the tokens list to be initialized * @private * @returns {Promise<void>} */ _waitForTokensList() { return new Promise((resolve) => { const l = () => { if (this.config.state.tokensList) { return resolve(); } setTimeout(l, 10); }; l(); }); } /** * Cleans up expired tokens and syncs state * @returns {Promise<void>} */ async cleanup() { if (this._cleanupPromise) return this._cleanupPromise; this._cleanupPromise = (async () => { const tokensChanged = await this._cleanExpiredTokens(); if ( tokensChanged && !this.config.noFSState && !this.config.storage?.tokens?.store ) { await fs.writeFile( this.config.tokens_store_path, JSON.stringify(this.config.state.tokensList), "utf8", ); } })(); return this._cleanupPromise; } } /** @type {typeof Cap} */ module.exports = Cap;