Merge branch 'feat/share-a-bridge-with-a-contact' into 'main'

Hand a contact the bridge you use (§6.19)

See merge request paulhitt/flamenet-messenger-android!83
This commit was merged in pull request #110.
This commit is contained in:
Paul Hitt
2026-09-06 23:59:34 +00:00
9 changed files with 468 additions and 3 deletions
@@ -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"
@@ -1087,6 +1087,31 @@ class Conversations(
sendPlaintext(toUserId, Content.disappearing(seconds))
}
/**
* Offer a contact the bridge this phone uses (§6.19).
*
* 🔴 **Nothing is stored locally, unlike [setDisappearing].** This changes
* nothing here — it hands somebody a line and lets them decide. The
* asymmetry is the point: a timer is a property of a conversation both
* ends must share, and a bridge is a route one person chooses for
* themselves.
*
* ⚠️ Rides the ratchet like any other message, so the relay sees a
* conversation happening and not a bridge being handed out. That is most
* of why this exists: a bridge is only useful while whoever wants to block
* it does not have the line, and every other way of distributing one —
* a web page, an email, a channel — is a place to go and collect them.
*/
fun shareBridge(toUserId: Long, line: String) {
sendPlaintext(toUserId, Content.bridgeOffer(line))
}
/** Bridge lines contacts have offered, newest first. Never applied here. */
fun bridgeOffers(): List<Vault.BridgeOffer> = vault.bridgeOffers()
/** Forget an offer, whether it was accepted or turned down. */
fun dismissBridgeOffer(fromUser: Long) = vault.removeBridgeOffer(fromUser)
/** This conversation's timer in seconds, 0 when off. */
fun disappearSeconds(toUserId: Long): Int =
vault.disappearSeconds(Vault.threadForUser(toUserId))
@@ -1685,6 +1710,18 @@ class Conversations(
// The other side set a timer (§6.11). Adopted rather than offered:
// a timer both parties have to agree to separately is a timer that
// is off whenever one of them has not noticed the prompt.
// A contact offering the bridge they use (§6.19).
//
// 🔴 **Stored as an OFFER and never applied.** Turning this on
// because it arrived would let a contact — or whoever has taken
// their account — move this phone's traffic onto a host they
// chose. They could not read it, but they would learn who is
// online and when, and could cut them off. The person it was sent
// to is the only thing that turns it on.
Content.Kind.BRIDGE -> {
val line = Content.bridgeLine(content) ?: return
vault.saveBridgeOffer(fromUser, line, at = System.currentTimeMillis() / 1000)
}
Content.Kind.DISAPPEAR -> {
val seconds = Content.disappearSeconds(content) ?: return
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
@@ -349,6 +349,25 @@ data class Content(
*/
const val AVATAR = "avatar"
/**
* A bridge line, offered by a contact (§6.19).
*
* 🔴 **An OFFER, never a setting.** A bridge line tells this phone to
* route every relay connection through a machine somebody else chose.
* Applying one because it arrived would mean a contact — or anyone who
* has taken their account — could silently 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. So this is stored as a
* pending offer and applied only by the person it was sent to.
*
* ⚠️ **Control, so older builds swallow it.** A build that has never
* heard of this kind consumes it via the `ctl` flag and shows nothing.
* The alternative — a bubble full of JSON in somebody's conversation —
* is what `ctl` was introduced to stop.
*/
const val BRIDGE = "bridge"
/**
* A per-conversation disappearing-message timer, in seconds (§6.11).
*
@@ -385,7 +404,7 @@ data class Content(
* chat.
*/
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR,
SCREENSHOT)
SCREENSHOT, BRIDGE)
/**
* Every kind this build can act on.
@@ -683,6 +702,34 @@ data class Content(
fun screenshot(atSeconds: Long): String =
"{\"fnc\":1,\"kind\":${quote(Kind.SCREENSHOT)},\"ctl\":1,\"at\":$atSeconds}"
/**
* Offer a contact the bridge this phone is using (§6.19).
*
* ⚠️ The line goes across whole, exactly as an operator wrote it,
* because the `cert=` is what makes it usable and the fingerprint is
* what the receiving person may want to check against another source.
* Reassembling it from parsed fields at the far end would lose both.
*
* 🔴 `1`, not `true`, for the same reason [disappearing] documents at
* length: iOS decodes `ctl` as a number, and a boolean fails the whole
* envelope's decode rather than merely failing to set the flag.
*/
fun bridgeOffer(line: String): String =
"{\"fnc\":1,\"kind\":${quote(Kind.BRIDGE)},\"ctl\":1,\"line\":${quote(line)}}"
/**
* The bridge line in a [Kind.BRIDGE] envelope, or null.
*
* ⚠️ Returns the raw string and validates nothing. Whether a line is
* usable is `BridgeLine.parse`'s question, and it is asked on the
* screen where a person decides whether to accept — not here, where a
* rejection would be silent.
*/
fun bridgeLine(content: Content): String? =
if (content.kind == Kind.BRIDGE) {
content.raw?.optString("line")?.takeIf { it.isNotBlank() }
} else null
/** When a [Kind.SCREENSHOT] envelope says it happened, or null. */
fun screenshotAt(content: Content): Long? =
if (content.kind == Kind.SCREENSHOT) {
@@ -85,6 +85,9 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val STORY_MUTES = "story-mutes"
private const val STORY_ARCHIVE = "story-archive"
private const val DISAPPEAR = "disappear"
/** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */
private const val BRIDGE_OFFERS = "bridge-offers"
private const val SNOOZE = "snooze"
private const val REPLY_REMINDER = "reply-reminder"
private const val ROSTER = "roster"
@@ -1243,6 +1246,46 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun disappearSeconds(threadId: String): Int =
readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0
/**
* A bridge line a contact offered, and who offered it (§6.19).
*
* 🔴 **Stored, never applied.** A bridge line routes 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: not readable, 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 layer
* writes it here and the person it was sent to is the only thing that can
* turn it on.
*
* ⚠️ One offer per contact, replaced rather than accumulated. An operator
* who rotates a bridge sends a new line, and a list of every line they
* ever had is a menu of stale hosts.
*/
fun bridgeOffers(): List<BridgeOffer> {
val all = readJson(BRIDGE_OFFERS) ?: return emptyList()
return all.keys().asSequence().mapNotNull { key ->
val o = all.optJSONObject(key) ?: return@mapNotNull null
val line = o.optString("line").takeIf { it.isNotBlank() } ?: return@mapNotNull null
BridgeOffer(fromUser = key.toLongOrNull() ?: return@mapNotNull null,
line = line, at = o.optLong("at", 0L))
}.sortedByDescending { it.at }.toList()
}
fun saveBridgeOffer(fromUser: Long, line: String, at: Long) {
val all = readJson(BRIDGE_OFFERS) ?: JSONObject()
all.put(fromUser.toString(), JSONObject().put("line", line).put("at", at))
writeJson(BRIDGE_OFFERS, all)
}
fun removeBridgeOffer(fromUser: Long) {
val all = readJson(BRIDGE_OFFERS) ?: return
all.remove(fromUser.toString())
writeJson(BRIDGE_OFFERS, all)
}
data class BridgeOffer(val fromUser: Long, val line: String, val at: Long)
fun saveDisappearSeconds(threadId: String, seconds: Int) {
val all = readJson(DISAPPEAR) ?: JSONObject()
if (seconds <= 0) all.remove(threadId) else all.put(threadId, seconds)
@@ -0,0 +1,98 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Handing a contact the bridge you use (§6.19).
*
* 🔴 **The security property under test is that this is an OFFER.** A bridge
* line routes every relay connection through a machine somebody else chose. A
* contact — or whoever has taken their account — could not read the traffic,
* because the ratchet does not care what carries it, but they would learn who
* is online and when, and could cut them off. So nothing in the engine or the
* transport may apply one; only the person it was sent to can.
*/
class BridgeOfferTest {
private val line =
"obfs4 198.51.100.7:4444 0123456789ABCDEF0123456789ABCDEF01234567 " +
"cert=" + java.util.Base64.getEncoder()
.encodeToString(ByteArray(52) { 0xAB.toByte() }).trimEnd('=') +
" iat-mode=0"
@Test
fun `an offer round trips through the envelope`() {
val parsed = Content.parse(Content.bridgeOffer(line))
assertEquals(Content.Kind.BRIDGE, parsed.kind)
assertEquals(line, Content.bridgeLine(parsed))
}
/**
* 🔴 **`1`, not `true`.** iOS decodes `ctl` as a number, and a boolean does
* not merely fail to set the flag — it fails the whole envelope's decode
* and falls through to the plain-text path, which is how an Android user
* once sent an iPhone a bubble of raw JSON. Asserted on the wire rather
* than through the parser, because the parser here would accept both.
*/
@Test
fun `the control flag is the number one on the wire`() {
val json = JSONObject(Content.bridgeOffer(line))
assertEquals(1, json.getInt("ctl"))
assertEquals(Content.Kind.BRIDGE, json.getString("kind"))
}
/**
* 🔴 **What an older build does with this.** A build that has never heard
* of this kind must swallow it, not draw it. `ctl` is what makes that true,
* and membership of CONTROL is what routes it to a handler here.
*/
@Test
fun `an offer is control traffic and never renders`() {
val parsed = Content.parse(Content.bridgeOffer(line))
assertTrue(parsed.isControl, "an offer must never become a bubble")
assertFalse(parsed.isRenderable)
assertTrue(Content.Kind.BRIDGE in Content.Kind.CONTROL)
}
/** ⚠️ The line crosses whole. Rebuilding it from parsed fields at the far
* end would lose the `cert=` that makes it usable and the fingerprint
* somebody may want to check against another source. */
@Test
fun `the line is carried verbatim`() {
val awkward = "obfs4 [2001:db8::1]:9001 ABC cert=zz+/9 iat-mode=1 sni=x.example"
assertEquals(awkward, Content.bridgeLine(Content.parse(Content.bridgeOffer(awkward))))
}
/** ⚠️ Asked of the wrong kind, it says nothing rather than guessing. */
@Test
fun `another kind carries no bridge line`() {
assertNull(Content.bridgeLine(Content.parse(Content.disappearing(60))))
assertNull(Content.bridgeLine(Content.parse("just a message")))
}
/**
* ⚠️ An empty or whitespace line is not an offer. Storing one would put an
* un-actionable row on the settings screen with somebody's name against it.
*/
@Test
fun `an empty line is not an offer`() {
assertNull(Content.bridgeLine(Content.parse(Content.bridgeOffer(" "))))
}
/**
* 🔴 **The engine validates nothing, deliberately.** Whether a line is
* usable is asked on the screen where a person decides, so a bad one is
* reported to them with a reason. Rejecting it here would drop it in
* silence — the exact failure the settings screen was built to end.
*/
@Test
fun `a malformed line still arrives, to be judged where it can be explained`() {
val junk = "obfs4 this-is-not-a-bridge-line"
assertEquals(junk, Content.bridgeLine(Content.parse(Content.bridgeOffer(junk))))
}
}
@@ -0,0 +1,100 @@
package io.flamenet.e2e
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Where a bridge somebody offered is kept, and what keeping it does not do.
*
* 🔴 **Storing an offer must change nothing about where traffic goes.** That is
* the whole reason offers exist as a separate thing from the configured bridge:
* a contact — or whoever has taken their account — must not be able to move
* another person's traffic onto a host of their choosing by sending a message.
*/
class BridgeOfferVaultTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(name: String, bytes: ByteArray) { records[name] = bytes }
override fun delete(name: String) { records.remove(name) }
override fun names() = records.keys.toList()
}
private fun key(): SecretKey =
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun vault() = Vault(MapStorage(), key())
private fun line(host: String) =
"obfs4 $host:4444 0123456789ABCDEF cert=abc iat-mode=0"
@Test
fun `an offer survives being stored and read back`() {
val v = vault()
v.saveBridgeOffer(fromUser = 42, line = line("198.51.100.7"), at = 1_700_000_000)
val offers = v.bridgeOffers()
assertEquals(1, offers.size)
assertEquals(42L, offers[0].fromUser)
assertEquals(line("198.51.100.7"), offers[0].line)
assertEquals(1_700_000_000L, offers[0].at)
}
/**
* ⚠️ One offer per contact. An operator who rotates a bridge sends a new
* line, and a list of every line they ever had is a menu of stale hosts.
*/
@Test
fun `a second offer from the same contact replaces the first`() {
val v = vault()
v.saveBridgeOffer(42, line("198.51.100.7"), 1_700_000_000)
v.saveBridgeOffer(42, line("203.0.113.9"), 1_700_000_100)
val offers = v.bridgeOffers()
assertEquals(1, offers.size)
assertEquals(line("203.0.113.9"), offers[0].line)
}
@Test
fun `offers from different contacts are kept apart, newest first`() {
val v = vault()
v.saveBridgeOffer(1, line("198.51.100.7"), 1_700_000_000)
v.saveBridgeOffer(2, line("203.0.113.9"), 1_700_000_500)
val offers = v.bridgeOffers()
assertEquals(listOf(2L, 1L), offers.map { it.fromUser })
}
@Test
fun `an offer can be forgotten`() {
val v = vault()
v.saveBridgeOffer(42, line("198.51.100.7"), 1_700_000_000)
v.removeBridgeOffer(42)
assertTrue(v.bridgeOffers().isEmpty())
}
/**
* 🔴 **The load-bearing one.** Offers live in their own record and touch
* nothing else. If storing an offer ever wrote to whatever the app reads
* for its configured bridge, a message would become a route — and the whole
* point of the offer/accept split would be gone while every other test here
* still passed.
*/
@Test
fun `storing an offer writes only the offers record`() {
val storage = MapStorage()
val v = Vault(storage, key())
val before = storage.records.keys.toSet()
v.saveBridgeOffer(42, line("198.51.100.7"), 1_700_000_000)
val written = storage.records.keys.toSet() - before
assertEquals(setOf("bridge-offers"), written,
"an offer touched something other than the offers record")
}
/** ⚠️ Reading offers on a fresh device is empty, not a crash. */
@Test
fun `no offers reads as empty`() {
assertTrue(vault().bridgeOffers().isEmpty())
}
}