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 --- .../app/src/main/kotlin/org/meshbay/client/MainActivity.kt | 7 +++---- .../src/main/kotlin/org/meshbay/client/shell/UiAssets.kt | 14 ++++---------- 2 files changed, 7 insertions(+), 14 deletions(-) (limited to 'packages/meshbay-android/app') diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt index 699e0c3..6bd2a3f 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -198,9 +198,8 @@ class MainActivity : Activity() { return docs.firstNotNullOfOrNull { it.serve(path) } ?: refused() } if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() - // reCAPTCHA (sign-up) and nothing else goes to the network from - // the page; the policy says the same, this is the second wall. - if (UiAssets.isRecaptcha(url.host) && url.scheme == "https") return null + // Nothing goes to the network from the page; the policy says + // the same, this is the second wall. if (url.scheme == "blob" || url.scheme == "data") return null return refused() } @@ -211,7 +210,7 @@ class MainActivity : Activity() { // The hub must never become the document origin. A link out // opens in the person's browser, not in a window holding keys. if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url) - return !(UiAssets.isRecaptcha(url.host) && !request.isForMainFrame) + return true } } web.webChromeClient = object : WebChromeClient() { diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt index 2300668..0721db3 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt @@ -45,10 +45,6 @@ class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler { const val PREFIX = "/ui/" const val START = "$ORIGIN${PREFIX}index.html" - // reCAPTCHA gates sign-up here as it does in a browser and on the - // desktop; these two hosts and no others. - private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com" - /** * meshbay-client/src/main.js's CSP, directive for directive * (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is @@ -56,22 +52,20 @@ class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler { */ val CSP = listOf( "default-src 'none'", - "script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC", + "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: $RECAPTCHA_SRC", + "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self'", - "connect-src 'self' $RECAPTCHA_SRC", + "connect-src 'self'", "worker-src 'self'", "object-src blob:", - "frame-src blob: $RECAPTCHA_SRC", + "frame-src blob:", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'", ).joinToString("; ") - fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com" - fun contentType(name: String): String = when (File(name).extension.lowercase()) { "html" -> "text/html" "js", "mjs" -> "text/javascript" -- cgit v1.2.3