Keep the token when only the proof was spent, and review keys at the call

Two things the iPhone got today and this did not.

A proof of possession is single use, and the request that spends one is
frequently not a request this code made: OkHttp retries across a
connection failure with the headers it already built, which is what a
phone moving between wi-fi and mobile data produces. The relay refuses
the replay, correctly, and now says so with its own code rather than a
bare "unauthorized". Reading that as a token problem meant discarding a
working token and minting another before carrying on, and during a call
that lands in front of the signalling. The request is signed again
either way; only the token is kept now. A relay too old to name the code
still says so in words, so this works against production as it stands.

And ios#73. A call refused by the trust gate already named the reason
and already offered a button. The button opened the conversation, which
is the same gap one screen along: the reason the conversation banner
does not cover this case is that a call placed from the Calls tab, or
answered from a notification, never passes through a conversation.
Sending somebody to one to find out why their call failed is the
complaint rather than the cure.

So the review is a dialog at the failure. It carries both readings the
chat banner carries, the ordinary one and the one that matters, and it
puts the safety number on screen rather than behind another tap, because
comparing the number is what actually settles the question.

The old test said accepting must not be offered here, on the argument
that a hurried decision is better made elsewhere. The issue argues the
other way and is right: an extra screen in front of the same tap slows
nobody down, it just costs somebody the explanation. The requirement
moves rather than disappears, from "no accept button" to "no accept
button without the sentence saying what accepting costs".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-10 09:51:55 -04:00
co-authored by Claude Opus 5
parent 6dd8633b08
commit d735b79b86
5 changed files with 290 additions and 41 deletions
@@ -785,11 +785,31 @@ class RelayClient(
): Response {
val first = client.newCall(build(method, url, logicalPath, body)).execute()
if (first.code != 401 || auth == null) return checked(first, logicalPath)
first.close()
auth?.invalidate()
// Read before closing: the reason is in the body, and one of the
// reasons costs a whole round trip to act on wrongly.
val spent = first.use { spentProof(it) }
// A proof is single use, and the request that spent one is often not a
// request this code made: OkHttp retries across a connection failure
// with the headers it already built, which is what a phone moving
// between wi-fi and mobile data produces. The relay refuses the replay,
// correctly. Throwing the token away for it means minting another
// before carrying on, and during a call that lands in front of the
// signalling. `build` signs afresh either way; only the token differs.
if (!spent) auth?.invalidate()
return checked(client.newCall(build(method, url, logicalPath, body)).execute(), logicalPath)
}
/**
* Whether a 401 says only that the proof of possession had already been
* spent. The relay names it; one too old to name it still says so in words.
*/
private fun spentProof(response: Response): Boolean {
val body = runCatching { response.body?.string() }.getOrNull() ?: return false
val parsed = runCatching { JSONObject(body) }.getOrNull() ?: return false
if (parsed.optString("code") == "proof_replayed") return true
return parsed.optString("message").contains("already used", ignoreCase = true)
}
private fun build(
method: String,
url: HttpUrl,
@@ -213,6 +213,83 @@ class RelayClientTest {
assertEquals("Bearer tok-2", retried.getHeader("Authorization"))
}
/**
* A spent proof is not a token problem. The request that spent one is often
* not a request this code made: OkHttp retries across a connection failure
* with the headers it already built, which is exactly what a phone moving
* between wi-fi and mobile data produces. Minting a replacement token for
* that puts a whole round trip in front of whatever was happening, and
* during a call that is in front of the signalling.
*/
@Test
fun `a spent proof is signed again and costs no new token`() {
val (client, _) = authedClient()
server.enqueue(
MockResponse()
.setResponseCode(401)
.setHeader("Content-Type", "application/json")
.setBody(
"""{"code":"proof_replayed","message":"proof-of-possession signature """ +
"""already used; sign the request again, the token is fine"}""",
),
)
server.enqueue(json("""{"size":4}"""))
assertEquals(4, client.logHead().getInt("size"))
next() // the first mint
val rejected = next()
val retried = next()
// No mint between them: three requests in total, not four.
assertEquals(3, server.requestCount)
assertEquals("Bearer tok-1", rejected.getHeader("Authorization"))
assertEquals("Bearer tok-1", retried.getHeader("Authorization"))
// The proof itself must be new, or the relay refuses the retry too.
assertNotEquals(
rejected.getHeader("X-FN-Signature"),
retried.getHeader("X-FN-Signature"),
)
}
/** A relay too old to name the code still says so in words. */
@Test
fun `an older relay is understood from its wording`() {
val (client, _) = authedClient()
server.enqueue(
MockResponse()
.setResponseCode(401)
.setHeader("Content-Type", "application/json")
.setBody(
"""{"code":"unauthorized","message":"proof-of-possession """ +
"""signature already used"}""",
),
)
server.enqueue(json("""{"size":4}"""))
assertEquals(4, client.logHead().getInt("size"))
assertEquals(3, server.requestCount)
}
/** Any OTHER 401 still buys the token retry it always had. */
@Test
fun `an expired token is still replaced`() {
val (client, _) = authedClient()
server.enqueue(
MockResponse()
.setResponseCode(401)
.setHeader("Content-Type", "application/json")
.setBody("""{"code":"unauthorized","message":"token expired"}"""),
)
server.enqueue(json("""{"token":"tok-2","expires_in":300}"""))
server.enqueue(json("""{"size":4}"""))
assertEquals(4, client.logHead().getInt("size"))
next() // the first mint
next() // the rejected call
assertEquals("/account/token", next().path)
assertEquals("Bearer tok-2", next().getHeader("Authorization"))
}
@Test
fun `a persistent 401 gives up rather than looping`() {
val (client, _) = authedClient()