aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/captcha.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 13:34:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 13:34:47 +0200
commit51705056a578d2649a989bf14a056fbcfae58a6b (patch)
treedfd078aba518427387fca29b9d43c3723b030008 /packages/meshbay-hub/src/meshbay_hub/static/captcha.js
parentdaec4bc6a2a9f9e038e5269586b06018ff327a27 (diff)
downloadmeshbay-51705056a578d2649a989bf14a056fbcfae58a6b.tar.gz
feat(hub): replace reCAPTCHA with a self-hosted proof of work
Sign-up and passphrase reset sent every visitor's address and browser details to Google, which needed a consent the sign-up form could not ask for freely. The hub now issues its own challenge (ALTCHA's scheme, signed with a key derived from the hub's private key, single-use, 30 minutes) and the page solves it with SubtleCrypto while the form is filled in. - GET /v1/users/captcha issues a challenge; 404 captcha_disabled when off. - [captcha] enabled / max_number replace site_key / secret_key, which are now ignored with a warning; MESHBAY_CAPTCHA_ENABLED replaces the old variables. - No Google origin left in the CSP of the hub, the desktop shell or the Android shell; captcha_site_key leaves /v1/hub/info. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/captcha.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/captcha.js47
1 files changed, 47 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/captcha.js b/packages/meshbay-hub/src/meshbay_hub/static/captcha.js
new file mode 100644
index 0000000..bc174a0
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/captcha.js
@@ -0,0 +1,47 @@
+// The proof of work that gates sign-up and passphrase reset (the hub's
+// captcha.py). The hub sends the SHA-256 of `<prefix><number>` and the page
+// finds the number by trying them from 0, so a person waits for nothing: it is
+// solved while the form is being filled in. Nothing leaves for a third party.
+//
+// On the main thread, in batches handed to SubtleCrypto, rather than in a Web
+// Worker: the Android shell serves the interface through its own request
+// interceptor, and a worker's script is a request that interceptor is not
+// guaranteed to see. Each batch is a turn of the event loop, so typing in the
+// form is not held up by it.
+
+const BATCH = 1000;
+
+function fromHex(hex) {
+ const out = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
+ return out;
+}
+
+function same(buf, want) {
+ const got = new Uint8Array(buf);
+ for (let i = 0; i < want.length; i++) if (got[i] !== want[i]) return false;
+ return true;
+}
+
+/**
+ * The answer to a challenge from `GET /v1/users/captcha`, as the token the
+ * register and reset-request bodies carry — or null if no number below
+ * `max_number` matches, which only a challenge that is not the hub's produces,
+ * or once `signal` is aborted: the form was left before it was needed.
+ */
+export async function solve({ prefix, challenge, max_number: max, signature }, signal) {
+ const enc = new TextEncoder();
+ const want = fromHex(challenge);
+ for (let start = 0; start < max; start += BATCH) {
+ if (signal && signal.aborted) return null;
+ const end = Math.min(start + BATCH, max);
+ const pending = [];
+ for (let n = start; n < end; n++) {
+ pending.push(crypto.subtle.digest('SHA-256', enc.encode(prefix + n)));
+ }
+ const digests = await Promise.all(pending);
+ const hit = digests.findIndex(d => same(d, want));
+ if (hit >= 0) return `${prefix}${start + hit}.${signature}`;
+ }
+ return null;
+}