From 1fd61ce124fbc17bee081bdae633aac661cd714b Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 6 Sep 2026 19:56:58 -0400 Subject: [PATCH] =?UTF-8?q?Hand=20a=20contact=20the=20bridge=20you=20use?= =?UTF-8?q?=20(=C2=A76.19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 2 +- .../messenger/app/MessengerViewModel.kt | 53 ++++++++++ .../flamenet/messenger/app/NetworkSection.kt | 75 ++++++++++++- .../flamenet/messenger/app/Obfs4Settings.kt | 14 +++ .../io/flamenet/client/Conversations.kt | 37 +++++++ .../main/kotlin/io/flamenet/e2e/Content.kt | 49 ++++++++- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 43 ++++++++ .../kotlin/io/flamenet/e2e/BridgeOfferTest.kt | 98 +++++++++++++++++ .../io/flamenet/e2e/BridgeOfferVaultTest.kt | 100 ++++++++++++++++++ 9 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index f31e05b..64e2af8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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() } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 37fc5a1..8896bcd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 { + 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) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt index 157cabe..200c4cb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/NetworkSection.kt @@ -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. * diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt index 0523cb6..6830853 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Obfs4Settings.kt @@ -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" diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index feaf925..7146c25 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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.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) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 7653742..bf1bab2 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -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) { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 6bd52ab..c876446 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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 { + 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) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt new file mode 100644 index 0000000..bd8e959 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferTest.kt @@ -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)))) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt new file mode 100644 index 0000000..462debc --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/BridgeOfferVaultTest.kt @@ -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() + 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()) + } +}