Solve the sign-up puzzle when a relay asks for one

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 15:47:22 -04:00
co-authored by Claude Opus 4.8
parent 0271c15ab3
commit df81800783
4 changed files with 481 additions and 9 deletions
@@ -34,12 +34,38 @@ class RelayAccounts(
val appPassword: String, 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("username", username)
.put("email", email) .put("email", email)
.put("password", password) .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 * ⚠️ 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. // better than anything this layer could invent from a status code.
val message = runCatching { JSONObject(text).optString("message") } val message = runCatching { JSONObject(text).optString("message") }
.getOrNull().orEmpty().ifBlank { text } .getOrNull().orEmpty().ifBlank { text }
throw when (it.code) { val code = runCatching { JSONObject(text).optString("code") }
401, 403 -> RelayException.Unauthorized(message) .getOrNull().orEmpty()
404 -> RelayException.NotFound(path) throw when {
409 -> RelayException.Http(409, message) // 🔴 Decided by the relay's own `code`, never by the status. A
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull()) // 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. // 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) else -> RelayException.Http(it.code, message)
} }
} }
@@ -41,6 +41,32 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
"Flamenet isn't available in your country yet. " + "Flamenet isn't available in your country yet. " +
"This isn't a problem with your account or your connection.") "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. */ /** 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 * The account exists and the credential is right; the address has not been
@@ -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) }
}
@@ -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("<html>not json</html>"),
"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<RelayException.ChallengeRequired> {
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<RelayException.ChallengeFailed> {
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<RelayException.Http> {
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")
}
}
}
}