A lot of changes to how cap works. Note: the capdotjs npm library has now been deprecated

This commit is contained in:
Tiago Rangel
2025-01-13 20:01:26 +00:00
parent 1ca6605e01
commit 1e843c4f9c
20 changed files with 403 additions and 262 deletions
+2 -1
View File
@@ -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
node_modules
+2 -2
View File
@@ -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]) {
-191
View File
@@ -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");
});
+2
View File
@@ -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:
<Demo />
+1 -1
View File
@@ -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
+3 -25
View File
@@ -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
<cap-widget id="floating" onsolve="alert(`Verification token: ${event.detail.token}`)" data-api-endpoint="https://<your website url>/api/"></cap-widget>
<cap-widget id="floating" onsolve="alert(`Verification token: ${event.detail.token}`)" data-api-endpoint="<api endpoint>"></cap-widget>
<button data-cap-floating="#floating" data-cap-floating-position="bottom">Trigger floating mode</button>
```
You'll also need to import both the Cap library and the floating mode script from JSDelivr:
```html{2}
<script src="https://cdn.jsdelivr.net/npm/capdotjs"></script>
<script src="https://cdn.jsdelivr.net/npm/capdotjs/lib/cap-floating.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget/cap-floating.min.js"></script>
```
::: details Other CDNs
While it is recommended to use JSDelivr, you can also import Cap from other CDNs like unpkg or GitHub.
##### unpkg
```html
<script src="https://unpkg.com/capdotjs/lib/cap-floating.min.js"></script>
```
##### jsdelivr + GitHub
```html
<script src="https://cdn.jsdelivr.net/gh/tiagorangel1/cap/lib/cap-floating.min.js"></script>
```
##### GitHub
```html
<script src="https://raw.githubusercontent.com/tiagorangel1/cap/master/lib/cap-floating.min.js"></script>
```
:::
> [!NOTE]
> You'll not need to re-import the main Cap library if you've already done it.
+47 -36
View File
@@ -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
<script src="https://cdn.jsdelivr.net/npm/capdotjs"></script>
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
<script src="https://unpkg.com/capdotjs"></script>
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
<script src="https://cdn.jsdelivr.net/gh/tiagorangel1/cap/lib/cap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
```
##### GitHub
```html
<script src="https://raw.githubusercontent.com/tiagorangel1/cap/master/lib/cap.min.js"></script>
```
:::
Next, add the `<cap-widget>` component to your HTML.
```html
<cap-widget id="cap" data-api-endpoint="https://<your website url>/api/"></cap-widget>
<cap-widget id="cap" data-api-endpoint="<your cap api endpoint>"></cap-widget>
```
**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('/<endpoint>/...', ...)`.
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 <api-endpoint>/validate
Content-Type: application/json
{
"token": "xxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```
The server response will confirm the validity and immediately invalidate the token:
```json
{
"success": true
}
```
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 })`.
+1 -1
View File
@@ -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
+6 -5
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap)
+192
View File
@@ -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;
+35
View File
@@ -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"
}
}
+78
View File
@@ -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);
+1
View File
@@ -0,0 +1 @@
# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap)
View File
+32
View File
@@ -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"
}
}
View File