From 51705056a578d2649a989bf14a056fbcfae58a6b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 13:34:47 +0200 Subject: 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 --- .../src/meshbay_hub/static/auth-page.js | 133 +++++++++------------ .../meshbay-hub/src/meshbay_hub/static/captcha.js | 47 ++++++++ .../src/meshbay_hub/static/keyderive.js | 6 +- .../src/meshbay_hub/static/locales/de.js | 3 +- .../src/meshbay_hub/static/locales/en.js | 3 +- .../src/meshbay_hub/static/locales/es.js | 3 +- .../src/meshbay_hub/static/locales/fr.js | 3 +- .../src/meshbay_hub/static/locales/it.js | 3 +- .../src/meshbay_hub/static/locales/ja.js | 3 +- .../src/meshbay_hub/static/locales/nl.js | 3 +- .../src/meshbay_hub/static/locales/pl.js | 3 +- .../src/meshbay_hub/static/locales/pt-BR.js | 3 +- .../src/meshbay_hub/static/locales/zh-CN.js | 3 +- 13 files changed, 119 insertions(+), 97 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/captcha.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index e014f61..c5374f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -8,6 +8,7 @@ import { import * as platform from './platform.js'; import { Icon } from './icon.js'; import { loadPending } from './invite-link.js'; +import { solve } from './captcha.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; @@ -44,73 +45,63 @@ function PasswordInput({ `; } -// ── reCAPTCHA v2 helper ────────────────────────────────────────────────────── - -let _captchaSiteKey = null; -let _captchaKeyFetched = false; - -async function fetchCaptchaSiteKey() { - if (_captchaKeyFetched) return _captchaSiteKey; - try { - const info = await hubFetch('/v1/hub/info'); - _captchaSiteKey = info.captcha_site_key || null; - } catch { _captchaSiteKey = null; } - _captchaKeyFetched = true; - return _captchaSiteKey; -} - -function loadRecaptchaScript() { - if (document.getElementById('recaptcha-script')) return; - const s = document.createElement('script'); - s.id = 'recaptcha-script'; - s.src = 'https://www.google.com/recaptcha/api.js?render=explicit'; - s.async = true; - s.defer = true; - document.head.appendChild(s); -} +// ── Proof of work (captcha.js) ─────────────────────────────────────────────── +// +// Fetched and solved as soon as the form is on screen, so by the time somebody +// has typed a name, an address and a passphrase twice there is nothing left to +// wait for. Nothing is drawn: there is nothing for a person to do. A hub with +// the check turned off answers 404, and the form then sends no answer at all. function useCaptcha() { - const [siteKey, setSiteKey] = useState(_captchaSiteKey); - const [token, setToken] = useState(null); - const containerRef = useRef(null); - const widgetId = useRef(null); - - useEffect(() => { - fetchCaptchaSiteKey().then(k => { - if (k) { setSiteKey(k); loadRecaptchaScript(); } - }); + const pending = useRef(null); + const abort = useRef(null); + + const start = useCallback(() => { + if (abort.current) abort.current.abort(); + const ctl = new AbortController(); + abort.current = ctl; + pending.current = (async () => { + let challenge; + try { + challenge = await hubFetch('/v1/users/captcha'); + } catch (err) { + return err.message === 'captcha_disabled' ? { token: null, until: Infinity } : null; + } + // A minute short of the hub's own limit, so an answer is never sent + // that expires on its way there. + const until = Date.now() + (challenge.expires_in - 60) * 1000; + return { token: await solve(challenge, ctl.signal), until }; + })(); }, []); useEffect(() => { - if (!siteKey || !containerRef.current) return; - const poll = setInterval(() => { - if (window.grecaptcha && window.grecaptcha.render && widgetId.current === null) { - clearInterval(poll); - widgetId.current = window.grecaptcha.render(containerRef.current, { - sitekey: siteKey, - callback: (tk) => setToken(tk), - 'expired-callback': () => setToken(null), - theme: document.documentElement.getAttribute('data-theme') === 'dark' - ? 'dark' : 'light', - }); - } - }, 100); - return () => clearInterval(poll); - }, [siteKey]); - - const reset = useCallback(() => { - if (widgetId.current !== null && window.grecaptcha) { - window.grecaptcha.reset(widgetId.current); - setToken(null); + start(); + return () => abort.current && abort.current.abort(); + }, [start]); + + // The answer for this submission — waited for if it is still being worked + // out, and worked out again if the form sat open past its expiry or the + // first fetch failed. Null only when the hub has no check, or cannot be + // reached, in which case the hub's own refusal is what the person sees. + const take = useCallback(async () => { + let got = await pending.current; + if (!got || got.until < Date.now()) { + start(); + got = await pending.current; } - }, []); + return got ? got.token : null; + }, [start]); - const widget = siteKey - ? html`
` - : null; + // An answer is spent once the hub has checked it, whatever happened after — + // a name already taken is refused past that point. Call after a failure. + return { take, renew: start }; +} - return { token, widget, reset, enabled: !!siteKey }; +// The hub's refusal of the proof of work, which a person can do nothing about +// but try again, in words rather than as the code it is sent as. +function errorText(err) { + return err.message === 'captcha_required' || err.message === 'captcha_failed' + ? t('captcha.failed') : err.message; } function passwordBits(pw) { @@ -411,11 +402,9 @@ export function RegisterPage() { const rk = window.MeshBayKeys.generateRecoveryKey(); // `name` (trimmed), not the raw field: the hub stores the trimmed // username and every key derivation must fold in the same string. - // `captcha.token` rides along — the submit button is already disabled - // until it is set when a captcha is configured (see the form below). const reg = await window.MeshBayKeys.registerUser( name, email, password, emailRecovery ? rk.mnemonic : null, - captcha.token); + await captcha.take()); // An account made in the desktop application starts without browser // access: its identities stay on this device and nothing of them is // left on nodes until the person turns it on, here, natively. @@ -433,17 +422,14 @@ export function RegisterPage() { body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '', - captcha_token: captcha.token, + captcha_token: await captcha.take(), }, }); setPhase('verify'); } } catch (err) { - setError(err.message); - // A reCAPTCHA token is single-use: after a failed attempt (name taken, - // e-mail in use…) it is spent, so clear it and make the user solve a - // fresh one before the next try. No-op when no captcha is configured. - captcha.reset(); + setError(errorText(err)); + captcha.renew(); } finally { setLoading(false); } @@ -609,9 +595,8 @@ export function RegisterPage() { onChange=${e => setEmailRecovery(e.target.checked)} /> ${t('register.recovery_email_opt')} - ${captcha.widget} ${error && html`
${error}
`} -
${error}
`} - `} 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 `` 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; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index edf4df9..bd5282c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -401,9 +401,9 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha // appends it to the verification e-mail and stores it nowhere // (docs/MESHBAY_DESIGN.md §3.6). Omitted when they chose to save it themselves. if (recoveryMnemonic) payload.recovery_key = recoveryMnemonic; - // reCAPTCHA response, when the hub has a captcha configured. The widget lives - // in RegisterPage (auth-page.js); this function just forwards its token. A - // hub with no captcha configured sends nothing and the server does not check. + // The proof-of-work answer, when the hub asks for one. RegisterPage + // (auth-page.js) works it out; this function just forwards it. A hub with + // the check turned off gets nothing and does not look. if (captchaToken) payload.captcha_token = captchaToken; const resp = await hubCall('/v1/users/register', { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 1fe2b36..bf16bca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -139,8 +139,7 @@ export default { 'register.strength': 'Stärke: etwa {bits} Bit. Sie schützt die Kopie Ihrer ' + 'Schlüssel, die auf den Nodes liegt, denen Sie beitreten — es lohnt sich also, ' + 'sie gut zu wählen.', - 'captcha.required': 'Bitte das Captcha ausfüllen.', - 'captcha.failed': 'Captcha-Überprüfung fehlgeschlagen — bitte erneut versuchen.', + 'captcha.failed': "Die Prüfung gegen Roboter ist fehlgeschlagen. Bitte versuchen Sie es erneut.", // Home 'home.welcome': 'Willkommen bei MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 642d3e2..c7dc47e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -140,8 +140,7 @@ export default { + 'your keys where they are stored — a few unrelated words work well.', 'register.strength': 'Strength: about {bits} bits. This protects the copy of ' + 'your keys kept on the nodes you join, so it is worth getting right.', - 'captcha.required': 'Please complete the captcha.', - 'captcha.failed': 'Captcha verification failed — please try again.', + 'captcha.failed': "The anti-robot check failed. Please try again.", // Home 'home.welcome': 'Welcome to MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index fe66af4..0831bac 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -137,8 +137,7 @@ export default { + 'relación entre sí funcionan muy bien.', 'register.strength': 'Solidez: unos {bits} bits. Protege la copia de sus claves que ' + 'se guarda en los nodes a los que se une, así que conviene acertar.', - 'captcha.required': 'Por favor, complete el captcha.', - 'captcha.failed': 'Verificación del captcha fallida — inténtelo de nuevo.', + 'captcha.failed': "La verificación anti-robots ha fallado. Inténtelo de nuevo.", // Home 'home.welcome': 'Bienvenido a MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 6296a8b..2a52848 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -138,8 +138,7 @@ export default { 'register.strength': 'Robustesse : environ {bits} bits. Elle protège la copie de ' + 'vos clés conservée sur les nodes que vous rejoignez, cela vaut donc la peine ' + 'de la soigner.', - 'captcha.required': 'Veuillez compléter le captcha.', - 'captcha.failed': 'Vérification du captcha échouée — veuillez réessayer.', + 'captcha.failed': "La vérification anti-robot a échoué. Veuillez réessayer.", // Home 'home.welcome': 'Bienvenue sur MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 0751692..1dd3f52 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -138,8 +138,7 @@ export default { + 'legame tra loro funziona bene.', 'register.strength': 'Robustezza: circa {bits} bit. Protegge la copia delle sue ' + 'chiavi conservata sui node a cui aderisce, quindi vale la pena sceglierla con cura.', - 'captcha.required': 'Completa il captcha.', - 'captcha.failed': 'Verifica captcha fallita — riprova.', + 'captcha.failed': "La verifica anti-robot non è riuscita. Riprovi.", // Home 'home.welcome': 'Benvenuto in MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index c3d4fa8..0d4aadf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -136,8 +136,7 @@ export default { + 'その鍵を守るものです。互いに関係のない単語をいくつか並べると効果的です。', 'register.strength': '強度:約 {bits} ビット。参加している各 node に保管される鍵の' + 'コピーを守るものですので、しっかり決めておく価値があります。', - 'captcha.required': 'キャプチャを完了してください。', - 'captcha.failed': 'キャプチャの検証に失敗しました — もう一度お試しください。', + 'captcha.failed': "ロボット対策の確認に失敗しました。もう一度お試しください。", // Home 'home.welcome': 'MeshBay へようこそ', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index c02be99..31fc7b3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -139,8 +139,7 @@ export default { 'register.strength': 'Sterkte: ongeveer {bits} bits. Ze beschermt de kopie van uw ' + 'sleutels die op de nodes staat waar u zich bij aansluit, dus het loont om er ' + 'even bij stil te staan.', - 'captcha.required': 'Vul de captcha in.', - 'captcha.failed': 'Captchaverificatie mislukt — probeer het opnieuw.', + 'captcha.failed': "De controle tegen robots is mislukt. Probeer het opnieuw.", // Home 'home.welcome': 'Welkom bij MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 97e8b53..e88272d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -143,8 +143,7 @@ export default { + 'bardzo dobrze.', 'register.strength': 'Siła: około {bits} bitów. Chroni kopię kluczy przechowywaną na ' + 'nodes, do których się dołącza, więc warto ją dobrać starannie.', - 'captcha.required': 'Proszę wypełnić captcha.', - 'captcha.failed': 'Weryfikacja captcha nie powiodła się — spróbuj ponownie.', + 'captcha.failed': "Weryfikacja antyrobotowa nie powiodła się. Spróbuj ponownie.", // Home 'home.welcome': 'Witamy w MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 8ff36a5..5b2d80f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -139,8 +139,7 @@ export default { + 'entre si funcionam muito bem.', 'register.strength': 'Robustez: cerca de {bits} bits. Ela protege a cópia das suas ' + 'chaves mantida nos nodes dos quais você participa, então vale a pena caprichar.', - 'captcha.required': 'Por favor, complete o captcha.', - 'captcha.failed': 'Verificação do captcha falhou — tente novamente.', + 'captcha.failed': "A verificação anti-robô falhou. Tente novamente.", // Home 'home.welcome': 'Bem-vindo ao MeshBay', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index d4ad64f..a6ea535 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -136,8 +136,7 @@ export default { + '几个互不相关的词就很好用。', 'register.strength': '强度:约 {bits} 位。它保护的是您所加入的各个 node 上保存的密钥副本,' + '因此值得认真设置。', - 'captcha.required': '请完成验证码。', - 'captcha.failed': '验证码验证失败——请重试。', + 'captcha.failed': "防机器人验证失败,请重试。", // Home 'home.welcome': '欢迎使用 MeshBay', -- cgit v1.2.3