A settings screen for the bridge line, and a path for it to reach

Two gaps, and the second was the bigger one.

obfs4 was reachable from nothing. The socket-factory seam landed in !77,
but every client in the app takes `defaultHttpClient()`'s null default —
`RelayClient`, `MessengerApi`, `AccountsClient`, `RelayAccounts`,
`SignupProofOfWork`, `RelayDiscovery` and the background service, ten
sites in all, several from default parameters evaluated at
field-initialisation time. The transport had been built, tested and
proven against real lyrebird in both directions, and no user could
switch it on.

Threading a bridge through ten constructors would have worked until an
eleventh was added, and the eleventh would connect directly,
successfully, with nothing to show that it had. A missed site does not
fail; it silently does not protect. So the factory is installed
unconditionally and consults `Obfs4Routing` per socket. With no bridge it
delegates to the platform's own factory, which is what OkHttp would have
used anyway — nothing changes for the overwhelming majority who never set
one.

Pooled connections are the trap underneath that. OkHttp keeps a
connection alive across requests, so one opened through the old bridge
serves requests for minutes after the user switches — or after they turn
a bridge OFF, which is the dangerous direction. The pool is shared so
`Obfs4Routing.use` can empty it.

The other gap is the one flagged when the wiring merged: a malformed line
parses to nothing, nothing means no bridge, and no bridge is a direct
connection. So `BridgeLine.parse` reports which part is wrong, and
`Obfs4Settings` stores only what it accepted.

Java's decoder made that worse here than on iOS. `fromCert` appended
`==`, which THROWS for most truncation lengths — 20, 29, 30, 50 and 51
bytes among them — so a short paste came back as unreadable gibberish
while iOS, whose decoder is padding-lenient, called the same paste "cut
short". That is the likeliest real mistake by a distance: bridge lines
wrap in every chat app and terminal, and copying only the first line is
ordinary. Decoding the bare string works at every length and lets the
size check give the honest answer. Both forms produce identical bytes for
a real 52-byte cert, so nothing that worked before changes.

The messages are the same sentences iOS shows, pinned by a test. The two
apps are handed the same lines by the same operators, and one support
answer should cover both. None of them says obfs4, base64 or node id —
whoever reaches this screen was handed a line and told to paste it, and
an error in the protocol's vocabulary reads as "you are not qualified to
fix this".

`iat-mode` 1 and 2 are a caveat, not a rejection: they ask the client to
space its writes and the bridge does not enforce them, so such a line
connects and works. Refusing it would take a working bridge away from
somebody who has no other one.

The status line reads a handshake counter, never the stored preference. A
saved bridge that is not there leaves the app connecting directly, and
only a completed handshake can tell you otherwise — so a bridge that has
carried nothing says exactly that, and the words "protected", "secure",
"hidden" and "safe" are asserted absent from it.

