@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
JavaScript
// @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;