diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
| commit | 51705056a578d2649a989bf14a056fbcfae58a6b (patch) | |
| tree | dfd078aba518427387fca29b9d43c3723b030008 /packages/meshbay-hub/src/meshbay_hub/static/captcha.js | |
| parent | daec4bc6a2a9f9e038e5269586b06018ff327a27 (diff) | |
| download | meshbay-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.js | 47 |
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; +} |