Live interop re-run against real lyrebird after the factory change: both
directions still pass. 448 unit tests green, 145 in the app, and
reverting the unconditional factory reddens the routing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-06 15:12:12 -04:00
co-authored by Claude Opus 5
parent 11f0fe9fee
commit 6cd7a0d5dc
13 changed files with 1051 additions and 25 deletions
@@ -0,0 +1,58 @@
package io.flamenet.client
import io.flamenet.obfs4.Obfs4Bridge
import okhttp3.ConnectionPool
import java.util.concurrent.TimeUnit
/**
* Which bridge, if any, the app's HTTP clients route through.
*
* 🔴 **A holder rather than a constructor argument, because there are ten
* construction sites and no way to be sure of catching them all.** `RelayClient`,
* `MessengerApi`, `AccountsClient`, `RelayAccounts`, `SignupProofOfWork`,
* `RelayDiscovery` and the background service each build their own client, some
* from default parameters evaluated at field-initialisation time. Threading a
* bridge through every one would work until somebody adds an eleventh — and the
* eleventh would connect directly, successfully, with nothing to show that it
* had. A missed site here does not fail; it silently does not protect.
*
* ⚠️ **The socket factory is installed whether or not a bridge is set.** A
* client is built once and the setting changes later, so the factory has to be
* able to follow it. With no bridge it delegates to the platform's own factory,
* which is exactly what OkHttp would have used.
*/
object Obfs4Routing {
@Volatile
private var current: Obfs4Bridge? = null
/**
* One pool for every default client.
*
* 🔴 **Shared so it can be emptied.** OkHttp keeps connections alive across
* requests; a connection opened before the bridge changed still runs over
* the old transport, and would go on serving requests for minutes after the
* user switched bridges — or after they turned one OFF, which is the
* dangerous direction. Eviction on change is the only thing that stops that,
* and it needs a pool we hold.
*
* ⚠️ The idle window is short for the same reason a bridge that goes away
* must not leave requests queued behind a dead tunnel.
*/
internal val pool = ConnectionPool(8, 2, TimeUnit.MINUTES)
/** The bridge in force right now, consulted per socket. */
val bridge: Obfs4Bridge? get() = current
/**
* Route through [bridge] from now on, or directly when it is null.
*
* Existing pooled connections are dropped, because they are already
* committed to the previous transport.
*/
fun use(bridge: Obfs4Bridge?) {
if (bridge == current) return
current = bridge
pool.evictAll()
}
}
@@ -86,17 +86,22 @@ class RelayClient(
// sealed blobs and envelope traffic surviving on disk after the user
// deletes a chat is exactly what "deleted means deleted" forbids.
.cache(null)
.apply {
if (bridge != null) {
socketFactory(Obfs4SocketFactory(bridge))
// ⚠️ **Connections must not be pooled across a transport
// change**, and the handshake makes each one expensive to
// establish, so the pool is kept but the idle window is
// short: a bridge that goes away should not leave requests
// queued behind a dead tunnel for minutes.
connectionPool(okhttp3.ConnectionPool(4, 2, TimeUnit.MINUTES))
}
}
// 🔴 **Installed unconditionally, and that is the point.** The
// parameter above is for tests and interop, which pin one
// bridge. Everything the app builds passes nothing — so if the
// factory were only installed when a bridge was already known,
// the setting could never reach the ten sites that construct
// clients, several of them from default parameters evaluated
// before the user has touched anything. With no bridge in force
// the factory delegates to the platform's own, so this changes
// nothing for the overwhelming majority who never set one.
.socketFactory(Obfs4SocketFactory { bridge ?: Obfs4Routing.bridge })
// ⚠️ **Connections must not outlive a transport change**, and an
// obfs4 handshake makes each one expensive to establish, so the
// pool is shared — `Obfs4Routing.use` empties it — and its idle
// window is short: a bridge that goes away should not leave
// requests queued behind a dead tunnel for minutes.
.connectionPool(Obfs4Routing.pool)
.build()
}
@@ -0,0 +1,137 @@
package io.flamenet.client
import io.flamenet.obfs4.Obfs4Bridge
import io.flamenet.obfs4.Obfs4Socket
import io.flamenet.obfs4.Obfs4SocketFactory
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The seam that carries the setting to the ten places that build HTTP clients.
*
* 🔴 **The bug this is for: obfs4 was reachable from nothing.** Every client in
* the app took `defaultHttpClient()`'s null default, so the transport that had
* been built, tested and proven against lyrebird was not on any code path a
* user could reach. Threading a bridge through each construction site would
* have worked until the eleventh was added — and the eleventh would connect
* directly, successfully, with nothing to show for it.
*/
class Obfs4RoutingTest {
private fun bridge(host: String = "198.51.100.7", port: Int = 4444) =
Obfs4Bridge(host, port, ByteArray(20) { 1 }, ByteArray(32) { 2 })
@AfterTest
fun clearRouting() = Obfs4Routing.use(null)
@Test
fun `no bridge means no bridge`() {
Obfs4Routing.use(null)
assertNull(Obfs4Routing.bridge)
}
@Test
fun `the bridge in force is the last one set`() {
Obfs4Routing.use(bridge(host = "198.51.100.7"))
assertEquals("198.51.100.7", Obfs4Routing.bridge?.host)
Obfs4Routing.use(bridge(host = "203.0.113.9", port = 9001))
assertEquals("203.0.113.9", Obfs4Routing.bridge?.host)
assertEquals(9001, Obfs4Routing.bridge?.port)
}
/**
* 🔴 **The factory is installed whether or not a bridge is set, so the
* no-bridge path has to be an ordinary socket.** If it were an
* `Obfs4Socket` pointed at nothing, installing the factory would break the
* app for the overwhelming majority who never configure a bridge — and it
* would break it at connect time, on the network, not at build time.
*/
@Test
fun `with no bridge the factory hands out a plain socket`() {
Obfs4Routing.use(null)
val factory = Obfs4SocketFactory { Obfs4Routing.bridge }
val socket = factory.createSocket()
assertFalse(socket is Obfs4Socket, "a plain connection must not go through obfs4")
socket.close()
}
/** And with one set, the same factory hands out the transport. */
@Test
fun `with a bridge the same factory hands out an obfs4 socket`() {
Obfs4Routing.use(bridge())
val factory = Obfs4SocketFactory { Obfs4Routing.bridge }
val socket = factory.createSocket()
assertTrue(socket is Obfs4Socket, "a configured bridge must be used")
socket.close()
}
/**
* 🔴 **The factory reads the setting per socket, not once at construction.**
* A client is built long before the user opens settings; a factory that
* captured the bridge at build time would keep using whatever was in force
* then — including nothing at all — for the life of the process.
*/
@Test
fun `a factory built before the bridge was set still picks it up`() {
Obfs4Routing.use(null)
val factory = Obfs4SocketFactory { Obfs4Routing.bridge }
factory.createSocket().use {
assertFalse(it is Obfs4Socket)
}
Obfs4Routing.use(bridge())
factory.createSocket().use {
assertTrue(it is Obfs4Socket, "the factory did not follow the setting")
}
}
/**
* ⚠️ The default client must carry the routing factory, or none of the
* above reaches anything the app actually uses.
*/
@Test
fun `the default http client routes through the holder`() {
Obfs4Routing.use(bridge())
val client = RelayClient.defaultHttpClient()
val socket = client.socketFactory.createSocket()
assertTrue(socket is Obfs4Socket,
"the app's default client ignored the configured bridge")
socket.close()
}
/** And with nothing configured it is a plain connection, as it always was. */
@Test
fun `the default http client connects directly when nothing is configured`() {
Obfs4Routing.use(null)
val socket = RelayClient.defaultHttpClient().socketFactory.createSocket()
assertFalse(socket is Obfs4Socket)
socket.close()
}
/**
* 🔴 **Pooled connections outlive the setting that created them.** OkHttp
* keeps a connection alive across requests, so one opened through the old
* bridge would go on serving requests for minutes after the user switched —
* or after they turned a bridge OFF, which is the dangerous direction.
*/
@Test
fun `changing the bridge empties the connection pool`() {
Obfs4Routing.use(bridge(host = "198.51.100.7"))
Obfs4Routing.use(bridge(host = "203.0.113.9"))
assertEquals(0, Obfs4Routing.pool.connectionCount(),
"connections committed to the previous transport were kept")
}
/** ⚠️ Setting the same bridge twice must not tear down live connections. */
@Test
fun `setting the same bridge again is not a change`() {
val one = bridge()
Obfs4Routing.use(one)
val before = Obfs4Routing.bridge
Obfs4Routing.use(bridge())
assertEquals(before, Obfs4Routing.bridge)
}
}