feat: switch to WASM solver!
This commit is contained in:
+9
-5
@@ -15,18 +15,22 @@ fastify.get("/", (req, res) => {
|
||||
|
||||
fastify.get("/cap.js", (req, res) => {
|
||||
res.header("Content-Type", "application/javascript");
|
||||
res.send(fs.createReadStream(path.join(__dirname, "../../widget/src/src/cap.js")));
|
||||
res.send(
|
||||
fs.createReadStream(path.join(__dirname, "../../widget/src/src/cap.js"))
|
||||
);
|
||||
});
|
||||
|
||||
fastify.get("/cap-floating.js", (req, res) => {
|
||||
res.header("Content-Type", "application/javascript");
|
||||
res.send(fs.createReadStream(path.join(__dirname, "../../widget/src/src/cap-floating.js")));
|
||||
res.send(
|
||||
fs.createReadStream(
|
||||
path.join(__dirname, "../../widget/src/src/cap-floating.js")
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
fastify.post("/api/challenge", (req, res) => {
|
||||
res.send(
|
||||
cap.createChallenge()
|
||||
);
|
||||
res.send(cap.createChallenge());
|
||||
});
|
||||
|
||||
fastify.post("/api/redeem", async (req, res) => {
|
||||
|
||||
@@ -87,8 +87,8 @@ export default defineConfig({
|
||||
{
|
||||
src: "https://cdn.jsdelivr.net/npm/@cap.js/widget@latest",
|
||||
async: true,
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
],
|
||||
themeConfig: {
|
||||
search: {
|
||||
@@ -108,7 +108,6 @@ export default defineConfig({
|
||||
|
||||
sidebar: [
|
||||
{ text: "Quickstart", link: "/guide/index.md" },
|
||||
{ text: "Effectiveness", link: "/guide/effectiveness.md" },
|
||||
{ text: "Alternatives", link: "/guide/alternatives.md" },
|
||||
{ text: "Philosophy", link: "/guide/philosophy.md" },
|
||||
{
|
||||
@@ -128,8 +127,15 @@ export default defineConfig({
|
||||
{ text: "@cap.js/cli", link: "/guide/cli.md" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Proof-of-work",
|
||||
items: [
|
||||
{ text: "Effectiveness", link: "/guide/effectiveness.md" },
|
||||
{ text: "How does it work", link: "/guide/workings.md" },
|
||||
],
|
||||
},
|
||||
{ text: "Benchmark", link: "/guide/benchmark.md" },
|
||||
{ text: "Demo", link: "/guide/demo.md" }
|
||||
{ text: "Demo", link: "/guide/demo.md" },
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
|
||||
+31
-21
@@ -1,37 +1,47 @@
|
||||
# Effectiveness
|
||||
|
||||
Cap is designed to reduce spam and abuse on websites and web apps. While it won't block all spam, it's highly effective at keeping most bots away.
|
||||
Cap reduces spam and abuse on websites and web apps. While it won't block _all_ spam (no CAPTCHA is a silver bullet), it significantly reduces it by making automated abuse costly. The core principle behind proof-of-work CAPTCHAs like Cap is **proving effort** rather than just verifying if you're human through interaction analysis or complex puzzles.
|
||||
|
||||
## How PoW works
|
||||
When you verify yourself, Cap performs these steps:
|
||||
|
||||
Cap uses a Proof-of-Work (PoW) system, similar to Friendly Captcha and Altcha. Instead of simply verifying if you're human by analysing your mouse movement and interaction with the website, Cap creates a computational task that bots find hard to solve.
|
||||
1. **Requests Challenges:** Asks the server for multiple unique challenges. Each includes a salt and a target difficulty. (Using multiple challenges helps adjust difficulty and track progress.)
|
||||
2. **Solves Computationally:** Uses Web Workers and WebAssembly (WASM) on your device to rapidly test nonce values, combining them with the challenge salt. In case
|
||||
3. **Finds Solutions:** Continues until it finds solutions whose cryptographic hashes meet the target difficulty specified in the challenges.
|
||||
4. **Validates:** Sends the successful nonce solutions back to the server for validation.
|
||||
5. **Issues Token:** If validation succeeds, the server issues a short-lived token that proves the work was done, granting access.
|
||||
|
||||
When you verify yourself, Cap asks the server for multiple challenges (we use multiple challenges in order to help adjust difficulty levels and track progress more accurately), which include a unique salt and target. The server generates these challenges and sends them to the Cap widget. Using Web Workers and WASM, Cap tries to find a challenge with a hash starting with a specific string for each challenge by combining the salt with a random string.
|
||||
This computational work makes spam expensive for attackers but remains quick (typically milliseconds) and unintrusive for legitimate users.
|
||||
|
||||
Once solved, the widget sends the results back to the server for validation. If successful, the server gives a token that can be used to prove you're human. This extra complexity makes it much harder for simple bots to bypass.
|
||||
**Example: The Economics of Spam**
|
||||
Imagine sending 10,000 spam messages costs $1, potentially earning $10 – a profitable venture. If Cap increases the computational cost so that sending those messages now costs $100, the spammer loses $90. This eliminates the financial incentive.
|
||||
|
||||
## Privacy
|
||||
## Privacy & Security
|
||||
|
||||
Cap is fully compliant with GDPR and CCPA. It doesn't use cookies or track you in intrusive ways. We never sell your data, and we don't even collect personal information. No ads, no tracking. Period.
|
||||
Cap prioritizes user privacy and is designed to be GDPR and CCPA compliant.
|
||||
|
||||
## Security
|
||||
- **No Tracking:** It doesn't use cookies or employ intrusive tracking methods.
|
||||
- **No Data Selling:** We never sell user data.
|
||||
- **Minimal Data:** We don't collect personal information beyond what's essential for the PoW process itself. No ads, no tracking, no telemetry. Period.
|
||||
- **IP Addresses:** Not stored by default.
|
||||
- **Challenge Storage:** Challenges exist only in memory on the server to prevent tampering and expire quickly (10 minutes by default).
|
||||
- **Token Storage:** Only _hashed_ tokens are stored persistently (in `.data/tokensList.json` by default) to validate user sessions, and they also expire (20 minutes by default).
|
||||
|
||||
- IP addresses are not stored by default
|
||||
- Challenges are stored in memory to make sure they are not tampered with (expire after 10 minutes by default), while tokens are stored in a file (hashed tokens only, this is `.data/tokensList.json` by default and expire after 20 minutes)
|
||||
## Why Proof-of-Work?
|
||||
|
||||
## Why you might not want to use this
|
||||
Every CAPTCHA can eventually be solved, whether by sophisticated bots or humans paid via CAPTCHA farms. The crucial difference lies in the _cost_ imposed on attackers.
|
||||
|
||||
Cap isn't a silver bullet: it won’t block all spam, but it will significantly reduce it. The core principle behind proof-of-work CAPTCHAs is that they are designed to prove effort rather than just verifying a human user.
|
||||
The goal is to make automated abuse prohibitively expensive while keeping the experience fast and virtually invisible for real users. Proof-of-work strikes an effective balance, deterring abuse by requiring computational effort rather than relying solely on human verification methods that bots continuously learn to mimic.
|
||||
|
||||
**Here's an example:**
|
||||
For a deeper dive into the technical aspects, you might find [this research paper](https://www.researchgate.net/publication/374638786_Proof-of-Work_CAPTCHA_with_password_cracking_functionality) insightful.
|
||||
|
||||
Imagine I can send 10,000 emails to 10,000 random recipients for $1. If I can earn $10 in return from these emails, it becomes highly profitable. However, if the cost of sending these emails increases from $1 to $100, it is no longer profitable. In fact, I would be losing money. This is where CAPTCHA logic comes into play: by making the cost of spamming increase dramatically, we can reduce its viability.
|
||||
---
|
||||
|
||||
By applying similar concepts to CAPTCHA, we make it costly for bots to spam, effectively discouraging them from trying.
|
||||
**Key improvements in this merged version:**
|
||||
|
||||
If you want to read more about proof-of-work, I recommend reading [this whitepaper](https://www.researchgate.net/publication/374638786_Proof-of-Work_CAPTCHA_with_password_cracking_functionality)
|
||||
|
||||
|
||||
## Why proof-of-work?
|
||||
|
||||
Every captcha is solvable by bots or by paid humans. The only question is how to make it costly for bots but invisible for users. Proof-of-work is the perfect balance for this problem.
|
||||
- **Stronger Opening:** Clearly states the goal and the "not a silver bullet" caveat upfront.
|
||||
- **Integrated PoW:** Uses the clear, numbered list from V2.
|
||||
- **Enhanced PoW Details:** Incorporates V1's useful details about salt, target, multiple challenges, and how nonces/hashes work, but phrased concisely.
|
||||
- **Clearer Economics Example:** Directly ties the cost increase to Cap.
|
||||
- **Combined Privacy & Security:** Merges these related concepts logically using clear bullet points, incorporating the specifics from both versions (memory storage, hashing, defaults).
|
||||
- **Refined "Why PoW?":** Uses strong phrasing ("prohibitively expensive," "virtually invisible," "strikes an effective balance") combining good points from both versions.
|
||||
- **Better Flow:** Information progresses logically from effectiveness to privacy/security and the underlying rationale.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# How does cap work?
|
||||
|
||||
> [!NOTE]
|
||||
> This is a more technical explanation of how Cap works. If you're looking for a more general overview, check out the [Effectiveness](./effectiveness.md) page.
|
||||
|
||||
#### Initialization
|
||||
|
||||
1. When Cap is initialized, it automatically registers a custom element for the widget in the browser's DOM.
|
||||
2. The widget creates a shadow DOM and appends all necessary HTML elements to it, ensuring encapsulation from the rest of the page.
|
||||
|
||||
#### Requesting the challenge
|
||||
|
||||
3. When a solution is requested, the widget sends a request to the server to create a challenge set.
|
||||
- Each challenge consists of a unique salt and a target hash prefix.
|
||||
- The goal is to find a nonce value that, when combined with the salt and hashed using SHA-256, produces a hash that begins with the target prefix.
|
||||
- Instead of generating just one challenge, Cap creates multiple challenges to enable progress tracking and optimize performance.
|
||||
|
||||
#### Computing the solution
|
||||
|
||||
4. The widget leverages Web Workers to solve the challenges in parallel:
|
||||
- Each worker attempts to find a valid nonce by repeatedly:
|
||||
- Combining the salt with different nonce values
|
||||
- Computing the SHA-256 hash of this combination
|
||||
- Checking if the resulting hash begins with the target prefix
|
||||
- Workers increment the nonce systematically until a matching hash is found
|
||||
|
||||
#### Redeeming the solution for a token
|
||||
|
||||
5. Once a valid solution is found, the widget sends the result back to the server for validation.
|
||||
6. Upon successful verification, the server redeems the solution and issues a token that can be used to authenticate the request.
|
||||
+1
-1
@@ -65,7 +65,7 @@ There are also some other helpful packages:
|
||||
|
||||
- **[Standalone mode](https://capjs.js.org/guide/standalone.html)**: Docker image that helps you use Cap with any language or framework. It runs a simple REST API that can be used to create and validate challenges and an interactive UI to manage your keys.
|
||||
|
||||
- **@cap.js/wasm**: Experimental WASM solvers built using Rust.
|
||||
- **@cap.js/wasm**: WASM solvers for Node and Web built with Rust.
|
||||
|
||||
It's designed to be a drop-in replacement for existing CAPTCHA solutions, with a focus on performance and UX.
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
|
||||
const workerCodeTemplate = (() => {
|
||||
const baseURL = "${baseURL}";
|
||||
|
||||
// const relativeWasmWrapperPath = "https://cdn.jsdelivr.net/npm/@cap.js/wasm@0.0.3/browser/cap_wasm.min.js";
|
||||
|
||||
const relativeWasmWrapperPath = "../src/browser/cap_wasm.js";
|
||||
|
||||
let solve_pow_function = null;
|
||||
@@ -71,13 +74,11 @@
|
||||
|
||||
initPromise = import(absoluteWasmWrapperUrl)
|
||||
.then((wasmModule) => {
|
||||
return wasmModule
|
||||
.default()
|
||||
.then((instance) => {
|
||||
solve_pow_function = (
|
||||
instance && instance.exports ? instance.exports : wasmModule
|
||||
).solve_pow;
|
||||
});
|
||||
return wasmModule.default().then((instance) => {
|
||||
solve_pow_function = (
|
||||
instance && instance.exports ? instance.exports : wasmModule
|
||||
).solve_pow;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[worker] setup failed", error);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cap.js/widget",
|
||||
"version": "0.0.22",
|
||||
"version": "0.1.0",
|
||||
"description": "Client-side widget for Cap. Cap is a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.",
|
||||
"keywords": [
|
||||
"account security",
|
||||
|
||||
+73
-102
@@ -8,30 +8,12 @@
|
||||
return fetch(...arguments);
|
||||
};
|
||||
|
||||
const until = (predFn, timeout = 10000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error("Initialize timeout"));
|
||||
}, timeout);
|
||||
|
||||
const poll = () => {
|
||||
if (predFn()) {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(poll, 500);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
});
|
||||
};
|
||||
|
||||
// MARK: Widget
|
||||
class CapWidget extends HTMLElement {
|
||||
#workerUrl = "";
|
||||
#resetTimer = null;
|
||||
#workersCount = navigator.hardwareConcurrency || 8;
|
||||
#token = null;
|
||||
token = null;
|
||||
#shadow;
|
||||
#div;
|
||||
#host;
|
||||
@@ -57,25 +39,15 @@
|
||||
this.boundHandleReset = this.handleReset.bind(this);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.#workerUrl) {
|
||||
URL.revokeObjectURL(this.#workerUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
await until(() => !!workerScript);
|
||||
this.#workerUrl = URL.createObjectURL(
|
||||
new Blob([workerScript], {
|
||||
type: "application/javascript",
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
this.error("Failed to initialize worker");
|
||||
throw err;
|
||||
}
|
||||
initialize() {
|
||||
this.#workerUrl = URL.createObjectURL(
|
||||
new Blob([workerScript], {
|
||||
type: "application/javascript",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
attributeChangedCallback(name, _, value) {
|
||||
if (name.startsWith("on")) {
|
||||
const eventName = name.slice(2);
|
||||
const oldHandler = this.#eventHandlers.get(name);
|
||||
@@ -83,7 +55,7 @@
|
||||
this.removeEventListener(eventName, oldHandler);
|
||||
}
|
||||
|
||||
if (newValue) {
|
||||
if (value) {
|
||||
const handler = (event) => {
|
||||
const callback = this.getAttribute(name);
|
||||
if (typeof window[callback] === "function") {
|
||||
@@ -123,7 +95,6 @@
|
||||
this.#solving = true;
|
||||
this.updateUI("verifying", "Verifying...", true);
|
||||
|
||||
await until(() => !!this.#workerUrl);
|
||||
this.dispatchEvent("progress", { progress: 0 });
|
||||
|
||||
try {
|
||||
@@ -153,7 +124,7 @@
|
||||
}
|
||||
|
||||
this.dispatchEvent("solve", { token: resp.token });
|
||||
this.#token = resp.token;
|
||||
this.token = resp.token;
|
||||
|
||||
if (this.#resetTimer) clearTimeout(this.#resetTimer);
|
||||
const expiresIn = new Date(resp.expires).getTime() - Date.now();
|
||||
@@ -163,7 +134,7 @@
|
||||
this.error("Invalid expiration time");
|
||||
}
|
||||
|
||||
return { success: true, token: this.#token };
|
||||
return { success: true, token: this.token };
|
||||
} catch (err) {
|
||||
this.error(err.message);
|
||||
throw err;
|
||||
@@ -324,7 +295,7 @@
|
||||
}
|
||||
|
||||
error(message = "Unknown error") {
|
||||
console.error("[Cap] Error:", message);
|
||||
console.error("[cap] Error:", message);
|
||||
this.dispatchEvent("error", { isCap: true, message });
|
||||
}
|
||||
|
||||
@@ -343,14 +314,14 @@
|
||||
this.#resetTimer = null;
|
||||
}
|
||||
this.dispatchEvent("reset");
|
||||
this.#token = null;
|
||||
this.token = null;
|
||||
if (this.querySelector("input[name='cap-token']")) {
|
||||
this.querySelector("input[name='cap-token']").value = "";
|
||||
}
|
||||
}
|
||||
|
||||
get token() {
|
||||
return this.#token;
|
||||
return this.token;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -425,28 +396,20 @@
|
||||
);
|
||||
document.adoptedStyleSheets.push(sheet);
|
||||
|
||||
// MARK: Solver worker
|
||||
// TODO: Switch to using Rust WASM solver
|
||||
const workerFunct = function () {
|
||||
// MARK: TODO: Switch to using Rust WASM solver
|
||||
let hasher;
|
||||
let initPromise, solve_pow_function;
|
||||
|
||||
self.onmessage = async ({ data: { salt, target } }) => {
|
||||
let nonce = 0;
|
||||
const batchSize = 50000;
|
||||
let processed = 0;
|
||||
const encoder = new TextEncoder();
|
||||
if (
|
||||
typeof WebAssembly !== "object" ||
|
||||
typeof WebAssembly?.instantiate !== "function"
|
||||
) {
|
||||
self.onmessage = async ({ data: { salt, target } }) => {
|
||||
// Fallback solver in case WASM is not available
|
||||
|
||||
// Alternative solver in case WASM is not available
|
||||
if (
|
||||
!(
|
||||
typeof WebAssembly === "object" &&
|
||||
typeof WebAssembly.instantiate === "function"
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
"[cap] WASM not enabled, falling back to crypto.subtle\nThis is significanty slower than the WASM implementation"
|
||||
);
|
||||
let nonce = 0;
|
||||
const batchSize = 50000;
|
||||
let processed = 0;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const targetBytes = new Uint8Array(target.length / 2);
|
||||
for (let k = 0; k < targetBytes.length; k++) {
|
||||
@@ -497,56 +460,64 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// MARK: TODO: Switch to using Rust WASM solver
|
||||
if (!hasher) {
|
||||
hasher = await hashwasm.createSHA256();
|
||||
}
|
||||
return console.warn(
|
||||
"[cap] WebAssembly is not supported, falling back to alternative solver."
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(128);
|
||||
initPromise = import(
|
||||
"https://cdn.jsdelivr.net/npm/@cap.js/wasm@0.0.3/browser/cap_wasm.min.js"
|
||||
)
|
||||
.then((wasmModule) => {
|
||||
return wasmModule.default().then((instance) => {
|
||||
solve_pow_function = (
|
||||
instance && instance.exports ? instance.exports : wasmModule
|
||||
).solve_pow;
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
useFallback = true;
|
||||
console.error("[cap] using fallback solver due to error:", e);
|
||||
});
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const input = salt + nonce.toString();
|
||||
const inputBytes = encoder.encode(input);
|
||||
buffer.set(inputBytes);
|
||||
self.onmessage = async ({ data: { salt, target } }) => {
|
||||
try {
|
||||
await initPromise;
|
||||
|
||||
hasher.init();
|
||||
hasher.update(buffer.subarray(0, inputBytes.length));
|
||||
const hash = hasher.digest("hex");
|
||||
const startTime = performance.now();
|
||||
const nonce = solve_pow_function(salt, target);
|
||||
const endTime = performance.now();
|
||||
|
||||
if (hash.startsWith(target)) {
|
||||
self.postMessage({ nonce, found: true });
|
||||
return;
|
||||
}
|
||||
|
||||
nonce++;
|
||||
}
|
||||
|
||||
processed += batchSize;
|
||||
} catch (error) {
|
||||
self.postMessage({ found: false, error: error.message });
|
||||
return;
|
||||
self.postMessage({
|
||||
nonce: Number(nonce),
|
||||
found: true,
|
||||
durationMs: (endTime - startTime).toFixed(2),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!initError) {
|
||||
console.error("[cap] solver error", error);
|
||||
self.postMessage({
|
||||
found: false,
|
||||
error: error.message || String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.onerror = (error) => {
|
||||
self.postMessage({
|
||||
found: false,
|
||||
error: `Worker error: ${error.message || error}`,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// MARK: TODO: Switch to using Rust WASM solver
|
||||
setTimeout(async function () {
|
||||
workerScript =
|
||||
(await (
|
||||
await capFetch(
|
||||
"https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js"
|
||||
)
|
||||
).text()) +
|
||||
workerFunct
|
||||
.toString()
|
||||
.replace(/^function\s*\([^\)]*\)\s*{|\}$/g, "")
|
||||
.trim();
|
||||
}, 1);
|
||||
workerScript = `(() => {${workerFunct
|
||||
.toString()
|
||||
.replace(/^function\s*\([^\)]*\)\s*{|\}$/g, "")
|
||||
.trim()}})()`;
|
||||
|
||||
window.Cap = Cap;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user