Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions captcha/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
87 changes: 87 additions & 0 deletions captcha/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Quantus Captcha

A proof-of-work captcha whose work is a **real Quantus mining share**.
No tracking, no image puzzles, no data labeling — the visitor's device does
~1 second of Poseidon2 hashing over the current block header, and the site
host earns any block the aggregate share stream happens to find.

Components:

- **Pool service** (`quantus-miner/crates/pool-service`) — connects to a
`quantus-node` as an external miner, hands out low-difficulty share
challenges with disjoint nonce ranges, verifies solves, mints single-use
tokens, submits network-difficulty shares as blocks.
- **WASM solver** (`quantus-miner/crates/solver-wasm`) — raw C-ABI WASM
module (no wasm-bindgen), built with plain `cargo build`.
- **Widget** (`widget/`) — drop-in JS: checkbox UI, Web Worker solver,
hidden token input, `quan-captcha-solved` event.
- **Demo** (`demo/`) — a spam-protected form.

## Quick start (standalone demo, no node needed)

```sh
# 1. Build the WASM solver into dist/
./scripts/build-solver.sh

# 2. Run the pool in standalone mode, serving this directory
cd ../../quantus-miner
cargo run -p pool-service -- --serve-dir ../quantus-apps/captcha

# 3. Open the demo
open http://127.0.0.1:8787/demo/
```

## Against a real node

```sh
quantus-node --dev # external-miner QUIC endpoint on :9833
cargo run -p pool-service -- \
--node-addr 127.0.0.1:9833 \
--share-difficulty 2000 \
--site-secret "$(openssl rand -hex 16)" \
--serve-dir ../quantus-apps/captcha
```

## Integrating on a website

```html
<form action="/comment" method="post">
<textarea name="text"></textarea>
<div class="quan-captcha" data-endpoint="https://pool.example.com"></div>
<button>Post</button>
</form>
<script src="https://pool.example.com/widget/quan-captcha.js" defer></script>
```

Server-side, redeem the submitted `quan-captcha-token` exactly once
(mirrors the reCAPTCHA/Turnstile siteverify shape):

```sh
curl -X POST https://pool.example.com/siteverify \
-H 'content-type: application/json' \
-d '{"secret": "<your site secret>", "response": "<token>"}'
# -> {"success": true, "challenge_ts": 1751600000}
```

## API

| Endpoint | Caller | Purpose |
|---|---|---|
| `POST /api/session` | widget | issue challenge: header, disjoint nonce range, share target |
| `POST /api/share` | widget | verify solved nonce, mint single-use token |
| `POST /siteverify` | site backend | redeem token (secret + response) |
| `GET /api/stats` | anyone | pool counters |

## Threat model, honestly

