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
@@ -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",
)
}
}