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:
Paul Hitt
2026-09-06 19:56:58 -04:00
co-authored by Claude Opus 5
parent 9ab6384638
commit 1fd61ce124
9 changed files with 468 additions and 3 deletions
@@ -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())
}
}