Put the app back to the last state that worked

Tonight's merges left sign-in, messages, calls and stories broken. Restores
app, engine and client to fa92581 — the tree that shipped as v8 and works.

Why they broke: those features are not independent. A message, a call
signal, a story and a story reply are the same content envelope with a
different kind, so they run through the same few functions in Content.kt.
Twenty-three MRs, each written against an older main and green on its own
branch, had never existed together until they were merged in one evening.

The conflicts that produced were resolved mechanically — union a parameter
list, merge a condition — and that produces code which compiles and passes
while being wrong: a field on the wrong struct, an argument in the wrong
order, a term in the wrong condition. Three were caught by the compiler.
The ones the compiler cannot see are why calls stopped connecting.

And the gate could not see any of it. 489 unit tests stayed green through
every break, because they test units: "assemble produces this JSON" is a
unit test, "Android shows what iOS sent" is not.

versionCode 10 so it installs over the broken v9.

Nothing is lost — every MR is still a merged commit with its branch on the
remote, and re-lands in small batches once there is a test that actually
sends a message between two devices.
This commit is contained in:
Paul Hitt
2026-09-02 19:03:30 -04:00
parent 1f126f2cee
commit aebf41ed95
41 changed files with 67 additions and 5016 deletions
@@ -81,28 +81,6 @@ class AccountsClient(
*/
object SessionExpired : Failure("your session has expired — sign in again")
object HandleTaken : Failure("that handle is already taken")
/**
* 🔴 **A 409 is not always a taken handle, and saying so was wrong.**
* The relay answers 409 for a taken handle, a registered email, AND a
* handle too close to an existing one — three different problems with
* three different fixes. Collapsing them told somebody whose EMAIL was
* already registered to pick a different handle, which cannot work and
* gives them no way to discover why.
*
* The status is the transport's opinion; the `code` in the body is the
* relay's, and it is the one that means something.
*/
object EmailTaken : Failure("an account with that email already exists")
/** The handle folds onto an existing one — see the relay's
* `User.confusableSkeleton`. Changing a digit will not help. */
object HandleConfusable : Failure("that handle is too easily mistaken for another")
object DisposableEmail : Failure("that email provider isn't accepted")
object BadHandle : Failure("that handle isn't allowed")
object BadEmail : Failure("that doesn't look like an email address")
object WeakPassword : Failure("that passphrase is too short")
object RateLimited : Failure("too many attempts — wait a few minutes")
class Service(val status: Int) : Failure("the account service returned $status")
class Transport(cause: String) : Failure("couldn't reach the account service: $cause")
@@ -190,48 +168,13 @@ class AccountsClient(
401, 403 ->
throw if (carriesCredentials) Failure.BadCredentials
else Failure.SessionExpired
// 🔴 **Read the relay's own code, not just the status.** A
// 400 became a bare "the account service returned 400" and a
// 409 became "that handle is already taken" whatever the
// actual reason — so a weak passphrase, an unusable email and
// an already-registered address were all reported as something
// the person could not act on, or as the wrong problem
// entirely. The relay has always sent a specific code; nothing
// here ever read it.
409, 400 -> throw failureFor(code(text), status = it.code)
409 -> throw Failure.HandleTaken
429 -> throw Failure.RateLimited
else -> throw Failure.Service(it.code)
}
}
}
/**
* The relay's error code, or null when the body is not its error shape.
*
* ⚠️ Never throws. A body that cannot be parsed is a transport problem, not
* a reason to lose the status we already have.
*/
private fun code(body: String): String? = runCatching {
JSONObject(body).optString("code").takeIf { it.isNotEmpty() }
}.getOrNull()
/**
* ⚠️ Falls back to the STATUS when the code is unknown, so a refusal this
* build has never heard of still reports as a refusal rather than as a
* success or a crash. A relay newer than the app is the normal case, not
* the exception.
*/
private fun failureFor(code: String?, status: Int): Failure = when (code) {
"username_taken" -> Failure.HandleTaken
"email_taken" -> Failure.EmailTaken
"username_confusable" -> Failure.HandleConfusable
"disposable_email" -> Failure.DisposableEmail
"bad_username" -> Failure.BadHandle
"bad_email" -> Failure.BadEmail
"weak_password" -> Failure.WeakPassword
else -> if (status == 409) Failure.HandleTaken else Failure.Service(status)
}
private companion object {
val JSON = "application/json".toMediaType()
}
@@ -164,15 +164,6 @@ class Conversations(
}
/** What happened to one send. Partial success is the normal case, not an error. */
/**
* Whether this device refuses to send when it cannot seal (ios#16).
*
* ⚠️ Injected rather than read from preferences here: this module has no
* Android dependency. An unset value must read as FALSE — degrade and say
* so, rather than silently refusing to send on a fresh install.
*/
var requireSealed: () -> Boolean = { false }
data class Sent(
val delivered: List<String>,
val failed: Map<String, String>,
@@ -221,22 +212,10 @@ class Conversations(
* at the sender as an incoming one. The sender's own copy is written
* locally by the caller.
*/
/**
* @param messageId one id for the whole fan-out (§6.9). It is ONE message:
* a reply or a reaction from any member has to name the same thing, so
* per-recipient ids would make that impossible.
*
* ⚠️ Taken as a parameter rather than minted here, because the caller has
* to build the same envelope again for the outbox. Two constructions with
* two ids means a queued retry arrives as a *different* message from the
* one everybody else got.
*/
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long,
messageId: String? = null): Sent {
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent {
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
val body = Content.groupText(text, group.id, messageId = messageId,
groupName = vault.groupName(group.id))
val body = Content.groupText(text, group.id, groupName = vault.groupName(group.id))
for (member in group.memberIds) {
if (member == myUserId) continue
@@ -356,15 +335,6 @@ class Conversations(
* the viewer's device contacts that host directly.
*/
streamUrl: String? = null,
/**
* The group this story is addressed to (§6.24), if any.
*
* ⚠️ Names the AUDIENCE, not a destination thread. Receivers render it
* in the story surface; a story drawn into the group's conversation
* would be a story that is a message, and one that never expires out
* of the thread.
*/
groupId: String? = null,
): StoryPosted {
val sealed = Attachment.seal(data)
val id = relay.uploadAttachment(sealed.blob)
@@ -374,7 +344,7 @@ class Conversations(
duration = duration,
expires = System.currentTimeMillis() / 1000.0 + lifetimeSeconds,
)
val body = Content.attachment(ref, caption, groupId, streamUrl = streamUrl)
val body = Content.attachment(ref, caption, null, streamUrl = streamUrl)
var reached = 0
var missed = 0
@@ -694,14 +664,6 @@ class Conversations(
// cannot tell it from a photo (§14.4).
val sealed = peerToken != null || peerKey != null
// ios#16 — refuse rather than degrade, for the people who would rather
// a message did not go than go attributable.
//
// ⚠️ Nothing is spent by refusing here: `sealed` is false exactly when
// neither a capability nor a key was found, so `takePeerToken` above
// returned null and there is no token to hand back.
if (!sealed && requireSealed()) throw RelayException.SealingRequired()
val preludes = listOfNotNull(
ourDeliveryKeyEnvelopeFor(toUserId),
ourKeyLogHeadEnvelopeFor(toUserId),
@@ -34,38 +34,12 @@ class RelayAccounts(
val appPassword: String,
)
/**
* ⚠️ **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()
fun signUp(username: String, email: String, password: String, displayName: String? = null): Account =
account(post("account/signup", JSONObject()
.put("username", username)
.put("email", email)
.put("password", password)
.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)))
}
}
.put("display_name", displayName ?: username)))
/**
* ⚠️ The field is "username **or email**" and always has been. A client
@@ -116,17 +90,6 @@ class RelayAccounts(
post("account/reset/request", JSONObject().put("handle", handle))
}
/**
* Spend an email verification code.
*
* ⚠️ Unauthenticated, exactly like reset confirm: the code IS the proof.
* Requiring a signed-in session would mean somebody kept out *because* they
* are unverified could never verify.
*/
fun verifyEmail(token: String) {
post("account/verify", JSONObject().put("token", token))
}
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
post("account/reset/confirm", JSONObject()
.put("handle", handle)
@@ -165,33 +128,13 @@ class RelayAccounts(
// better than anything this layer could invent from a status code.
val message = runCatching { JSONObject(text).optString("message") }
.getOrNull().orEmpty().ifBlank { text }
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)
// 🔴 **A 403 is not an expired session.** 401 means the token
// was refused and signing in again is the fix. 403 means a
// RULE refused this — too many devices, a retired device, an
// unverified address — and the relay writes a sentence saying
// which. Reporting those as "your session expired, sign out and
// back in" sends somebody round a loop that cannot end: they
// sign in again, hit the same rule, and are told the same
// wrong thing. Observed tonight on a fresh install.
it.code == 401 -> RelayException.Unauthorized(message)
it.code == 403 && code == "email_unverified" ->
RelayException.EmailUnverified(message)
it.code == 403 -> RelayException.Http(403, message)
it.code == 404 -> RelayException.NotFound(path)
it.code == 409 -> RelayException.Http(409, message)
it.code == 429 ->
RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
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())
// The geo-gate, not the relay. Needs a sentence, not a number.
it.code == 451 -> RelayException.Unavailable(message)
451 -> RelayException.Unavailable(message)
else -> RelayException.Http(it.code, message)
}
}
@@ -339,33 +339,6 @@ class RelayClient(
* inside the ratchet; putting it anywhere the relay can read defeats the
* point of encrypting the blob.
*/
/**
* `POST /e2e/moderation/report` — file a report (§6.25).
*
* 🔴 **The one call that deliberately sends readable content to the
* server.** Everything else this client sends is sealed. A relay that
* cannot open a reported story cannot act on an accusation about it, so
* the reporter's own copy travels with the report — and the caller is
* required to have obtained consent naming that, before reaching here.
*/
fun submitReport(
about: Long,
kind: String,
reason: String,
content: String,
note: String,
) {
val body = JSONObject()
.put("about", about)
.put("kind", kind)
.put("reason", reason)
.put("content", content)
.put("note", note)
.toString()
request("POST", url("/e2e/moderation/report"), "/e2e/moderation/report",
body.toRequestBody(JSON)).use { it.close() }
}
fun uploadAttachment(blob: ByteArray): String =
request("POST", url("/e2e/attachments"), "/e2e/attachments", blob.toRequestBody(OCTETS))
.use { json(it).getString("id") }
@@ -823,15 +796,6 @@ class RelayClient(
throw when (code) {
401 -> RelayException.Unauthorized(readable)
// ⚠️ The relay's own code, not the status. A 403 is also "that
// device belongs to another account", and telling somebody to check
// their email for that would send them looking for a message that
// was never sent.
403 -> {
val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull()
if (relayCode == "email_unverified") RelayException.EmailUnverified(readable)
else RelayException.Http(code, readable)
}
404 -> RelayException.NotFound(path)
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
// The geo-gate, not the relay. Needs a sentence, not a number.
@@ -41,69 +41,11 @@ 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
* proved reachable yet.
*
* 🔴 **Its own type because everything else about it looks like a
* credential failure.** Without this it arrives as a bare 403 and
* [RelayAuth] logs "minting failed" before falling back to Basic — which
* the e2e routes refuse — so the person sees calls not working and an inbox
* that does nothing, and nothing anywhere says the remedy is a code sitting
* in their email.
*
* ⚠️ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
* comments, so a slash-star inside one opens a comment that never closes
* and takes the whole module down with ninety unrelated errors.
*/
class EmailUnverified(val body: String) : RelayException(
"this account's email address has not been confirmed yet")
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
/** A 2xx whose body was not what the route promised. */
class Malformed(message: String) : RelayException(message)
/**
* Sealing was required and this send could not be sealed (§14.5, ios#16).
*
* ⚠️ Its own type rather than a generic failure. The user turned this on
* deliberately and the message did not go for a reason they chose — a bare
* "couldn't send" reads as the app being broken, and the next thing
* somebody does is switch the setting off without knowing that is what
* they are doing.
*/
class SealingRequired : RelayException(
"Not sent. This message could not be sealed, so the server would have seen " +
"that you sent it — and you asked Flamenet to refuse rather than send " +
"anyway. It will seal again once they message you.")
}
/**
@@ -1,140 +0,0 @@
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) }
}
@@ -1,99 +0,0 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* A 403 that means "check your email", told apart from every other 403.
*
* 🔴 **Without this it is indistinguishable from a credential problem.** An
* unverified account arrives as a bare 403; `RelayAuth` logs "minting failed"
* and falls back to Basic, which the e2e routes refuse. The person then sees
* calls that do not work and an inbox that does nothing, and nothing anywhere
* says the remedy is a code sitting in their inbox.
*
* ⚠️ The status alone is not enough: 403 is also "that device belongs to
* another account". Telling somebody to check their email for that would send
* them looking for a message that was never sent — so the relay's own `code` is
* what decides, not the number.
*/
class EmailUnverifiedTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() {
server = MockWebServer()
server.start()
}
@AfterTest fun stop() {
server.shutdown()
}
private fun relay() = RelayClient(
server.url("/").toString(), username = "ada", appPassword = "credential")
private fun refuse(code: String?, status: Int = 403) {
val body = if (code == null) """{"message":"no"}"""
else """{"code":"$code","message":"Check your email and enter the code."}"""
server.enqueue(
MockResponse().setResponseCode(status)
.setHeader("Content-Type", "application/json")
.setBody(body)
)
}
@Test
fun `an unverified account is its own failure, not a generic 403`() {
refuse("email_unverified")
val failure = assertFailsWith<RelayException.EmailUnverified> {
relay().listDevices(1)
}
assertTrue(
failure.body.contains("code"),
"the relay's sentence should survive: ${failure.body}"
)
}
/**
* 🔴 The distinction that matters. A device conflict is also a 403 and must
* NOT be reported as an unverified address.
*/
@Test
fun `another 403 is not mistaken for an unverified address`() {
refuse("forbidden")
val failure = assertFailsWith<RelayException.Http> { relay().listDevices(1) }
assertEquals(403, failure.status)
}
/** ⚠️ A 403 with no code at all — an old relay, or a proxy — stays generic. */
@Test
fun `a 403 with no code stays generic`() {
refuse(null)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** ⚠️ And a body that is not JSON must not throw a parse error. */
@Test
fun `an unparseable 403 body does not crash`() {
server.enqueue(
MockResponse().setResponseCode(403)
.setHeader("Content-Type", "text/html")
.setBody("<html>a proxy wrote this</html>")
)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** The code is only meaningful on a 403 — the same string on a 500 is not. */
@Test
fun `the code does not change the meaning of other statuses`() {
refuse("email_unverified", status = 500)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
}
@@ -1,129 +0,0 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What a refused sign-up is reported as.
*
* 🔴 **A 409 is not always a taken handle, and this used to say it was.** The
* relay answers 409 for a taken handle, an already-registered email, and a
* handle too close to an existing one — three problems with three different
* fixes. Collapsing them told somebody whose EMAIL was registered to pick a
* different handle, which cannot work and leaves them no way to find out why.
*
* 400 was worse: every format refusal became "the account service returned
* 400", which is not a sentence anybody can act on.
*
* The status is the transport's opinion. The `code` in the body is the relay's,
* and it is the one that means something.
*/
class SignupFailureMappingTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() {
server = MockWebServer()
server.start()
}
@AfterTest fun stop() {
server.shutdown()
}
private fun client() = AccountsClient(server.url("/").toString())
/** Ask the server to refuse with a given code and status, and see what comes back. */
private fun refusalFor(code: String, status: Int): AccountsClient.Failure {
server.enqueue(
MockResponse().setResponseCode(status)
.setHeader("Content-Type", "application/json")
.setBody("""{"code":"$code","message":"refused"}""")
)
return try {
client().register("someone", "passphrase-long-enough")
throw AssertionError("the sign-up was not refused at all")
} catch (failure: AccountsClient.Failure) {
failure
}
}
@Test
fun `each refusal code maps to its own failure`() {
val cases = listOf(
Triple("username_taken", 409, AccountsClient.Failure.HandleTaken),
Triple("email_taken", 409, AccountsClient.Failure.EmailTaken),
Triple("username_confusable", 409, AccountsClient.Failure.HandleConfusable),
Triple("disposable_email", 400, AccountsClient.Failure.DisposableEmail),
Triple("bad_username", 400, AccountsClient.Failure.BadHandle),
Triple("bad_email", 400, AccountsClient.Failure.BadEmail),
Triple("weak_password", 400, AccountsClient.Failure.WeakPassword),
)
for ((code, status, expected) in cases) {
assertEquals(expected, refusalFor(code, status), "$code should map to $expected")
}
}
/**
* 🔴 The regression this whole change exists for. An already-registered
* email must NOT be reported as a taken handle — the person would change
* their handle, be refused again, and have no way to work out why.
*/
@Test
fun `an already registered email is not reported as a taken handle`() {
val failure = refusalFor("email_taken", 409)
assertEquals(AccountsClient.Failure.EmailTaken, failure)
assertTrue(
failure.message!!.contains("email"),
"the message should mention the email, not the handle: ${failure.message}"
)
}
/**
* ⚠️ A relay newer than the app is the normal case, not the exception. An
* unknown code must still refuse — falling through to success would create
* an account the server never made.
*/
@Test
fun `an unknown refusal code still refuses`() {
assertEquals(AccountsClient.Failure.HandleTaken, refusalFor("invented_next_year", 409))
val fourHundred = refusalFor("invented_next_year", 400)
assertTrue(
fourHundred is AccountsClient.Failure.Service,
"an unknown 400 should keep its status: $fourHundred"
)
}
/** ⚠️ A body that is not the relay's error shape must not throw a parse error. */
@Test
fun `an unparseable body falls back to the status`() {
server.enqueue(
MockResponse().setResponseCode(409)
.setHeader("Content-Type", "text/html")
.setBody("<html>a proxy wrote this</html>")
)
try {
client().register("someone", "passphrase-long-enough")
throw AssertionError("the sign-up was not refused")
} catch (failure: AccountsClient.Failure) {
assertEquals(AccountsClient.Failure.HandleTaken, failure)
}
}
/** Rate limiting and auth keep their existing meanings. */
@Test
fun `the other statuses are unchanged`() {
server.enqueue(MockResponse().setResponseCode(429))
try {
client().register("someone", "passphrase-long-enough")
throw AssertionError("not refused")
} catch (failure: AccountsClient.Failure) {
assertEquals(AccountsClient.Failure.RateLimited, failure)
}
}
}
@@ -1,271 +0,0 @@
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")
}
}
}
}