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:
co-authored by
Claude Opus 4.8
parent
0271c15ab3
commit
df81800783
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user