From df81800783634f1a993f3d18fb8764f7641e8b5d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 22:40:15 -0400 Subject: [PATCH] Solve the sign-up puzzle when a relay asks for one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay can charge a few seconds of arithmetic before an account exists — cheap for one person, expensive for a farm, and unlike a CAPTCHA it hands nothing to a third party. This is the Android half, matching the iPhone app. Asked for only when the relay refuses without one, so a relay with the feature off — every relay running today — still sees exactly one request. Rethrown rather than swallowed when this app cannot answer: a difficulty above the cap leaves it genuinely unable to sign up here, and "update the app" is then the true sentence. Two refusals get their own sentences. challenge_required means the app is behind its server; challenge_failed is nearly always a challenge that expired while somebody was typing. Sharing one message would send half these people to the Play Store for nothing. Decided by the relay's code, never the status, because a 400 is also a bad email. The bit counting is pinned to exact vectors. A byte-counting implementation still SATISFIES a 9-bit difficulty — it overshoots to 16 and does 230x the work — so an assertion about "at least 9 leading zeros" cannot see the bug. Refs flamenet-relay#39 Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/client/RelayAccounts.kt | 53 +++- .../io/flamenet/client/RelayException.kt | 26 ++ .../io/flamenet/client/SignupProofOfWork.kt | 140 +++++++++ .../flamenet/client/SignupProofOfWorkTest.kt | 271 ++++++++++++++++++ 4 files changed, 481 insertions(+), 9 deletions(-) create mode 100644 client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt create mode 100644 client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt index 4f42337..0c74859 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt @@ -34,12 +34,38 @@ class RelayAccounts( val appPassword: String, ) - fun signUp(username: String, email: String, password: String, displayName: String? = null): Account = - account(post("account/signup", JSONObject() + /** + * ⚠️ **The challenge is fetched only when the relay asks for one.** + * Fetching up front would add a round trip to every sign-up on every + * server, for a feature that is off by default and off on every relay + * running today — a second wait on the one screen where somebody is + * already waiting. So the first attempt goes out plain, and only a relay + * that refuses it sends us to solve anything. + * + * The refused attempt costs one of fifty per hour from this address, which + * is the right trade for not charging everybody else a round trip. + */ + fun signUp(username: String, email: String, password: String, displayName: String? = null): Account { + fun body() = JSONObject() .put("username", username) .put("email", email) .put("password", password) - .put("display_name", displayName ?: username))) + .put("display_name", displayName ?: username) + + return try { + account(post("account/signup", body())) + } catch (asked: RelayException.ChallengeRequired) { + // 🔴 Rethrown, not swallowed, when we cannot answer. A difficulty + // above the cap or a search that ran out leaves this app genuinely + // unable to sign up here, and "update the app" is then the true + // sentence — quietly failing with something else would send + // somebody looking in the wrong place. + val solved = SignupProofOfWork.satisfy(base.toString(), http) ?: throw asked + account(post("account/signup", body() + .put("challenge", solved.challenge) + .put("nonce", solved.nonce))) + } + } /** * ⚠️ The field is "username **or email**" and always has been. A client @@ -139,13 +165,22 @@ class RelayAccounts( // better than anything this layer could invent from a status code. val message = runCatching { JSONObject(text).optString("message") } .getOrNull().orEmpty().ifBlank { text } - throw when (it.code) { - 401, 403 -> RelayException.Unauthorized(message) - 404 -> RelayException.NotFound(path) - 409 -> RelayException.Http(409, message) - 429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull()) + val code = runCatching { JSONObject(text).optString("code") } + .getOrNull().orEmpty() + throw when { + // 🔴 Decided by the relay's own `code`, never by the status. A + // 400 is also a malformed body and a bad email; telling either + // of those to go and update the app would send somebody to the + // Play Store to fix a typo. + code == "challenge_required" -> RelayException.ChallengeRequired(message) + code == "challenge_failed" -> RelayException.ChallengeFailed(message) + it.code == 401 || it.code == 403 -> RelayException.Unauthorized(message) + it.code == 404 -> RelayException.NotFound(path) + it.code == 409 -> RelayException.Http(409, message) + it.code == 429 -> + RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull()) // The geo-gate, not the relay. Needs a sentence, not a number. - 451 -> RelayException.Unavailable(message) + it.code == 451 -> RelayException.Unavailable(message) else -> RelayException.Http(it.code, message) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index c7fd098..1332695 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -41,6 +41,32 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti "Flamenet isn't available in your country yet. " + "This isn't a problem with your account or your connection.") + /** + * This relay charges a few seconds of arithmetic before a new account, and + * this app is too old to pay it. + * + * ⚠️ The remedy is an update, and it is the only refusal in sign-up where + * that is true — which is exactly why it cannot share a sentence with the + * one below. Telling somebody to try again when the app will never be able + * to answer sends them round a loop that has no exit. + */ + class ChallengeRequired(val body: String) : RelayException( + "This server asks new accounts to do a quick check first, and this " + + "version of the app can't do it yet. Update the app and try again.") + + /** + * The check was answered and the answer did not hold up. + * + * ⚠️ Nearly always a challenge that expired while somebody was typing. + * The relay deliberately gives one message for all four of its refusals — + * forged, stale, unsolved, malformed — because telling an attacker which + * knob to turn is worse than being vague with everybody else, so this side + * cannot be more specific than "start a fresh one" either. + */ + class ChallengeFailed(val body: String) : RelayException( + "That check took too long. Tap Create account again and it will " + + "start a fresh one.") + /** Any other non-2xx. The body is kept because relays put the reason in it. */ /** * The account exists and the credential is right; the address has not been diff --git a/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt b/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt new file mode 100644 index 0000000..0330bb9 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/SignupProofOfWork.kt @@ -0,0 +1,140 @@ +package io.flamenet.client + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.security.MessageDigest + +/** + * A few seconds of this phone's arithmetic, spent before an account exists. + * + * 🔴 **Why this and not a CAPTCHA.** A CAPTCHA hands a third party every + * visitor's connection, which is a strange thing to do in a product whose + * argument is that it collects almost nothing. This collects nothing at all: + * no personal data, no third party, no picture of a crosswalk. It costs one + * person a couple of seconds and costs a farm of registrations real money. + * + * ⚠️ **Every branch here has to end in "sign up anyway".** A relay that + * predates the feature, a request that times out, a proxy that returns HTML — + * none of those are reasons somebody cannot create an account. The relay + * decides whether a challenge is *required*; this side only tries to satisfy + * one when it is offered. + */ +object SignupProofOfWork { + + /** + * What the relay hands out. `bits == 0` means it wants no work — the relay + * answers that rather than 404ing, so asking is always safe and the answer + * never reveals how a deployment is configured. + */ + data class Challenge(val challenge: String, val bits: Int, val expiresIn: Int) + + /** A solved challenge, ready to travel beside the sign-up fields. */ + data class Solution(val challenge: String, val nonce: String) + + /** + * 🔴 **Refuse to grind past this even if the relay asks.** The relay caps + * itself at 24 bits, but a client that trusts a number from the network + * with an unbounded loop is one misconfiguration away from an app that + * appears to hang on the sign-up button — which reads as broken, not as + * spam protection. Above the cap we send nothing and let the relay refuse, + * because a refusal with a sentence beats a spinner that never ends. + */ + const val MAXIMUM_BITS = 24 + + /** + * Hashes to try before giving up. At 24 bits the expected count is ~16.7M, + * so this is roughly a 4x margin — generous enough that an unlucky run + * still finishes, bounded enough that a wrong difficulty cannot run for + * ever. + */ + const val CEILING = 64_000_000 + + /** + * Ask what work this server wants, if any. + * + * ⚠️ Returns null for every failure, deliberately. 404 is the common one — + * an older relay, or a site that has no such route — and it is + * indistinguishable from success-with-no-work as far as what the caller + * should do next. + */ + fun fetch(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Challenge? { + val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder() + .addPathSegments("account/challenge").build() + val request = Request.Builder().url(url).get().header("Accept", "application/json").build() + return try { + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + val json = JSONObject(response.body?.string().orEmpty()) + Challenge( + challenge = json.getString("challenge"), + bits = json.optInt("bits", 0), + expiresIn = json.optInt("expires_in", 0), + ) + } + } catch (_: Exception) { + // Transport, a 404 page that is not JSON, a missing field — all + // mean the same thing here, and it is not "no account for you". + null + } + } + + /** Solve a challenge, or return null if there is nothing solvable in it. */ + fun solve(issued: Challenge): Solution? { + if (issued.bits <= 0 || issued.bits > MAXIMUM_BITS) return null + val nonce = nonce(issued.challenge, issued.bits) ?: return null + return Solution(issued.challenge, nonce) + } + + /** + * The search itself. Separated so tests can drive it at a low difficulty + * without going through a [Challenge] that claims an expiry. + */ + fun nonce(challenge: String, bits: Int, ceiling: Int = CEILING): String? { + val digest = MessageDigest.getInstance("SHA-256") + // Hashed as `challenge.nonce`, which is exactly what the relay + // verifies. The prefix never changes, so it is encoded once rather than + // per attempt — at tens of millions of iterations that is not a + // micro-optimisation, it is most of the running time. + val prefix = "$challenge.".toByteArray(Charsets.UTF_8) + for (n in 0 until ceiling) { + digest.reset() + digest.update(prefix) + digest.update(n.toString().toByteArray(Charsets.UTF_8)) + if (leadingZeroBits(digest.digest()) >= bits) return n.toString() + } + return null + } + + /** + * ⚠️ Counted in BITS, not leading zero bytes. Bytes move in steps of 256x, + * which leaves no way to ask for two seconds rather than half a second or + * two minutes. This must agree with the relay exactly or every solution + * this phone produces is rejected by a server that is working perfectly. + */ + fun leadingZeroBits(digest: ByteArray): Int { + var count = 0 + for (byte in digest) { + val value = byte.toInt() and 0xFF + if (value == 0) { count += 8; continue } + count += Integer.numberOfLeadingZeros(value) - 24 + break + } + return count + } + + /** + * Fetch and solve, for the caller that just wants fields to send. + * + * Returns null when this server asks for no work, which is the normal case + * today and stays the normal case for anyone self-hosting who never turns + * it on. + * + * ⚠️ Blocking and CPU-bound, like every other call in this module. The + * caller is already off the main thread; if it were not, sign-up would + * already be an ANR without any of this. + */ + fun satisfy(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Solution? = + fetch(baseUrl, http)?.let { solve(it) } +} diff --git a/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt b/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt new file mode 100644 index 0000000..69e86b6 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/SignupProofOfWorkTest.kt @@ -0,0 +1,271 @@ +package io.flamenet.client + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.security.MessageDigest + +/** + * The client half of the sign-up puzzle. + * + * 🔴 **The bit counting has to agree with the relay exactly.** Both sides count + * leading zero *bits* of `SHA256("challenge.nonce")`. If this side counted + * bytes, every solution this phone produced would still be accepted — it would + * simply do hundreds of times the work — and no assertion about "at least N + * leading zeros" could see it, because overshooting also satisfies N. That is + * why the vectors below are pinned to exact values. + * + * ⚠️ The other half of the contract is that **nothing here may prevent a + * sign-up**. A relay that predates the feature, a request that times out, a + * proxy returning HTML — all of those mean "no work wanted", not "no account + * for you". + */ +class SignupProofOfWorkTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { + server = MockWebServer() + server.start() + } + + @AfterTest fun stop() { + server.shutdown() + } + + private fun url() = server.url("/").toString() + + // ---------------------------------------------------------------- the arithmetic + + /** + * ⚠️ The input is chosen so the answer is NOT a multiple of eight. + * `SHA256("pin754")` starts `000f d989…` — twelve zero bits, which a + * byte-counting implementation reports as eight. Almost every digest has no + * leading zeros at all and cannot tell the two apart. + */ + @Test + fun `leading zeros are counted in bits, not bytes`() { + val digest = MessageDigest.getInstance("SHA-256") + .digest("pin754".toByteArray(Charsets.UTF_8)) + assertEquals(12, SignupProofOfWork.leadingZeroBits(digest), + "counted to a byte boundary instead of a bit") + } + + /** + * 🔴 The exact nonce, not merely "a nonce that is good enough". A + * byte-rounding implementation still satisfies a 9-bit difficulty — it + * overshoots to 16 bits and returns 4416 instead of 19, which is 230x the + * work for the person signing up. + */ + @Test + fun `the first satisfying nonce is the one returned`() { + assertEquals("19", SignupProofOfWork.nonce("1700000000.c2FsdA.bWFj", bits = 9), + "solved a harder puzzle than the relay asked for") + } + + /** The same string the relay hashes: `challenge` then a dot then `nonce`. */ + @Test + fun `a solved nonce satisfies the relay's own check`() { + val challenge = "1700000000.c2FsdA.bWFj" + val nonce = assertNotNull(SignupProofOfWork.nonce(challenge, bits = 12)) + val digest = MessageDigest.getInstance("SHA-256") + .digest("$challenge.$nonce".toByteArray(Charsets.UTF_8)) + assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 12, + "the relay would refuse this as unsolved") + } + + /** + * 🔴 A nonce solved for one challenge must not satisfy another, or the + * puzzle is solved once and reused for every registration in a farm. + */ + @Test + fun `a nonce does not transfer between challenges`() { + val nonce = assertNotNull(SignupProofOfWork.nonce("challenge-one", bits = 12)) + val digest = MessageDigest.getInstance("SHA-256") + .digest("challenge-two.$nonce".toByteArray(Charsets.UTF_8)) + assertTrue(SignupProofOfWork.leadingZeroBits(digest) < 12, + "a nonce solved elsewhere satisfied this challenge") + } + + // ---------------------------------------------------------------- refusing to grind + + /** ⚠️ The ceiling is a real bound. A search that cannot finish must return. */ + @Test + fun `the search is bounded rather than endless`() { + assertNull(SignupProofOfWork.nonce("unreachable", bits = 32, ceiling = 5_000)) + } + + /** + * 🔴 A difficulty above the cap is not attempted at all. Trusting a number + * from the network with a long loop is how an app comes to look hung. + */ + @Test + fun `a difficulty above the cap is not attempted`() { + val absurd = SignupProofOfWork.Challenge("x", SignupProofOfWork.MAXIMUM_BITS + 1, 600) + assertNull(SignupProofOfWork.solve(absurd), + "ground on a difficulty the relay is not even allowed to ask for") + } + + /** `bits: 0` is how a relay says it wants none. That must cost nothing. */ + @Test + fun `no work wanted means no solution`() { + assertNull(SignupProofOfWork.solve(SignupProofOfWork.Challenge("x", 0, 600))) + } + + // ---------------------------------------------------------------- never in the way + + /** 🔴 Every one of these servers must still be able to create an account. */ + @Test + fun `every server that cannot offer a challenge still allows sign-up`() { + val cases = listOf( + "an older relay with no such route" to MockResponse().setResponseCode(404), + "a relay that is unwell" to MockResponse().setResponseCode(500), + "a proxy returning HTML with a 200" to + MockResponse().setResponseCode(200).setBody("not json"), + "an empty body" to MockResponse().setResponseCode(200).setBody(""), + "JSON with no challenge field" to + MockResponse().setResponseCode(200).setBody("""{"bits":10}"""), + ) + for ((name, response) in cases) { + server.enqueue(response) + assertNull(SignupProofOfWork.satisfy(url()), + "$name should mean no work, not no account") + } + } + + /** And the ordinary case: an offer of real work is fetched, parsed and met. */ + @Test + fun `an offered challenge is fetched and solved`() { + server.enqueue(MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":10,"expires_in":600}""")) + + val solution = assertNotNull(SignupProofOfWork.satisfy(url())) + assertEquals("1700000000.c2FsdA.bWFj", solution.challenge) + val digest = MessageDigest.getInstance("SHA-256") + .digest("${solution.challenge}.${solution.nonce}".toByteArray(Charsets.UTF_8)) + assertTrue(SignupProofOfWork.leadingZeroBits(digest) >= 10) + } + + /** ⚠️ Asked at `account/challenge`, beside `account/signup`. */ + @Test + fun `the challenge is asked for beside sign-up`() { + server.enqueue(MockResponse().setResponseCode(404)) + SignupProofOfWork.fetch(url()) + assertEquals("/account/challenge", server.takeRequest().path) + } + + // ---------------------------------------------------------------- what people read + + private fun refuse(code: String) { + server.enqueue(MockResponse().setResponseCode(400) + .setHeader("Content-Type", "application/json") + .setBody("""{"code":"$code","message":"the relay said something"}""")) + } + + /** + * 🔴 **The whole reactive flow.** Refused for a missing challenge, we go + * and get one, solve it, and send the same fields again with the answer. + * + * ⚠️ Three exchanges, in this order. The first sign-up carries no challenge + * at all, which is the point: a relay with this feature off — every relay + * running today — sees exactly one request and nobody waits twice. + */ + @Test + fun `a relay that asks for work is answered and the sign-up goes through`() { + refuse("challenge_required") + server.enqueue(MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"challenge":"1700000000.c2FsdA.bWFj","bits":8,"expires_in":600}""")) + server.enqueue(MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"user_id":7,"username":"ada","app_password":"credential"}""")) + + val account = RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + assertEquals("ada", account.username) + + val first = server.takeRequest() + assertEquals("/account/signup", first.path) + assertTrue(!first.body.readUtf8().contains("challenge"), + "the first attempt should carry no challenge — that round trip is the cost") + + assertEquals("/account/challenge", server.takeRequest().path) + + val retry = JSONObject(server.takeRequest().body.readUtf8()) + assertEquals("1700000000.c2FsdA.bWFj", retry.getString("challenge")) + // Every original field survives the retry. Dropping one here would look + // like a validation failure on a sign-up that was fine. + assertEquals("ada", retry.getString("username")) + assertEquals("ada@example.com", retry.getString("email")) + assertEquals("passphrase", retry.getString("password")) + } + + /** + * 🔴 Rethrown, not swallowed. When this app genuinely cannot answer — a + * difficulty above the cap, or a relay that will not hand out a challenge — + * "update the app" is the true sentence, and failing with anything else + * sends somebody looking in the wrong place. + */ + @Test + fun `an app that cannot answer is told to update`() { + refuse("challenge_required") + server.enqueue(MockResponse().setResponseCode(404)) + val failure = assertFailsWith { + RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + } + assertTrue(failure.message!!.contains("Update"), "no remedy offered: ${failure.message}") + } + + /** + * ⚠️ A different sentence from the one above, and it must stay different. + * This one is nearly always a challenge that expired while somebody was + * typing; the remedy is another tap, not a trip to the Play Store. + */ + @Test + fun `an expired check is told to try again`() { + refuse("challenge_failed") + val failure = assertFailsWith { + RelayAccounts(url()).signUp("ada", "ada@example.com", "passphrase") + } + assertTrue(failure.message!!.contains("again"), "no remedy offered: ${failure.message}") + } + + /** + * 🔴 The distinction that matters. A 400 is also a bad email, and telling + * somebody to update the app to fix a typo is worse than saying nothing. + */ + @Test + fun `another 400 is not mistaken for a challenge problem`() { + refuse("bad_email") + assertFailsWith { + RelayAccounts(url()).signUp("ada", "not-an-email", "passphrase") + } + } + + /** ⚠️ Neither sentence blames the person or their password. */ + @Test + fun `neither refusal reads as a credential problem`() { + val sentences = listOf( + RelayException.ChallengeRequired("").message!!, + RelayException.ChallengeFailed("").message!!, + ) + for (text in sentences.map { it.lowercase() }) { + for (misleading in listOf("password", "passphrase", "incorrect", "invalid", "wrong")) { + assertTrue(!text.contains(misleading), "reads as a credential problem: $text") + } + // And nothing machine-shaped: somebody creating an account does not + // need the words "proof of work", "hash" or "nonce". + for (jargon in listOf("proof of work", "hash", "nonce", "sha", "_")) { + assertTrue(!text.contains(jargon), "leads with the mechanism: $text") + } + } + } +}