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
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user