- This is a **rate limiter, not sybil resistance**: it prices requests in
compute, it does not identify humans. A GPU farm pays less per share than
a phone; keep the share difficulty in "annoying to bots, invisible to
humans" territory and layer account/balance gates for high-value actions.
- Sessions are single-use, expire in 120 s, and shares are only valid inside
the session's assigned nonce range over the session's header snapshot —
no precomputation, no replay, no share theft.
- Tokens are single-use and expire in 300 s.
- Work runs only on explicit user action (Coinhive's fatal mistake was
ambient page-load mining without consent — see the plan doc in
`../debate-tree/PLAN.md`).
90 changes: 90 additions & 0 deletions captcha/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quantus Captcha — demo</title>
<style>
body {
font: 16px/1.6 system-ui, -apple-system, sans-serif; color: #1f2937;
max-width: 640px; margin: 48px auto; padding: 0 20px;
}
h1 { font-size: 22px; }
.card {
border: 1px solid #e4e7ec; border-radius: 12px; padding: 24px;
margin-top: 24px; box-shadow: 0 1px 3px rgba(16, 24, 40, .06);
}
label { display: block; font-size: 14px; font-weight: 600; margin: 14px 0 4px; }
input[type=text], textarea {
width: 100%; box-sizing: border-box; border: 1px solid #d0d5dd;
border-radius: 8px; padding: 9px 12px; font: inherit;
}
button {
margin-top: 18px; background: #2563eb; color: #fff; border: 0;
border-radius: 8px; padding: 10px 18px; font: inherit; cursor: pointer;
}
button:disabled { background: #98a2b3; cursor: not-allowed; }
.note { font-size: 13px; color: #667085; margin-top: 24px; }
#result { margin-top: 14px; font-size: 14px; white-space: pre-wrap; font-family: ui-monospace, monospace; }
</style>
</head>
<body>
<h1>Quantus Captcha demo</h1>
<p>
The checkbox below does ~1 second of real Poseidon2 proof-of-work over the
current Quantus block header. The work is a genuine mining share: if it
ever meets full network difficulty, the pool submits it as a block.
No tracking, no image puzzles, no data labeling.
</p>

<div class="card">
<form id="demo-form">
<label for="msg">Post a comment (spam-protected)</label>
<textarea id="msg" rows="3" placeholder="Write something…"></textarea>

<div style="margin-top:16px">
<div class="quan-captcha" data-endpoint=""></div>
</div>

<button type="submit" id="submit" disabled>Submit</button>
<div id="result"></div>
</form>
</div>

<p class="note">
After solving, the widget puts a single-use token in the form. A real
backend would redeem it server-side via <code>POST /siteverify</code>
with its secret. This demo calls it from the page for illustration only —
never expose your secret in production.
</p>

<script src="/widget/quan-captcha.js" defer></script>
<script>
const form = document.getElementById("demo-form");
const submit = document.getElementById("submit");
const result = document.getElementById("result");

form.addEventListener("quan-captcha-solved", (e) => {
submit.disabled = false;
if (e.detail.blockFound) {
result.textContent = "You just mined a real block while proving you're not a bot.";
}
});

form.addEventListener("submit", async (e) => {
e.preventDefault();
const token = form.querySelector('input[name="quan-captcha-token"]').value;
// Demo-only: siteverify belongs on the backend with a private secret.
const res = await fetch("/siteverify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ secret: "dev-secret", response: token }),
});
const body = await res.json();
result.textContent = "siteverify response:\n" + JSON.stringify(body, null, 2) +
(body.success ? "\n\nComment accepted." : "\n\nRejected (token reuse or expiry).");
submit.disabled = true;
});
</script>
</body>
</html>
13 changes: 13 additions & 0 deletions captcha/scripts/build-solver.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Builds the WASM solver from quantus-miner and copies it into dist/.
set -euo pipefail

CAPTCHA_DIR="$(cd "$(dirname "$0")/.." && pwd)"
MINER_DIR="${QUANTUS_MINER_DIR:-$CAPTCHA_DIR/../../quantus-miner}"

echo "Building solver-wasm from $MINER_DIR"
(cd "$MINER_DIR" && CARGO_TARGET_DIR=target cargo build -p solver-wasm --target wasm32-unknown-unknown --release)

mkdir -p "$CAPTCHA_DIR/dist"
cp "$MINER_DIR/target/wasm32-unknown-unknown/release/solver_wasm.wasm" "$CAPTCHA_DIR/dist/"
echo "Wrote $CAPTCHA_DIR/dist/solver_wasm.wasm ($(wc -c < "$CAPTCHA_DIR/dist/solver_wasm.wasm") bytes)"
179 changes: 179 additions & 0 deletions captcha/widget/quan-captcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Quantus Captcha widget.
//
// Usage:
// <div class="quan-captcha" data-endpoint="https://pool.example.com"></div>
// <script src="/widget/quan-captcha.js" defer></script>
//
// On solve, a hidden input named "quan-captcha-token" is added to the widget's
// enclosing form (or the widget element itself), and a "quan-captcha-solved"
// CustomEvent (detail: { token }) is dispatched on the widget element.
// The site backend then redeems the token: POST {endpoint}/siteverify
// with JSON {"secret": "...", "response": "<token>"}.

(function () {
"use strict";

const WIDGET_CLASS = "quan-captcha";

const STYLE = `
.quan-captcha-box {
display: inline-flex; align-items: center; gap: 10px;
border: 1px solid #d0d5dd; border-radius: 8px; padding: 10px 14px;
font: 14px/1.4 system-ui, -apple-system, sans-serif; color: #1f2937;
background: #fff; min-width: 260px; user-select: none;
}
.quan-captcha-check {
width: 22px; height: 22px; border: 2px solid #98a2b3; border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; flex: none; background: #fff; transition: border-color .15s;
}
.quan-captcha-box[data-state="idle"] .quan-captcha-check:hover { border-color: #2563eb; }
.quan-captcha-box[data-state="solving"] .quan-captcha-check {
border-color: #2563eb; border-top-color: transparent; border-radius: 50%;
animation: quan-captcha-spin .8s linear infinite;
}
.quan-captcha-box[data-state="solved"] .quan-captcha-check {
border-color: #16a34a; background: #16a34a; color: #fff; cursor: default;
}
.quan-captcha-box[data-state="error"] .quan-captcha-check { border-color: #dc2626; }
.quan-captcha-label { flex: 1; }
.quan-captcha-sub { display: block; font-size: 11px; color: #667085; margin-top: 1px; }
@keyframes quan-captcha-spin { to { transform: rotate(360deg); } }
`;

function injectStyle() {
if (document.getElementById("quan-captcha-style")) return;
const style = document.createElement("style");
style.id = "quan-captcha-style";
style.textContent = STYLE;
document.head.appendChild(style);
}

function formatHashrate(h) {
if (h >= 1e6) return (h / 1e6).toFixed(1) + " MH/s";
if (h >= 1e3) return (h / 1e3).toFixed(1) + " kH/s";
return h.toFixed(0) + " H/s";
}

function initWidget(el) {
if (el.dataset.quanCaptchaInit) return;
el.dataset.quanCaptchaInit = "1";

const endpoint = (el.dataset.endpoint || "").replace(/\/$/, "");
const workerUrl = el.dataset.workerUrl || endpoint + "/widget/solver-worker.js";
const wasmUrl = el.dataset.wasmUrl || endpoint + "/dist/solver_wasm.wasm";

const box = document.createElement("div");
box.className = "quan-captcha-box";
box.dataset.state = "idle";
box.innerHTML =
'<span class="quan-captcha-check" role="checkbox" aria-checked="false" tabindex="0"></span>' +
'<span class="quan-captcha-label">I\'m not a spammer' +
'<span class="quan-captcha-sub">Quantus proof-of-work &middot; no tracking, no puzzles</span>' +
"</span>";
el.appendChild(box);

const check = box.querySelector(".quan-captcha-check");
const sub = box.querySelector(".quan-captcha-sub");
let worker = null;
const startedAt = { t: 0 };

function setState(state, subText) {
box.dataset.state = state;
check.setAttribute("aria-checked", state === "solved" ? "true" : "false");
// Own the mark in both directions so error/retry states never keep a
// stale checkmark from a previous solved transition.
check.textContent = state === "solved" ? "\u2713" : "";
if (subText) sub.textContent = subText;
}

function fail(message) {
if (worker) { worker.terminate(); worker = null; }
setState("error", message + " — click to retry");
}

async function start() {
if (box.dataset.state === "solving" || box.dataset.state === "solved") return;
setState("solving", "requesting challenge…");
try {
const res = await fetch(endpoint + "/api/session", { method: "POST" });
if (!res.ok) throw new Error("challenge unavailable (" + res.status + ")");
const session = await res.json();

setState("solving", "computing (~" + session.expected_hashes + " hashes)…");
startedAt.t = performance.now();

worker = new Worker(workerUrl);
worker.onerror = () => fail("solver failed to load");
worker.onmessage = async (e) => {
const msg = e.data;
if (msg.type === "progress") {
const secs = (performance.now() - startedAt.t) / 1000;
sub.textContent = "computing… " + formatHashrate(msg.hashes / Math.max(secs, 0.001));
} else if (msg.type === "error") {
fail(msg.message);
} else if (msg.type === "found") {
worker.terminate();
worker = null;
try {
const shareRes = await fetch(endpoint + "/api/share", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: session.session_id, nonce: msg.nonce }),
});
const share = await shareRes.json();
if (!share.success) throw new Error(share.error || "share rejected");

// Do all fallible tail work BEFORE showing the solved state, so
// a throw here lands in fail() without leaving solved visuals.
const form = el.closest("form") || el;
let input = form.querySelector('input[name="quan-captcha-token"]');
if (!input) {
input = document.createElement("input");
input.type = "hidden";
input.name = "quan-captcha-token";
form.appendChild(input);
}
input.value = share.token;

const secs = ((performance.now() - startedAt.t) / 1000).toFixed(1);
setState("solved", "verified in " + secs + "s (" + msg.hashes + " hashes)" +
(share.block_found ? " — BLOCK FOUND!" : ""));

el.dispatchEvent(new CustomEvent("quan-captcha-solved", {
bubbles: true,
detail: { token: share.token, blockFound: !!share.block_found },
}));
} catch (err) {
fail(String(err.message || err));
}
Comment thread
illuzen marked this conversation as resolved.
}
};
worker.postMessage({
wasmUrl: wasmUrl,
headerHash: session.header_hash,
nonceStart: session.nonce_start,
shareTarget: session.share_target,
});
} catch (err) {
fail(String(err.message || err));
}
}

check.addEventListener("click", start);
check.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); start(); }
});
}

function initAll() {
injectStyle();
document.querySelectorAll("." + WIDGET_CLASS).forEach(initWidget);
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAll);
} else {
initAll();
}
})();
Loading
Loading