Hand a contact the bridge you use (§6.19)
A bridge only works while whoever wants to block it does not have the line. Every ordinary way of distributing one — a web page, an email, a channel — is a place a censor can go and collect them, which is why Tor needs BridgeDB, moat and domain-fronted rendezvous and still gets its bridges enumerated. This app already has the thing that solves it: an encrypted channel between people who know each other. A line handed over that rail is invisible to the relay, and enumerating it means infiltrating one friendship at a time. 🔴 **An offer, never a setting.** A bridge line tells a phone to route every relay connection through a machine somebody else chose. Applying one on arrival would let a contact — or whoever has taken their account — move another person's traffic onto a host they control. They could not read it, because the ratchet does not care what carries it, but they would learn who is online and when, and could cut them off at will. So the transport stores it and a button applies it, pressed by the person it was sent to. The test that holds that line asserts storing an offer writes ONLY the offers record. Make `saveBridgeOffer` also write a configured bridge and it reddens, alone, while every other test in the file still passes — which is what makes it worth having. The line is shown in full on the screen, not hidden behind "Accept". Somebody may want to check the fingerprint against another source before routing their traffic through it, and they cannot check what they cannot see. A line that arrived broken says which part is wrong, using the same validator the paste field uses, rather than failing when accepted. ⚠️ **`"ctl":1`, so 0.10 swallows it.** A build that has never heard of this kind consumes the envelope and shows nothing. The alternative — a bubble full of JSON in somebody's conversation — is the exact bug the `ctl` flag was introduced to stop, and it would have shipped to every older install. Asserted on the wire as the NUMBER one, because iOS decodes it as a number and a boolean fails the whole envelope's decode rather than merely failing to set the flag. 1175 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9ab6384638
commit
1fd61ce124
@@ -805,7 +805,7 @@ fun ColumnScope.AccountScreen(
|
||||
// from it — filing it as privacy would make the claim the section itself
|
||||
// is careful not to make.
|
||||
SectionBand()
|
||||
SettingsSection { NetworkSection() }
|
||||
SettingsSection { NetworkSection(model) }
|
||||
|
||||
SectionBand()
|
||||
SettingsSection { ParentalControlsSection() }
|
||||
|
||||
@@ -1004,6 +1004,59 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* relay, the relay could answer for both — which is exactly the party the
|
||||
* comparison is meant to exclude.
|
||||
*/
|
||||
/**
|
||||
* Bridge lines contacts have offered, with a name against each (§6.19).
|
||||
*
|
||||
* ⚠️ Resolved to a display name here rather than stored with one. A name
|
||||
* changes; the offer is keyed on the account it came from, so a contact who
|
||||
* renames themselves does not turn into a second, anonymous offer.
|
||||
*/
|
||||
data class BridgeOfferView(
|
||||
val fromUser: Long,
|
||||
val fromName: String,
|
||||
val line: String,
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
fun bridgeOffers(): List<BridgeOfferView> {
|
||||
val current = session ?: return emptyList()
|
||||
val buddies = (_phase.value as? Phase.SignedIn)?.buddies.orEmpty()
|
||||
.associateBy { it.userId }
|
||||
return current.conversations.bridgeOffers().map { offer ->
|
||||
BridgeOfferView(
|
||||
fromUser = offer.fromUser,
|
||||
// ⚠️ Falls back to the id rather than to "someone". A row you
|
||||
// cannot attribute is one you cannot make a decision about, and
|
||||
// an id is at least checkable against the conversation list.
|
||||
fromName = buddies[offer.fromUser]?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: buddies[offer.fromUser]?.handle
|
||||
?: "account ${offer.fromUser}",
|
||||
line = offer.line,
|
||||
at = offer.at,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget an offer. Called whether it was accepted or turned down. */
|
||||
fun dismissBridgeOffer(fromUser: Long) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { current.conversations.dismissBridgeOffer(fromUser) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a contact the bridge this phone is using.
|
||||
*
|
||||
* ⚠️ Refuses when nothing is configured rather than sending an empty line.
|
||||
* "Share" on a screen with no bridge set is a button that should not have
|
||||
* been enabled, and sending blankly would put an un-actionable row on
|
||||
* somebody else's settings screen.
|
||||
*/
|
||||
fun shareBridge(toUserId: Long) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val line = Obfs4Settings(getApplication()).bridgeLine ?: return@launch
|
||||
withContext(Dispatchers.IO) { current.conversations.shareBridge(toUserId, line) }
|
||||
}
|
||||
|
||||
fun setVerified(peerId: Long, verified: Boolean) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) { current.conversations.trust.setVerified(peerId, verified) }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -40,7 +42,7 @@ import io.flamenet.obfs4.Obfs4Stats
|
||||
* bridge then proxies to the relay, which sees exactly what it saw before.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.NetworkSection() {
|
||||
fun ColumnScope.NetworkSection(model: MessengerViewModel? = null) {
|
||||
val context = LocalContext.current
|
||||
val settings = remember { Obfs4Settings(context) }
|
||||
var draft by remember { mutableStateOf(settings.bridgeLine ?: "") }
|
||||
@@ -65,6 +67,75 @@ fun ColumnScope.NetworkSection() {
|
||||
modifier = Modifier.testTag("obfs4-status"),
|
||||
)
|
||||
|
||||
// 🔴 **Offers from contacts, and the reason they are a list and not a
|
||||
// setting.** A bridge line routes every relay connection through a machine
|
||||
// somebody else chose. Nothing applies one on arrival — a contact, or
|
||||
// whoever has taken their account, could otherwise move this phone's
|
||||
// traffic onto a host they control. They could not read it, but they would
|
||||
// learn who is online and when, and could cut them off. Accepting is a
|
||||
// button, pressed by the person it was sent to.
|
||||
val offers = remember(tick) { model?.bridgeOffers().orEmpty() }
|
||||
if (offers.isNotEmpty()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text("Offered to you", fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
for (offer in offers) {
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Text(
|
||||
"${offer.fromName} shared a bridge",
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("obfs4-offer-${offer.fromUser}"),
|
||||
)
|
||||
// ⚠️ The line is shown, not hidden behind "Accept". Somebody may
|
||||
// want to check the fingerprint against another source before
|
||||
// routing their traffic through it, and they cannot check what
|
||||
// they cannot see.
|
||||
Text(
|
||||
offer.line,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
when (val parsed = BridgeLine.parse(offer.line)) {
|
||||
is BridgeLineResult.Bad -> Text(
|
||||
// An offer that cannot be used says why, rather than
|
||||
// failing when it is accepted.
|
||||
parsed.problem.message,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.Danger,
|
||||
)
|
||||
is BridgeLineResult.Ok -> Text(
|
||||
"Reads as ${parsed.bridge.host} port ${parsed.bridge.port}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
KitPrimaryButton(
|
||||
text = "Use this bridge",
|
||||
onClick = {
|
||||
if (settings.accept(offer.line) is BridgeLineResult.Ok) {
|
||||
draft = offer.line
|
||||
stored = settings.bridgeLine
|
||||
model?.dismissBridgeOffer(offer.fromUser)
|
||||
tick++
|
||||
}
|
||||
},
|
||||
enabled = parsedOk(offer.line),
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "obfs4-offer-accept-${offer.fromUser}",
|
||||
)
|
||||
Spacer(Modifier.width(Space.sm))
|
||||
KitSecondaryButton(
|
||||
text = "Dismiss",
|
||||
onClick = { model?.dismissBridgeOffer(offer.fromUser); tick++ },
|
||||
modifier = Modifier.weight(1f),
|
||||
tag = "obfs4-offer-dismiss-${offer.fromUser}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -152,6 +223,8 @@ fun ColumnScope.NetworkSection() {
|
||||
Text(EXPLANATION, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
|
||||
private fun parsedOk(line: String) = BridgeLine.parse(line) is BridgeLineResult.Ok
|
||||
|
||||
/**
|
||||
* What is actually happening, as opposed to what is stored.
|
||||
*
|
||||
|
||||
@@ -88,6 +88,20 @@ class Obfs4Settings(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a bridge a contact offered.
|
||||
*
|
||||
* 🔴 **The only path from an offer to a route, and it takes a person.**
|
||||
* The transport layer stores offers and applies none of them; this is
|
||||
* called from a button. Everything else about an offer — that it arrived,
|
||||
* who sent it, that it parses — changes nothing about where this phone's
|
||||
* traffic goes.
|
||||
*
|
||||
* Returns the same result [save] does, so a line that arrived broken is
|
||||
* reported with a reason rather than dropped.
|
||||
*/
|
||||
fun accept(offeredLine: String): BridgeLineResult = save(offeredLine)
|
||||
|
||||
private companion object {
|
||||
const val PREFS = "flamenet.obfs4"
|
||||
const val BRIDGE_LINE = "bridge-line"
|
||||
|
||||
Reference in New Issue
Block a user