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