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()) + } +}