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.

336 lines (284 loc) 9.35 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 {Array<ChallengeTuple>} challenge - Array of [salt, target] tuples * @property {number} expires - Expiration timestamp * @property {string} token - Challenge token */ /** * @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 - The challenge token * @property {Array<[string, string, number]>} solutions - Array of [salt, target, solution] tuples */ /** * @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 */ /** @type {typeof import('node:crypto')} */ const crypto = require("crypto"); /** @type {typeof import('node:fs/promises')} */ const fs = require("fs/promises"); const { EventEmitter } = require("events"); const DEFAULT_TOKENS_STORE = ".data/tokensList.json"; /** * Main Cap class * @extends EventEmitter */ class Cap extends EventEmitter { /** @type {Promise<void>|null} */ _cleanupPromise; /** @type {Required<CapConfig>} */ config; /** * Creates a new Cap instance * @param {Partial<CapConfig>} [configObj] - Configuration object */ constructor(configObj) { super(); this._cleanupPromise = null; /** @type {Required<CapConfig>} */ this.config = { tokens_store_path: DEFAULT_TOKENS_STORE, noFSState: false, state: { challengesList: {}, tokensList: {}, }, ...configObj, }; if (!this.config.noFSState) { 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)); }); }); } /** * Generates a new challenge * @param {ChallengeConfig} [conf] - Challenge configuration * @returns {{ challenge: Array<ChallengeTuple>, token?: string, expires: number }} Challenge data */ createChallenge(conf) { this._cleanExpiredTokens(); /** @type {Array<ChallengeTuple>} */ const challenges = Array.from( { length: (conf && conf.challengeCount) || 50 }, () => /** @type {ChallengeTuple} */ ([ crypto .randomBytes(Math.ceil(((conf && conf.challengeSize) || 32) / 2)) .toString("hex") .slice(0, (conf && conf.challengeSize) || 32), crypto .randomBytes( Math.ceil(((conf && conf.challengeDifficulty) || 4) / 2) ) .toString("hex") .slice(0, (conf && conf.challengeDifficulty) || 4), ]) ); const token = crypto.randomBytes(25).toString("hex"); const expires = Date.now() + ((conf && conf.expiresMs) || 600000); if (conf && conf.store === false) { return { challenge: challenges, expires }; } this.config.state.challengesList[token] = { challenge: challenges, expires, token, }; return { challenge: challenges, 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) { return { success: false, message: "Invalid body" }; } this._cleanExpiredTokens(); const challengeData = this.config.state.challengesList[token]; if (!challengeData || challengeData.expires < Date.now()) { delete this.config.state.challengesList[token]; return { success: false, message: "Challenge expired" }; } delete this.config.state.challengesList[token]; const isValid = challengeData.challenge.every(([salt, target]) => { const solution = solutions.find(([s, t]) => s === salt && t === target); return ( solution && crypto .createHash("sha256") .update(salt + solution[2]) .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"); if (this?.config?.state?.tokensList) this.config.state.tokensList[`${id}:${hash}`] = 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 }; } /** * Validates a token * @param {string} token - The token to validate * @param {TokenConfig} [conf] - Validation configuration * @returns {Promise<{success: boolean}>} */ async validateToken(token, conf) { this._cleanExpiredTokens(); const [id, vertoken] = token.split(":"); const hash = crypto.createHash("sha256").update(vertoken).digest("hex"); const key = `${id}:${hash}`; await this._waitForTokensList(); if (this.config.state.tokensList[key]) { if (!(conf && conf.keepToken)) { delete this.config.state.tokensList[key]; } if (!this.config.noFSState) { await fs.writeFile( this.config.tokens_store_path, JSON.stringify(this.config.state.tokensList), "utf8" ); } 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) || {}; this._cleanExpiredTokens(); } 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 (error) { console.warn( `[cap] Couldn't load or write tokens file, using empty state` ); this.config.state.tokensList = {}; } } /** * Removes expired tokens and challenges from memory * @private * @returns {boolean} - True if any tokens were changed/removed */ _cleanExpiredTokens() { const now = Date.now(); let tokensChanged = false; for (const k in this.config.state.challengesList) { if (this.config.state.challengesList[k].expires < now) { delete this.config.state.challengesList[k]; } } for (const k in this.config.state.tokensList) { if (this.config.state.tokensList[k] < now) { delete this.config.state.tokensList[k]; tokensChanged = true; } } 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 = this._cleanExpiredTokens(); if (tokensChanged) { await fs.writeFile( this.config.tokens_store_path, JSON.stringify(this.config.state.tokensList), "utf8" ); } })(); return this._cleanupPromise; } } /** @type {typeof Cap} */ module.exports = Cap;