aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
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
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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js129
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/captcha.js47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
13 files changed, 117 insertions, 95 deletions
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);
+ const pending = useRef(null);
+ const abort = useRef(null);
- useEffect(() => {
- fetchCaptchaSiteKey().then(k => {
- if (k) { setSiteKey(k); loadRecaptchaScript(); }
- });
+ 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]);
+ start();
+ return () => abort.current && abort.current.abort();
+ }, [start]);
- const reset = useCallback(() => {
- if (widgetId.current !== null && window.grecaptcha) {
- window.grecaptcha.reset(widgetId.current);
- setToken(null);
+ // 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`<div ref=${containerRef}
- style="display:flex;justify-content:center;margin:12px 0"></div>`
- : 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)} />
<span>${t('register.recovery_email_opt')}</span>
</label>
- ${captcha.widget}
${error && html`<div class="error-msg">${error}</div>`}
- <button type="submit" disabled=${loading || (captcha.enabled && !captcha.token)}>
+ <button type="submit" disabled=${loading}>
${loading ? t('register.loading') : t('register.submit')}
</button>
<p class="register-legal">
@@ -658,12 +643,13 @@ export function ResetPasswordPage({ onLogin }) {
method: 'POST',
body: {
username: username.trim(), email: email.trim(),
- captcha_token: captcha.token,
+ captcha_token: await captcha.take(),
},
});
setPhase('form');
} catch (err) {
- setError(err.message);
+ setError(errorText(err));
+ captcha.renew();
} finally {
setBusy(false);
}
@@ -771,9 +757,8 @@ export function ResetPasswordPage({ onLogin }) {
<input type="email" placeholder="${t('register.email')}" value=${email}
onInput=${e => setEmail(e.target.value)}
autocomplete="email" required />
- ${captcha.widget}
${error && html`<div class="error-msg">${error}</div>`}
- <button type="submit" disabled=${busy || (captcha.enabled && !captcha.token)}>
+ <button type="submit" disabled=${busy}>
${t('reset.send_code')}</button>
</form>`}
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;
+}
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',