diff --git a/.gitignore b/.gitignore index 9329e7d..93d41c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.old build/Release bun.lockb package-lock.json @@ -89,4 +90,4 @@ docs/.vitepress/dist .DS_Store .vscode .data -node_modules \ No newline at end of file +node_modules diff --git a/build.js b/build.js index e214a71..2964cca 100644 --- a/build.js +++ b/build.js @@ -3,8 +3,8 @@ const babel = require("@babel/core"); const { minify } = require("terser"); const paths = [ - ["./src/cap.js", "./lib/cap.min.js"], - ["./src/cap-floating.js", "./lib/cap-floating.min.js"], + ["./widget/src/cap.js", "./widget/cap.min.js"], + ["./widget/src/cap-floating.js", "./widget/cap-floating.min.js"], ]; paths.forEach(async function ([inpath, outpath]) { diff --git a/demo.js b/demo.js deleted file mode 100644 index b0f8443..0000000 --- a/demo.js +++ /dev/null @@ -1,191 +0,0 @@ -const fs = require("fs"); -const Koa = require("koa"); -const path = require("path"); -const crypto = require("crypto"); -const json = require("koa-json"); -const mount = require("koa-mount"); -const Router = require("@koa/router"); -const compress = require("koa-compress"); -const ratelimit = require("koa-ratelimit"); -const bodyParser = require("koa-bodyparser"); - -const fsp = fs.promises; - -const app = new Koa(); -const router = new Router(); - -app.proxy = true; - -const serve = require("koa-static"); -app.use(mount("/docs", serve(path.join(__dirname, "/docs/.vitepress/dist/")))); - -const TOKENS_PATH = path.join(__dirname, ".data", "tokensList.json"); -const state = { - challengesList: {}, - tokensList: {}, - requestCounts: {} -}; - -app.use(bodyParser()); -app.use(json()); -app.use(compress()); - -app.use(async (ctx, next) => { - const start = Date.now(); - await next(); - const ms = Date.now() - start; - ctx.set("X-Response-Time", `${ms}ms`); -}); - -const db = new Map(); - -const createRateLimit = () => - ratelimit({ - driver: "memory", - db: db, - duration: 60 * 1000, - max: 30, - errorMessage: "Too many requests, please try again later.", - }); - -const nid = function (l) { - return crypto - .randomBytes(Math.ceil(l / 2)) - .toString("hex") - .slice(0, l); -}; - -router.post("/api/challenge", createRateLimit(), async (ctx) => { - state.requestCounts[ctx.ip] = { - counts: (state.requestCounts[ctx.ip]?.counts + 1) || 1, - timestamp: Date.now() - }; - const config = { challengeCount: 18, challengeSize: 32, challengeDifficulty: 4 }; - const challenges = Array.from({ length: config.challengeCount }, () => [ - nid(config.challengeSize), - nid(config.challengeDifficulty), - ]); - const token = crypto.randomBytes(25).toString("hex"); - const expires = Date.now() + 600000; - - state.challengesList[token] = { - challenge: challenges, - expires, - ip: ctx.ip, - }; - - ctx.body = { challenge: challenges, token, expires }; -}); - -router.post("/api/redeem", createRateLimit(), async (ctx) => { - const { token, solutions } = ctx.request.body; - const challengeData = state.challengesList[token]; - delete state.challengesList[token]; - - if ( - !challengeData || - challengeData.expires < Date.now() || - !(challengeData.ip === ctx.ip) - ) { - ctx.body = { success: false }; - return; - } - - 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) { - ctx.body = { success: false }; - return; - } - - const encodeBigInt = (num, base = 62) => { - const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let res = "", n = BigInt(num); - while (n > 0) (res = chars[Number(n % BigInt(base))] + res), (n /= BigInt(base)); - return res || "0"; - }; - - const vertoken = crypto.randomBytes(15).toString("hex"); - const expires = Date.now() + 20 * 60 * 1000; - const hash = encodeBigInt(Bun.hash(vertoken)); - const id = crypto.randomBytes(8).toString("hex"); - - state.tokensList[`${id}:${hash}`] = expires; - - await fsp.writeFile(TOKENS_PATH, JSON.stringify(state.tokensList), "utf8"); - - ctx.body = { success: true, token: `${id}:${vertoken}`, expires }; -}); - -router.post("/api/validate", createRateLimit(), async (ctx) => { - const encodeBigInt = (num, base = 62) => { - const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let res = "", n = BigInt(num); - while (n > 0) (res = chars[Number(n % BigInt(base))] + res), (n /= BigInt(base)); - return res || "0"; - }; - - const { token } = ctx.request.body; - const [id, vertoken] = token.split(":"); - const hash = encodeBigInt(Bun.hash(vertoken)); - - if (state.tokensList[`${id}:${hash}`]) { - delete state.tokensList[`${id}:${hash}`]; - ctx.body = { success: true }; - } else { - ctx.body = { success: false }; - } -}) - -router.get("/", async (ctx) => { - ctx.redirect("/docs"); -}); - -setInterval(async () => { - const now = Date.now(); - let tokensChanged = false; - - for (const k in state.challengesList) { - if (state.challengesList[k].expires < now) delete state.challengesList[k]; - } - - for (const k in state.tokensList) { - if (state.tokensList[k] < now) { - delete state.tokensList[k]; - tokensChanged = true; - } - } - - if (tokensChanged) { - await fsp.writeFile(TOKENS_PATH, JSON.stringify(state.tokensList), "utf8"); - } -}, 1000); - -setInterval(function () { - const now = Date.now(); - - Object.entries(state.requestCounts).forEach(([ip, { counts, timestamp }]) => { - if (now - timestamp > 30 * 60 * 1000) { - delete state.requestCounts[ip]; - } - }); -}, 1000); - -state.tokensList = JSON.parse(fs.readFileSync(TOKENS_PATH, "utf-8").toString()) || {}; - -app.use(router.routes()).use(router.allowedMethods()); - -app.listen(process.env.PORT || 3000, () => { - console.log("Server running on http://localhost:3000"); - require("./build.js"); -}); diff --git a/docs/guide/demo.md b/docs/guide/demo.md index 1848e2e..2fea82e 100644 --- a/docs/guide/demo.md +++ b/docs/guide/demo.md @@ -1,5 +1,7 @@ # Demo +**Note:** Please note that the demo implementation is still experimental! Don't consider it to be representative of the stability or behavior of Cap overall. + ### Normal button: diff --git a/docs/guide/effectiveness.md b/docs/guide/effectiveness.md index 71d2573..a2ec41f 100644 --- a/docs/guide/effectiveness.md +++ b/docs/guide/effectiveness.md @@ -16,7 +16,7 @@ Cap is fully compliant with GDPR and CCPA. It doesn't use cookies or track you i ## Security -- IP addresses are only stored in-memory +- IP addresses are not stored by default - Requests are stored in-memory in make sure they are not tampered with (hashed tokens only, this is stored in .data/tokensList.json by default) - Confirmation tokens reset after 20 minutes - Challenges are only valid for 10 minutes diff --git a/docs/guide/floating.md b/docs/guide/floating.md index ea263e1..5f4a24b 100644 --- a/docs/guide/floating.md +++ b/docs/guide/floating.md @@ -3,39 +3,17 @@ Cap can automatically hide the captcha until the form is submitted. To use this feature, add the `data-cap-floating` attribute to the Cap widget with the query selector of the `cap-widget` element you want to use. ```html - + ``` You'll also need to import both the Cap library and the floating mode script from JSDelivr: ```html{2} - - + + ``` -::: details Other CDNs -While it is recommended to use JSDelivr, you can also import Cap from other CDNs like unpkg or GitHub. - -##### unpkg - -```html - -``` - -##### jsdelivr + GitHub - -```html - -``` - -##### GitHub - -```html - -``` -::: - > [!NOTE] > You'll not need to re-import the main Cap library if you've already done it. diff --git a/docs/guide/index.md b/docs/guide/index.md index c6f666a..f0918af 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -7,41 +7,67 @@ Cap is a library designed for safeguarding against spam and abuse by utilizing a ## Adding the Cap widget -Cap is built to be straightforward and requires no API tokens for setup. Start by importing the Cap library: +### Server-side +Cap is fully self-hosted, so you'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute. This is easy since we've already pre-made a library to help you generate and validate challenges for you. -```html - +Start by installing it using npm or bun: + +``` +npm i @cap.js/server ``` -::: details Other CDNs -While it is recommended to use JSDelivr, you can also import Cap from other CDNs like unpkg or GitHub. +Now, you'll need to change your server code to add the routes that Cap needs to work. Here's an example with Express.js: -##### unpkg +```js +const express = require('express'); +const Cap = require('./index.js'); -```html - +const app = express(); +app.use(express.json()); + +const cap = new Cap({ + tokens_store_path: '.data/tokensList.json' // make sure this file has already been created and added to your gitignore +}); + +app.post('/api/challenge', (req, res) => { + res.json(cap.createChallenge({ + challengeCount: 2, + challengeSize: 16, + challengeDifficulty: 3, + expiresMs: 300000 + })); +}); + +app.post('/api/redeem', async (req, res) => { + const { token, solutions } = req.body; + if (!token || !solutions) { + return res.status(400).json({ success: false }); + } + res.json(await cap.redeemChallenge({ token, solutions })); +}); + +app.listen(3000, () => { + console.log('Listening on port 3000'); +}) ``` -##### jsdelivr + GitHub +It should be easy to adapt this to work with other frameworks such as Hono. + +### Client-side + +Cap's widget is really easy to add. Start by adding it from a CDN: ```html - + ``` -##### GitHub - -```html - -``` -::: - Next, add the `` component to your HTML. ```html - + ``` -**Note:** You'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute. +**Note:** You'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute. In the server-side example we provided, the `data-api-endpoint` attribute is set to `/api`. You can change this by replacing every `app.post('/api/...', ...)` to `app.post('//...', ...)`. Then, in your JavaScript, listen for the `solve` event to capture the token when generated: @@ -60,21 +86,6 @@ Alternatively, you can use `onsolve=""` directly within the widget or wrap the w ## Server-Side Validation -Once you have the token, verify it server-side with a simple POST request: +Once the token is generated and captured, you can use it later to validate the user's identity. This is typically done by sending the token to a server-side endpoint that uses the Cap API for validation. -```http -POST /validate -Content-Type: application/json - -{ - "token": "xxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -} -``` - -The server response will confirm the validity and immediately invalidate the token: - -```json -{ - "success": true -} -``` \ No newline at end of file +To do this, you'll only need to call `await cap.validateToken("...")`. This will return `{ success: Boolean }`. Note that the token will immediately be deleted after this. To prevent this, use `await cap.validateToken("...", { keepToken: true })`. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 31248a9..27cbf82 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ hero: tagline: "Cap is the open-source & modern ReCAPTCHA alternative designed for speed. Lightweight, powerful, private and secure." actions: - theme: brand - text: Quickstart + text: Guide link: /guide - theme: alt text: Demo diff --git a/package.json b/package.json index 2cbd6a3..3a936d6 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,6 @@ { - "name": "capdotjs", - "version": "0.0.4", + "name": "cap", "author": "Tiago Rangel", - "main": "lib/cap.min.js", "description": "A modern, lightning-quick PoW captcha", "keywords": [ "captcha", @@ -14,12 +12,15 @@ "node" ], "scripts": { - "demo": "bun run demo.js", "build": "bun run build.js", "test": "echo \"Error: no test specified\" && exit 1", + "docs:dev": "cd docs && bun run docs:dev", "docs:build": "cd docs && bun run docs:build", - "docs:preview": "cd docs && bun run docs:preview" + "docs:preview": "cd docs && bun run docs:preview", + + "widget:publish": "cd widget && sudo npm publish --access public", + "server:publish": "cd server && sudo npm publish --access public" }, "dependencies": { "@babel/core": "^7.25.8", diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..853a3e2 --- /dev/null +++ b/server/README.md @@ -0,0 +1 @@ +# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap) \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..6c8c352 --- /dev/null +++ b/server/index.js @@ -0,0 +1,192 @@ +const crypto = require("crypto"); +const fs = require("fs/promises"); +const { EventEmitter } = require("events"); + +const encodeBigInt = (num, base = 62) => { + const chars = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let res = "", + n = BigInt(num); + while (n > 0) { + res = chars[Number(n % BigInt(base))] + res; + n /= BigInt(base); + } + return res || "0"; +}; + +class Cap extends EventEmitter { + config = { + tokens_store_path: ".data/tokensList.json", + state: { + challengesList: {}, + tokensList: {}, + }, + }; + + #cleanupPromise = null; + + constructor(configObj) { + super(); + this.config = { ...this.config, ...configObj }; + + 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)); + }); + }); + } + + async #loadTokens() { + try { + const data = await fs.readFile(this.config.tokens_store_path, "utf-8"); + this.config.state.tokensList = JSON.parse(data) || {}; + this.#cleanExpiredTokens(); + } catch (error) { + this.config.state.tokensList = {}; + } + } + + #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; + } + + 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; + } + + createChallenge(conf) { + this.#cleanExpiredTokens(); + + const challenges = Array.from( + { length: conf?.challengeCount || 18 }, + () => [ + crypto + .randomBytes(Math.ceil((conf?.challengeSize || 32) / 2)) + .toString("hex") + .slice(0, conf?.challengeSize || 32), + crypto + .randomBytes(Math.ceil((conf?.challengeDifficulty || 4) / 2)) + .toString("hex") + .slice(0, conf?.challengeDifficulty || 4), + ] + ); + + const token = crypto.randomBytes(25).toString("hex"); + const expires = Date.now() + (conf?.expiresMs || 600000); + + if (conf?.store === false) { + return { challenge: challenges, expires }; + } + + this.config.state.challengesList[token] = { + challenge: challenges, + expires, + token, + }; + + return { challenge: challenges, token, expires }; + } + + async redeemChallenge({ token, solutions }) { + this.#cleanExpiredTokens(); + + const challengeData = this.config.state.challengesList[token]; + if (!challengeData || challengeData.expires < Date.now()) { + delete this.config.state.challengesList[token]; + return { success: false }; + } + + 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 }; + + const vertoken = crypto.randomBytes(15).toString("hex"); + const expires = Date.now() + 20 * 60 * 1000; + const hash = encodeBigInt(Bun.hash(vertoken)); + const id = crypto.randomBytes(8).toString("hex"); + + this.config.state.tokensList[`${id}:${hash}`] = expires; + + await fs.writeFile( + this.config.tokens_store_path, + JSON.stringify(this.config.state.tokensList), + "utf8" + ); + + return { success: true, token: `${id}:${vertoken}`, expires }; + } + + async validateToken(token, conf) { + this.#cleanExpiredTokens(); + + const [id, vertoken] = token.split(":"); + const hash = encodeBigInt(Bun.hash(vertoken)); + const key = `${id}:${hash}`; + + if (this.config.state.tokensList[key]) { + if (conf.keepToken) { + delete this.config.state.tokensList[key]; + + await fs.writeFile( + this.config.tokens_store_path, + JSON.stringify(this.config.state.tokensList), + "utf8" + ); + } + + return { success: true }; + } + + return { success: false }; + } +} + +module.exports = Cap; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..572273b --- /dev/null +++ b/server/package.json @@ -0,0 +1,35 @@ +{ + "name": "@cap.js/server", + "version": "1.0.1", + "description": "Cap server-side helper", + "keywords": [ + "captcha", + "pow", + "crypto", + "encryption", + "recaptcha", + "bun", + "js", + "node", + "hcaptcha" + ], + "homepage": "https://github.com/tiagorangel1/cap#readme", + "bugs": { + "url": "https://github.com/tiagorangel1/cap/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tiagorangel1/cap.git" + }, + "license": "AGPL-3.0", + "author": "Tiago Rangel", + "type": "commonjs", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "npm:publish": "sudo npm publish --access public" + }, + "dependencies": { + "express": "^4.21.2" + } +} diff --git a/server/test.js b/server/test.js new file mode 100644 index 0000000..5ec36f3 --- /dev/null +++ b/server/test.js @@ -0,0 +1,78 @@ +const express = require('express'); +const crypto = require('crypto'); +const fs = require('fs').promises; +const Cap = require('./index.js'); + +const app = express(); +app.use(express.json()); + +const cap = new Cap({ + tokens_store_path: '.data/tokensList.json' +}); + +function solveChallenge(salt, target) { + let nonce = 0; + while (true) { + if (crypto.createHash('sha256').update(salt + nonce.toString()).digest('hex').startsWith(target)) { + return nonce.toString(); + } + nonce++; + } +} + +app.post('/api/challenge', (req, res) => { + res.json(cap.createChallenge({ + challengeCount: 2, + challengeSize: 16, + challengeDifficulty: 3, + expiresMs: 300000 + })); +}); + +app.post('/api/redeem', async (req, res) => { + const { token, solutions } = req.body; + if (!token || !solutions) { + return res.status(400).json({ success: false }); + } + res.json(await cap.redeemChallenge({ token, solutions })); +}); + +app.post('/api/validate', async (req, res) => { + const { token } = req.body; + if (!token) { + return res.status(400).json({ success: false }); + } + res.json(await cap.validateToken({ token })); +}); + +async function test() { + const { challenge, token } = await fetch('http://localhost:3000/api/challenge', { + method: 'POST' + }).then(r => r.json()); + + const solutions = challenge.map(([salt, target]) => [salt, target, solveChallenge(salt, target)]); + const { success, token: verToken } = await fetch('http://localhost:3000/api/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, solutions }) + }).then(r => r.json()); + + if (!success || !verToken) { + throw new Error('Failed to redeem challenge'); + } + + const validation = await fetch('http://localhost:3000/api/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: verToken }) + }).then(r => r.json()); + + if (!validation.success) { + throw new Error('Failed to validate token'); + } + + console.log('All tests passed!'); + process.exit(0); +} + +app.listen(3000, test); \ No newline at end of file diff --git a/widget/README.md b/widget/README.md new file mode 100644 index 0000000..853a3e2 --- /dev/null +++ b/widget/README.md @@ -0,0 +1 @@ +# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap) \ No newline at end of file diff --git a/lib/cap-floating.min.js b/widget/cap-floating.min.js similarity index 100% rename from lib/cap-floating.min.js rename to widget/cap-floating.min.js diff --git a/lib/cap.min.js b/widget/cap.min.js similarity index 100% rename from lib/cap.min.js rename to widget/cap.min.js diff --git a/widget/package.json b/widget/package.json new file mode 100644 index 0000000..85736fb --- /dev/null +++ b/widget/package.json @@ -0,0 +1,32 @@ +{ + "name": "@cap.js/widget", + "version": "0.0.2", + "description": "Cap widget", + "keywords": [ + "captcha", + "pow", + "crypto", + "encryption", + "recaptcha", + "bun", + "js", + "node", + "hcaptcha" + ], + "homepage": "https://github.com/tiagorangel1/cap#readme", + "bugs": { + "url": "https://github.com/tiagorangel1/cap/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tiagorangel1/cap.git" + }, + "license": "AGPL-3.0", + "author": "Tiago Rangel", + "type": "commonjs", + "main": "cap.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "npm:publish": "sudo npm publish --access public" + } +} diff --git a/src/cap-floating.js b/widget/src/cap-floating.js similarity index 100% rename from src/cap-floating.js rename to widget/src/cap-floating.js diff --git a/src/cap.js b/widget/src/cap.js similarity index 100% rename from src/cap.js rename to widget/src/cap.js diff --git a/lib/wasm-hashes.min.js b/widget/wasm-hashes.min.js similarity index 100% rename from lib/wasm-hashes.min.js rename to widget/wasm-hashes.min.js