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
+6
View File
@@ -171,6 +171,12 @@ android {
dependencies {
implementation(project(":engine"))
implementation(project(":client"))
// ⚠️ A direct dependency rather than leaning on `:client` to re-export it.
// The settings screen reads bridge lines and the handshake counter itself,
// so the app genuinely uses these types; borrowing them through another
// module's classpath would make `:client` unable to change its own
// dependencies without breaking a screen that never mentioned it.
implementation(project(":obfs4"))
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
@@ -800,6 +800,13 @@ fun ColumnScope.AccountScreen(
SectionBand()
SettingsSection { HistoryBackupSection(model) }
// ⚠️ Sits with the other settings and not under anything privacy-shaped.
// A bridge is about REACHING the relay on a hostile network, not hiding
// from it — filing it as privacy would make the claim the section itself
// is careful not to make.
SectionBand()
SettingsSection { NetworkSection() }
SectionBand()
SettingsSection { ParentalControlsSection() }
@@ -48,6 +48,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
/** Photo quality and link-preview preferences. */
private val mediaSettings = MediaSettings(app)
/**
* The bridge, if this phone has one.
*
* 🔴 **Applied in the field initialiser, not in `init`.** `restore()` runs
* from `init` and reaches the network almost immediately; on a network that
* blocks the relay, a first request that went out directly is a failed
* launch rather than a slow one. A field initialiser runs before the `init`
* block, which is early enough.
*/
private val obfs4Settings = Obfs4Settings(app).also { it.applyAtStartup() }
sealed interface Phase {
/** Reading the vault. Brief, but it decides which screen is first. */
data object Starting : Phase
@@ -0,0 +1,196 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.flamenet.client.Obfs4Routing
import io.flamenet.obfs4.BridgeLine
import io.flamenet.obfs4.BridgeLineResult
import io.flamenet.obfs4.Obfs4Stats
/**
* Reaching Flamenet on a network that blocks it.
*
* 🔴 **This screen exists because a bad line used to be silent.** A line that
* would not parse became "no bridge", and "no bridge" is a direct connection —
* so somebody who pasted a bridge *because* direct does not work would have
* been put straight back on the path they were escaping, with the setting still
* looking configured. Nothing is stored here that could not be used, and what
* is shown afterwards is whether a handshake has actually completed, not what
* is written in preferences.
*
* ⚠️ **Every word has to stay honest about what this protects against.** A
* bridge hides traffic from whoever is between the phone and the bridge. The
* bridge then proxies to the relay, which sees exactly what it saw before.
*/
@Composable
fun ColumnScope.NetworkSection() {
val context = LocalContext.current
val settings = remember { Obfs4Settings(context) }
var draft by remember { mutableStateOf(settings.bridgeLine ?: "") }
var stored by remember { mutableStateOf(settings.bridgeLine) }
// Bumped on save/remove so the live status below re-reads.
var tick by remember { mutableIntStateOf(0) }
@Suppress("UNUSED_EXPRESSION") tick
val parsed = if (draft.isBlank()) null else BridgeLine.parse(draft)
val isUnchanged = draft.trim() == (stored ?: "")
Text("Network", style = MaterialTheme.typography.titleMedium)
// 🔴 Reads the handshake counter and the bridge in force, never the stored
// line. The two disagree exactly when it matters — a saved bridge that is
// not there leaves the app connecting directly, and only this says so.
Text(
statusLine(),
fontSize = 13.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("obfs4-status"),
)
Spacer(Modifier.height(Space.md))
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("obfs4 198.51.100.7:4444 …", fontSize = 13.sp) },
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
minLines = 2,
maxLines = 6,
modifier = Modifier.fillMaxWidth().testTag("obfs4-bridge-line"),
)
// ⚠️ Under the field and always in the same place, so it reads as feedback
// on what was typed rather than as a warning from somewhere else.
when (parsed) {
is BridgeLineResult.Bad -> Text(
parsed.problem.message,
fontSize = 12.sp,
color = Palette.Danger,
modifier = Modifier.testTag("obfs4-problem"),
)
is BridgeLineResult.Ok -> {
Text(
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("obfs4-parsed"),
)
parsed.caveat?.let {
Text(
it,
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("obfs4-caveat"),
)
}
}
null -> Unit
}
Spacer(Modifier.height(Space.sm))
KitPrimaryButton(
text = "Save this bridge",
onClick = {
// ⚠️ Goes through `save`, which parses again and stores only what it
// could use. The button being enabled is a convenience; the refusal
// lives one layer down, where nothing can route around it.
if (settings.save(draft) is BridgeLineResult.Ok) {
stored = settings.bridgeLine
tick++
}
},
enabled = parsed is BridgeLineResult.Ok && !isUnchanged,
modifier = Modifier.fillMaxWidth(),
tag = "obfs4-save",
)
if (stored != null) {
Spacer(Modifier.height(Space.sm))
KitSecondaryButton(
text = "Stop using a bridge",
onClick = {
settings.clear()
draft = ""
stored = null
tick++
},
modifier = Modifier.fillMaxWidth(),
tag = "obfs4-remove",
)
}
Spacer(Modifier.height(Space.sm))
Text(
"Paste the line a bridge provider gave you. It's one long line that starts with " +
"obfs4 — copy all of it, including the part after cert=.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
Text(EXPLANATION, fontSize = 12.sp, color = Palette.SecondaryText)
}
/**
* What is actually happening, as opposed to what is stored.
*
* 🔴 **Lifted out of the Composable so it can be asserted.** A string built
* inline in a `Text(...)` is a string nothing can test, which is how a screen
* ends up quietly claiming protection it does not have. The same lesson the
* `registeredLine` note records, applied before it bites rather than after.
*/
internal fun statusLine(
bridge: io.flamenet.obfs4.Obfs4Bridge? = Obfs4Routing.bridge,
handshakes: Int = Obfs4Stats.completedHandshakes(),
): String = when {
bridge == null ->
"Connecting directly to your Flamenet server. That's the right setting almost " +
"everywhere."
handshakes > 0 ->
"Going through ${bridge.host}:${bridge.port}. Traffic to your server is wrapped so " +
"that whoever carries it can't tell what it is — your server still sees what it " +
"always did."
// ⚠️ Not "protected". A saved bridge that has never completed a handshake
// has carried nothing, and saying otherwise is the exact claim this screen
// was built to stop making.
else ->
"Bridge saved: ${bridge.host}:${bridge.port}. Nothing has gone through it yet — the " +
"app will try the next time it goes online."
}
/**
* 🔴 **Says what it does not protect, in the same breath as what it does.** The
* failure mode for this feature is somebody believing it hides them from their
* server, behaving accordingly, and being wrong.
*/
private const val EXPLANATION =
"Some networks block Flamenet outright, or recognise it and slow it down. A bridge is a " +
"machine somewhere else that your app talks to instead, in a way that doesn't look " +
"like anything in particular — not like Flamenet, and not like a video call or a web " +
"page either. The bridge passes your traffic on to your server.\n\n" +
"Two things worth being clear about. Your server sees exactly what it saw before, so " +
"this doesn't hide anything from whoever runs it. And whoever runs the bridge can see " +
"that you're connecting to them, though not what you're saying — your messages stay " +
"end-to-end encrypted the whole way, as they always are.\n\n" +
"If Flamenet already works where you are, you don't need this."
@@ -0,0 +1,95 @@
package io.flamenet.messenger.app
import android.content.Context
import io.flamenet.client.Obfs4Routing
import io.flamenet.obfs4.BridgeLine
import io.flamenet.obfs4.BridgeLineResult
/**
* The bridge line this phone uses, if any.
*
* 🔴 **Nothing is stored that [BridgeLine.parse] rejected.** A line that will
* not parse becomes "no bridge", and "no bridge" is a direct connection — so
* storing one would put the person who pasted it back on the exact path they
* were trying to leave, while the screen went on showing their line. The screen
* refuses it instead and says which part is wrong.
*
* ⚠️ Ordinary SharedPreferences, not the vault. A bridge line is not a secret:
* operators publish them, and it has to be readable on a path that runs before
* the encrypted store is open — the app needs its transport before it can sign
* in.
*
* ⚠️ **This hides traffic from the NETWORK, not from us.** The bridge proxies
* to the relay, which still sees everything it saw before. No copy anywhere may
* imply otherwise.
*/
class Obfs4Settings(context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* The stored line, exactly as it was pasted.
*
* ⚠️ Kept as the operator's own line rather than as parsed fields, so it can
* be shown back unchanged. A round trip through separate fields loses the
* fingerprint and makes a typo unrecoverable.
*/
var bridgeLine: String?
get() = prefs.getString(BRIDGE_LINE, null)
private set(value) {
prefs.edit().apply {
if (value.isNullOrBlank()) remove(BRIDGE_LINE) else putString(BRIDGE_LINE, value)
}.apply()
}
/**
* Stores a line and starts using it, or reports why it cannot be used.
*
* 🔴 The only way in. A setter that took any string would be the silent
* fallback all over again.
*/
fun save(line: String): BridgeLineResult {
val trimmed = line.trim()
val result = BridgeLine.parse(trimmed)
if (result is BridgeLineResult.Ok) {
bridgeLine = trimmed
Obfs4Routing.use(result.bridge)
}
return result
}
/** Stop using a bridge, and drop the connections that were using one. */
fun clear() {
bridgeLine = null
Obfs4Routing.use(null)
}
/**
* Puts the stored line into force. Called once at startup.
*
* ⚠️ **Before any client is built.** The default HTTP client consults
* [Obfs4Routing] per socket rather than at construction, so a late call
* still takes effect — but a request that went out first would have gone
* out directly, and on a network that blocks the relay that is a failed
* launch rather than a slow one.
*
* ⚠️ A stored line that no longer parses is discarded rather than kept.
* Leaving it would show a bridge on the settings screen that nothing could
* use.
*/
fun applyAtStartup() {
val line = bridgeLine ?: return
when (val result = BridgeLine.parse(line)) {
is BridgeLineResult.Ok -> Obfs4Routing.use(result.bridge)
is BridgeLineResult.Bad -> {
bridgeLine = null
Obfs4Routing.use(null)
}
}
}
private companion object {
const val PREFS = "flamenet.obfs4"
const val BRIDGE_LINE = "bridge-line"
}
}
@@ -0,0 +1,62 @@
package io.flamenet.messenger.app
import io.flamenet.obfs4.Obfs4Bridge
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* What the Network section says is happening.
*
* 🔴 **A settings screen that reads the stored line and calls it a status is
* the failure this feature is built around.** A saved bridge that is not there
* leaves the app connecting directly; the preference cannot tell you that and
* a completed handshake can. `statusLine` is lifted out of the Composable
* precisely so this can be asserted — a string built inline in a `Text(...)` is
* a string nothing can test.
*/
class NetworkStatusLineTest {
private val bridge = Obfs4Bridge("198.51.100.7", 4444, ByteArray(20), ByteArray(32))
@Test
fun `with no bridge it says the connection is direct`() {
val line = statusLine(bridge = null, handshakes = 0)
assertTrue(line.contains("directly"), line)
}
/**
* 🔴 **The load-bearing case.** A bridge is saved and nothing has gone
* through it. Reporting that as protection is exactly the claim the screen
* exists to stop making.
*/
@Test
fun `a saved bridge that has carried nothing does not claim to be working`() {
val line = statusLine(bridge = bridge, handshakes = 0)
assertTrue(line.contains("Nothing has gone through it yet"), line)
assertFalse(line.contains("Going through"), line)
for (word in listOf("protected", "secure", "hidden", "safe")) {
assertFalse(line.lowercase().contains(word), "\"$word\" in: $line")
}
}
@Test
fun `once a handshake has completed it says so, with the address`() {
val line = statusLine(bridge = bridge, handshakes = 1)
assertTrue(line.contains("Going through 198.51.100.7:4444"), line)
}
/**
* ⚠️ Every state has to repeat that the relay is unaffected. Somebody who
* reads "wrapped so nobody can tell what it is" and stops there has been
* told a true thing that leads to a false conclusion.
*/
@Test
fun `the working state does not imply anything is hidden from the server`() {
val line = statusLine(bridge = bridge, handshakes = 3)
assertTrue(
line.contains("your server still sees what it always did", ignoreCase = true),
"the working state must say what it does NOT hide: $line",
)
}
}
@@ -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)
}
}
@@ -0,0 +1,170 @@
package io.flamenet.obfs4
/**
* Reading a bridge line the way a person actually pastes one.
*
* 🔴 **The reason this exists: a wrong line used to be indistinguishable from
* no line.** Nothing carried a bridge at all on Android — every client took the
* default `null` — and the moment one does, the failure mode arrives with it:
* a line that will not parse becomes "no bridge", and "no bridge" is a DIRECT
* connection. That puts the one person who pasted a bridge because direct does
* not work back on the exact path they were trying to leave.
*
* ⚠️ **The messages here are the same sentences iOS shows.** They are a pair on
* purpose — the two apps are handed the same lines by the same operators, and
* somebody comparing two phones, or one support answer covering both, should
* not meet two different vocabularies for one mistake. `Obfs4Settings.Problem`
* in the iOS repo is the other half.
*/
sealed interface BridgeLineProblem {
/**
* Written for the person who pasted the line, not for us.
*
* ⚠️ **Never names obfs4, cert, base64 or a node id.** Somebody reaching
* this screen was handed a line by an operator and told to paste it; the
* vocabulary of the protocol is not theirs, and an error written in it
* reads as "you are not qualified to fix this".
*/
val message: String
object Empty : BridgeLineProblem {
override val message = "Paste the line your bridge provider gave you."
}
object NoAddress : BridgeLineProblem {
override val message =
"This doesn't have an address in it. A bridge line has one near the " +
"start, like 198.51.100.7:4444."
}
data class BadPort(val text: String) : BridgeLineProblem {
override val message =
"\"$text\" isn't a port number. It should be digits, after the colon " +
"in the address."
}
object NoCert : BridgeLineProblem {
override val message =
"The part starting cert= is missing. That's the piece that proves you're " +
"talking to the right bridge, so it's worth going back for the whole line."
}
object CertNotReadable : BridgeLineProblem {
override val message =
"The part after cert= has something in it that doesn't belong — often a " +
"stray space or a line break picked up while copying. Try copying it again."
}
/**
* The decoded `cert=`, which must be 52 bytes: 20 of node id and 32 of key.
*
* 🔴 The likeliest real failure by a distance, and the one worth spending
* words on. A bridge line is long, and every chat app and terminal wraps
* it — so a paste that stops at the wrap is the ordinary mistake, not an
* exotic one, and it must not be reported as gibberish.
*/
data class CertWrongLength(val got: Int) : BridgeLineProblem {
override val message =
if (got < CERT_BYTES) {
"This line looks cut short. They're long enough that most apps wrap them " +
"onto a second line, and it's easy to copy only the first — check " +
"you've got all of it."
} else {
"There's more after cert= than belongs there. Something extra was " +
"probably picked up while copying."
}
}
}
/** What reading a line produced. */
sealed interface BridgeLineResult {
/**
* @param caveat something true about a line that WORKS. Kept apart from
* [BridgeLineProblem] deliberately: reporting it as an error would push
* somebody to go and find a different bridge they do not have, to fix
* something that is not broken.
*/
data class Ok(val bridge: Obfs4Bridge, val caveat: String? = null) : BridgeLineResult
data class Bad(val problem: BridgeLineProblem) : BridgeLineResult
}
/** 20 bytes of node id followed by a 32-byte identity key. */
private const val CERT_BYTES = 52
object BridgeLine {
/**
* Reads a bridge line, saying what is wrong when it cannot.
*
* Tolerant of the leading `obfs4` and of the fingerprint, both of which
* lyrebird includes and neither of which this client needs — the `cert=`
* carries the node id and key. Unknown `key=value` fields are ignored, since
* real lines carry several.
*/
fun parse(line: String): BridgeLineResult {
val fields = line.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
if (fields.isEmpty()) return BridgeLineResult.Bad(BridgeLineProblem.Empty)
val address = fields.firstOrNull { it.contains(":") && !it.contains("=") }
?: return BridgeLineResult.Bad(BridgeLineProblem.NoAddress)
// ⚠️ Split from the LAST colon, so a bracketed IPv6 literal keeps its
// own colons and only the port comes off the end.
val colon = address.lastIndexOf(':')
val host = address.substring(0, colon)
val portText = address.substring(colon + 1)
if (host.isEmpty()) return BridgeLineResult.Bad(BridgeLineProblem.NoAddress)
val port = portText.toIntOrNull()
if (port == null || port !in 1..65535) {
return BridgeLineResult.Bad(BridgeLineProblem.BadPort(portText))
}
val cert = fields.firstOrNull { it.startsWith("cert=") }
?.removePrefix("cert=")
?: return BridgeLineResult.Bad(BridgeLineProblem.NoCert)
val raw = decodeCert(cert)
?: return BridgeLineResult.Bad(BridgeLineProblem.CertNotReadable)
if (raw.size != CERT_BYTES) {
return BridgeLineResult.Bad(BridgeLineProblem.CertWrongLength(raw.size))
}
return BridgeLineResult.Ok(
Obfs4Bridge(host, port, raw.copyOfRange(0, 20), raw.copyOfRange(20, 52)),
caveat = caveatFor(fields),
)
}
/**
* 🔴 **Decodes the bare string, never `cert + "=="`.**
*
* Java's decoder is strict about padding where Foundation's is not, and the
* difference lands precisely on the mistake people actually make. Appending
* `==` to a truncated cert throws for most cut positions — 20, 29, 30, 50
* and 51 bytes among them — so the same short paste that iOS reports as
* "looks cut short" would have come back here as unreadable gibberish. The
* bare form decodes at every length, which lets the length check below give
* the honest answer on both platforms.
*/
private fun decodeCert(cert: String): ByteArray? = runCatching {
java.util.Base64.getDecoder().decode(cert.trimEnd('='))
}.getOrNull()
/**
* ⚠️ **An unsupported `iat-mode` is a caveat, not a rejection.** Modes 1 and
* 2 ask the CLIENT to space its writes out; the bridge does not enforce
* them, so such a line connects and works — it simply does not get the
* timing obfuscation its operator intended. Refusing it would take a working
* bridge away from somebody who has no other one.
*/
private fun caveatFor(fields: List<String>): String? {
val mode = fields.firstOrNull { it.startsWith("iat-mode=") }
?.removePrefix("iat-mode=") ?: return null
if (mode == "0") return null
return "This bridge asks for extra timing camouflage (iat-mode $mode) that this " +
"app doesn't do yet. It will still connect, and the traffic still doesn't " +
"look like anything in particular — the gaps between messages just aren't " +
"disguised as well."
}
}
@@ -36,10 +36,22 @@ data class Obfs4Bridge(
companion object {
/**
* `cert=` is base64 of `nodeId ‖ identityPublic` with its trailing `==`
* stripped — lyrebird re-appends it, so this must too.
* stripped by lyrebird.
*
* 🔴 **Decodes the bare string rather than re-appending `==`.** Java's
* decoder is strict about padding, and appending `==` to anything but a
* full-length cert throws for most lengths — 20, 29, 30, 50 and 51 bytes
* among them. That turned a SHORT paste, which is the ordinary mistake,
* into an exception carrying nothing anyone could act on. Decoding the
* bare form succeeds at every length and lets the size check below say
* what is actually wrong. Both forms give identical bytes for a real
* 52-byte cert, so nothing that worked before changes.
*
* ⚠️ For anything user-facing use [BridgeLine.parse] instead, which
* reports the reason rather than throwing.
*/
fun fromCert(host: String, port: Int, cert: String): Obfs4Bridge {
val raw = java.util.Base64.getDecoder().decode(cert + "==")
val raw = java.util.Base64.getDecoder().decode(cert.trimEnd('='))
require(raw.size == 52) { "a cert decodes to 52 bytes, not ${raw.size}" }
return Obfs4Bridge(host, port, raw.copyOfRange(0, 20), raw.copyOfRange(20, 52))
}
@@ -119,6 +131,11 @@ class Obfs4Socket(private val bridge: Obfs4Bridge) : Socket() {
// the frame decoder immediately.
pending = dataPlane.receive(session.leftover, isClient = true)
plane = dataPlane
// 🔴 Counted at the ONLY point that proves the transport carried
// something. A settings screen reading the stored line would report
// protection whether or not a single byte ever went through a bridge;
// this is the difference between the preference and the fact.
Obfs4Stats.recordHandshake()
}
override fun getOutputStream(): OutputStream {
@@ -189,25 +206,47 @@ class Obfs4HandshakeRefused : java.io.IOException(
* returning a plain socket means some code path silently bypasses the transport
* — the traffic still works, which is exactly why nobody would notice.
*/
class Obfs4SocketFactory(private val bridge: Obfs4Bridge) : SocketFactory() {
override fun createSocket(): Socket = Obfs4Socket(bridge)
class Obfs4SocketFactory(private val bridgeFor: () -> Obfs4Bridge?) : SocketFactory() {
/** A factory pinned to one bridge, as the interop tests use it. */
constructor(bridge: Obfs4Bridge) : this({ bridge })
/**
* 🔴 **`null` means a PLAIN socket, not a failure.** The factory is
* installed on the app's HTTP client whether or not a bridge is configured,
* because a client is built once and a setting changes later — so the
* no-bridge path has to be the ordinary direct connection, byte for byte
* what OkHttp would have done on its own. Delegating to
* `SocketFactory.getDefault()` rather than constructing a `Socket` by hand
* is what guarantees that.
*/
private fun plain(): SocketFactory = getDefault()
override fun createSocket(): Socket =
bridgeFor()?.let { Obfs4Socket(it) } ?: plain().createSocket()
override fun createSocket(host: String, port: Int): Socket =
Obfs4Socket(bridge).apply { connect(InetSocketAddress(host, port)) }
bridgeFor()?.let { Obfs4Socket(it).apply { connect(InetSocketAddress(host, port)) } }
?: plain().createSocket(host, port)
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket =
Obfs4Socket(bridge).apply {
bind(InetSocketAddress(localHost, localPort))
connect(InetSocketAddress(host, port))
}
bridgeFor()?.let {
Obfs4Socket(it).apply {
bind(InetSocketAddress(localHost, localPort))
connect(InetSocketAddress(host, port))
}
} ?: plain().createSocket(host, port, localHost, localPort)
override fun createSocket(host: InetAddress, port: Int): Socket =
Obfs4Socket(bridge).apply { connect(InetSocketAddress(host, port)) }
bridgeFor()?.let { Obfs4Socket(it).apply { connect(InetSocketAddress(host, port)) } }
?: plain().createSocket(host, port)
override fun createSocket(
address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int,
): Socket = Obfs4Socket(bridge).apply {
bind(InetSocketAddress(localAddress, localPort))
connect(InetSocketAddress(address, port))
}
): Socket = bridgeFor()?.let {
Obfs4Socket(it).apply {
bind(InetSocketAddress(localAddress, localPort))
connect(InetSocketAddress(address, port))
}
} ?: plain().createSocket(address, port, localAddress, localPort)
}
@@ -0,0 +1,46 @@
package io.flamenet.obfs4
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* Whether the transport has actually carried anything.
*
* 🔴 **Because a settings screen must not read the preference and call it a
* status.** A saved bridge line and a working bridge are different facts, and
* the gap between them is where this whole feature fails quietly: a line that
* parses but points at nothing leaves the app connecting directly, with the
* settings screen still showing the line. Only a completed handshake proves
* otherwise, so only a completed handshake is counted.
*
* ⚠️ This is the same counter, in production form, that the socket-factory test
* needed before it meant anything — a test fetching an upstream URL passes
* whether or not the transport was used, because a bypassed connection returns
* the same bytes.
*
* ⚠️ Process-lifetime only, deliberately. Persisting it would let a screen
* report "working" on the strength of a handshake from a different network
* three weeks ago, which is the reassuring answer rather than the true one.
*/
object Obfs4Stats {
private val completed = AtomicInteger(0)
private val lastAt = AtomicLong(0L)
/** How many obfs4 handshakes have completed since the app started. */
fun completedHandshakes(): Int = completed.get()
/** `System.currentTimeMillis()` of the last one, or 0 if there has been none. */
fun lastHandshakeAt(): Long = lastAt.get()
internal fun recordHandshake() {
completed.incrementAndGet()
lastAt.set(System.currentTimeMillis())
}
/** For tests, which must not inherit a count from whatever ran before. */
fun reset() {
completed.set(0)
lastAt.set(0L)
}
}
@@ -0,0 +1,194 @@
package io.flamenet.obfs4
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Reading a pasted bridge line, and refusing one with a reason.
*
* 🔴 **The bug these exist for: a wrong line is indistinguishable from no
* line.** Anything that fails to parse becomes "no bridge", and "no bridge" is
* a direct connection — which is exactly the path somebody pasting a bridge is
* trying to leave. Every test below is about making a failure say which failure
* it is.
*/
class BridgeLineTest {
private fun certOf(bytes: Int) =
java.util.Base64.getEncoder().encodeToString(ByteArray(bytes) { 0xAB.toByte() })
.trimEnd('=')
private fun line(
cert: String = certOf(52),
address: String = "198.51.100.7:4444",
iatMode: String = "0",
) = "obfs4 $address 0123456789ABCDEF0123456789ABCDEF01234567 cert=$cert iat-mode=$iatMode"
// ---- Accepting what is right
@Test
fun `a well formed line parses`() {
val result = BridgeLine.parse(line())
assertTrue(result is BridgeLineResult.Ok, "a real line must be accepted")
assertEquals("198.51.100.7", result.bridge.host)
assertEquals(4444, result.bridge.port)
assertEquals(20, result.bridge.nodeId.size)
assertEquals(32, result.bridge.identityPublic.size)
}
/**
* ⚠️ The fingerprint and `iat-mode` appear in every real line and are used
* by neither this client nor the check above. A parser that choked on
* unrecognised fields would reject every line an operator hands out.
*/
@Test
fun `unknown fields are ignored`() {
val result = BridgeLine.parse(line() + " sni=example.com unknown-flag=1")
assertTrue(result is BridgeLineResult.Ok)
}
// ---- Refusing what is wrong, and saying why
@Test
fun `each kind of bad line names its own problem`() {
val cases = listOf<Pair<String, BridgeLineProblem>>(
"" to BridgeLineProblem.Empty,
"obfs4 cert=abc" to BridgeLineProblem.NoAddress,
line(address = "198.51.100.7:notaport") to BridgeLineProblem.BadPort("notaport"),
"obfs4 198.51.100.7:4444 0123456789ABCDEF" to BridgeLineProblem.NoCert,
// ⚠️ No spaces: fields split on whitespace, so a cert with a space
// is truncated at the space and is a LENGTH problem, not an
// encoding one. This is the character-invalid case.
line(cert = "!!!!not@@@@valid####base64$$$$") to BridgeLineProblem.CertNotReadable,
)
for ((input, expected) in cases) {
val result = BridgeLine.parse(input)
assertTrue(result is BridgeLineResult.Bad, "\"$input\" should not have been accepted")
assertEquals(expected, result.problem, "wrong reason for \"$input\"")
}
}
/**
* 🔴 **The likeliest real mistake, and the one Java's decoder got wrong.**
* Bridge lines wrap in every chat app and terminal, so copying only the
* first line is ordinary. The previous decode appended `==`, which THROWS
* for most cut positions — 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". Swept across cut
* positions because a single length would prove it for that cut and nothing
* else.
*/
@Test
fun `a truncated cert is reported as truncated rather than unreadable`() {
for (count in listOf(10, 20, 29, 30, 31, 40, 50, 51)) {
val result = BridgeLine.parse(line(cert = certOf(count)))
assertTrue(result is BridgeLineResult.Bad, "a $count-byte cert must not be accepted")
val problem = result.problem
assertTrue(
problem is BridgeLineProblem.CertWrongLength,
"cut at $count bytes reported $problem, not a length",
)
assertEquals(count, problem.got)
assertTrue(
problem.message.lowercase().contains("cut short"),
"the message must point at the wrap, not at the encoding",
)
}
}
/**
* ⚠️ Every message is read by somebody who was handed a line and told to
* paste it. Protocol vocabulary in an error tells them they are not
* qualified to fix it.
*/
@Test
fun `problem messages avoid protocol jargon`() {
val jargon = listOf("obfs4", "base64", "node id", "nodeid", "elligator", "ntor", "parse")
val problems = listOf(
BridgeLineProblem.Empty,
BridgeLineProblem.NoAddress,
BridgeLineProblem.BadPort("x"),
BridgeLineProblem.NoCert,
BridgeLineProblem.CertNotReadable,
BridgeLineProblem.CertWrongLength(30),
BridgeLineProblem.CertWrongLength(90),
)
for (problem in problems) {
val text = problem.message.lowercase()
assertTrue(problem.message.isNotEmpty())
for (word in jargon) {
assertFalse(text.contains(word), "\"$word\" in: ${problem.message}")
}
}
}
// ---- iat-mode is a caveat, not a rejection
/**
* 🔴 Modes 1 and 2 ask the CLIENT to space its writes; the bridge does not
* enforce them. Refusing such a line would take a working bridge away from
* somebody who has no other one.
*/
@Test
fun `an unsupported iat mode still connects and says what is missing`() {
val result = BridgeLine.parse(line(iatMode = "1"))
assertTrue(result is BridgeLineResult.Ok, "iat-mode 1 works; it must not be rejected")
assertNotNull(result.caveat, "the missing timing camouflage has to be said out loud")
}
@Test
fun `iat mode zero has no caveat`() {
val result = BridgeLine.parse(line(iatMode = "0"))
assertTrue(result is BridgeLineResult.Ok)
assertNull(result.caveat)
}
/**
* 🔴 **The two apps are handed the same lines by the same operators.** These
* sentences are duplicated in the iOS repo's `Obfs4Settings.Problem`, and
* this pins the exact strings so a change on one side is a visible decision
* rather than a silent divergence. If you edit a message, edit both.
*/
@Test
fun `messages match the ones iOS shows`() {
assertEquals(
"Paste the line your bridge provider gave you.",
BridgeLineProblem.Empty.message,
)
assertEquals(
"The part starting cert= is missing. That's the piece that proves you're " +
"talking to the right bridge, so it's worth going back for the whole line.",
BridgeLineProblem.NoCert.message,
)
assertEquals(
"This line looks cut short. They're long enough that most apps wrap them " +
"onto a second line, and it's easy to copy only the first — check " +
"you've got all of it.",
BridgeLineProblem.CertWrongLength(30).message,
)
}
// ---- The address
/** ⚠️ Split from the LAST colon, or an IPv6 literal loses its own. */
@Test
fun `an ipv6 literal keeps its colons`() {
val result = BridgeLine.parse(line(address = "[2001:db8::1]:9001"))
assertTrue(result is BridgeLineResult.Ok)
assertEquals("[2001:db8::1]", result.bridge.host)
assertEquals(9001, result.bridge.port)
}
@Test
fun `port zero and out of range ports are refused`() {
for (port in listOf("0", "65536", "-1")) {
val result = BridgeLine.parse(line(address = "198.51.100.7:$port"))
assertTrue(result is BridgeLineResult.Bad, "port $port must not be accepted")
assertTrue(result.problem is BridgeLineProblem.BadPort)
}
}
}